diff --git a/apps/swift-ios/App/Platform/PlatformIncomingShare.swift b/apps/swift-ios/App/Platform/PlatformIncomingShare.swift index f96834a5ad9..0b050bbb5dc 100644 --- a/apps/swift-ios/App/Platform/PlatformIncomingShare.swift +++ b/apps/swift-ios/App/Platform/PlatformIncomingShare.swift @@ -1,10 +1,17 @@ +import AVFoundation +import CoreGraphics import Foundation +import ImageIO import Observation import SwiftUI +import UniformTypeIdentifiers enum PlatformIncomingShareError: LocalizedError, Equatable { case missingImage(String) case invalidImage(String) + case missingVideo(String) + case invalidVideo(String) + case unreadableVideo(String) case invalidEnvelope var errorDescription: String? { @@ -13,6 +20,12 @@ enum PlatformIncomingShareError: LocalizedError, Equatable { "The shared image \(name) is no longer available. Share it again to retry." case let .invalidImage(name): "The shared image \(name) is incomplete or too large. Share it again to retry." + case let .missingVideo(name): + "The shared video \(name) is no longer available. Share it again to retry." + case let .invalidVideo(name): + "The shared video \(name) is incomplete or too large. Share it again to retry." + case let .unreadableVideo(name): + "T3 Code could not create representative frames from \(name)." case .invalidEnvelope: "This shared item is invalid. Share it again to retry." } @@ -22,7 +35,18 @@ enum PlatformIncomingShareError: LocalizedError, Equatable { struct PlatformIncomingShareSource: Sendable { var loadAll: @Sendable () async -> [T3IncomingShareEnvelope] var data: @Sendable (T3IncomingShareImage) async throws -> Data + var videoURL: @Sendable (T3IncomingShareVideo) async throws -> URL = { video in + throw PlatformIncomingShareError.missingVideo(video.fileName) + } var remove: @Sendable (String) async throws -> Void + var updateDestination: @Sendable ( + String, + T3IncomingShareDestination? + ) async throws -> Void = { id, destination in + try await Task.detached(priority: .utility) { + try T3IncomingShareStore.updateDestination(id: id, destination: destination) + }.value + } static let live = PlatformIncomingShareSource( loadAll: { @@ -49,6 +73,22 @@ struct PlatformIncomingShareSource: Sendable { } return data }, + videoURL: { video in + guard let root = T3SharedContainer.rootURL?.standardizedFileURL, + let url = T3IncomingShareStore.fileURL(for: video)?.standardizedFileURL, + url.path.hasPrefix(root.path + "/") else { + throw PlatformIncomingShareError.missingVideo(video.fileName) + } + let values = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) + guard values.isRegularFile == true, + let byteCount = values.fileSize, + byteCount > 0, + byteCount <= T3IncomingShareStore.maximumVideoBytes, + byteCount == video.byteCount else { + throw PlatformIncomingShareError.invalidVideo(video.fileName) + } + return url + }, remove: { id in guard UUID(uuidString: id) != nil else { throw PlatformIncomingShareError.invalidEnvelope @@ -60,6 +100,16 @@ struct PlatformIncomingShareSource: Sendable { ) } +struct PlatformIncomingShareDraftImport: Sendable { + let draft: FeatureComposerDraft + let didImport: Bool +} + +struct PlatformIncomingShareImport: Sendable { + let draft: FeatureComposerDraft + let sharedContent: FeatureComposerIncomingShareDraft +} + struct PlatformIncomingShareDraftRepository: Sendable { var importContent: @Sendable ( _ shareID: String, @@ -67,17 +117,21 @@ struct PlatformIncomingShareDraftRepository: Sendable { _ attachments: [FeatureDraftAttachment], _ key: String, _ maximumAttachmentCount: Int - ) async throws -> FeatureComposerDraft + ) async throws -> PlatformIncomingShareDraftImport static let live = PlatformIncomingShareDraftRepository( importContent: { shareID, text, attachments, key, maximumAttachmentCount in - try await FeatureComposerDraftStore.shared.importSharedContent( + let result = try await FeatureComposerDraftStore.shared.importSharedContentResult( shareID: shareID, text: text, attachments: attachments, for: key, maximumAttachmentCount: maximumAttachmentCount ) + return PlatformIncomingShareDraftImport( + draft: result.draft, + didImport: result.didImport + ) } ) } @@ -91,6 +145,11 @@ struct PlatformIncomingSharePipeline: Sendable { private let source: PlatformIncomingShareSource private let drafts: PlatformIncomingShareDraftRepository private let prepareImage: @Sendable (Data, Int) async throws -> FeatureDraftAttachment + private let prepareVideo: @Sendable ( + URL, + T3IncomingShareVideo, + Int + ) async throws -> FeatureDraftAttachment init( source: PlatformIncomingShareSource = .live, @@ -101,11 +160,23 @@ struct PlatformIncomingSharePipeline: Sendable { try await Task.detached(priority: .userInitiated) { try FeatureImageProcessor.attachment(from: data, ordinal: ordinal) }.value + }, + prepareVideo: @escaping @Sendable ( + URL, + T3IncomingShareVideo, + Int + ) async throws -> FeatureDraftAttachment = { url, video, ordinal in + try await PlatformSharedVideoProcessor.contactSheetAttachment( + videoURL: url, + video: video, + ordinal: ordinal + ) } ) { self.source = source self.drafts = drafts self.prepareImage = prepareImage + self.prepareVideo = prepareVideo } func pendingEnvelopes() async -> [T3IncomingShareEnvelope] { @@ -116,12 +187,55 @@ struct PlatformIncomingSharePipeline: Sendable { _ envelope: T3IncomingShareEnvelope, into project: FeatureProject ) async throws -> FeatureComposerDraft { + try await importEnvelope( + envelope, + draftKey: FeatureComposerDraftStore.newTaskKey(project: project), + removesEnvelope: true + ).draft + } + + func importEnvelope( + _ envelope: T3IncomingShareEnvelope, + into thread: FeatureThread + ) async throws -> PlatformIncomingShareImport { + try await importEnvelope( + envelope, + draftKey: FeatureComposerDraftStore.threadKey(thread), + removesEnvelope: true + ) + } + + func stageEnvelopeForNewThread( + _ envelope: T3IncomingShareEnvelope + ) async throws -> FeatureComposerDraft { + try await importEnvelope( + envelope, + draftKey: FeatureComposerDraftStore.incomingShareKey(shareID: envelope.id), + removesEnvelope: false + ).draft + } + + func acknowledgeEnvelope(id: String) async throws { + try await source.remove(id) + } + + func updateDestination( + id: String, + destination: T3IncomingShareDestination? + ) async throws { + try await source.updateDestination(id, destination) + } + + private func importEnvelope( + _ envelope: T3IncomingShareEnvelope, + draftKey: String, + removesEnvelope: Bool + ) async throws -> PlatformIncomingShareImport { guard UUID(uuidString: envelope.id) != nil else { throw PlatformIncomingShareError.invalidEnvelope } - let key = FeatureComposerDraftStore.newTaskKey(project: project) var prepared: [FeatureDraftAttachment] = [] - prepared.reserveCapacity(envelope.images.count) + prepared.reserveCapacity(envelope.images.count + envelope.videos.count) for (offset, image) in envelope.images.enumerated() { let data = try await source.data(image) let attachment = try await prepareImage( @@ -131,18 +245,45 @@ struct PlatformIncomingSharePipeline: Sendable { prepared.append(Self.stableAttachment(attachment, for: image)) } - let merged = try await drafts.importContent( + for (offset, video) in envelope.videos.enumerated() { + let url = try await source.videoURL(video) + let attachment = try await prepareVideo( + url, + video, + envelope.images.count + offset + 1 + ) + prepared.append(Self.stableAttachment(attachment, for: video)) + } + + let sharedText = Self.composerText(for: envelope) + let imported = try await drafts.importContent( envelope.id, - envelope.text, + sharedText, prepared, - key, + draftKey, Self.maximumAttachmentCount ) // The repository's actor operation atomically merges the latest draft // and records the share ID. Never acknowledge the inbox before it ends. - try await source.remove(envelope.id) - return merged + if removesEnvelope { + try await source.remove(envelope.id) + } + return PlatformIncomingShareImport( + draft: imported.draft, + sharedContent: FeatureComposerIncomingShareDraft( + shareID: envelope.id, + draft: FeatureComposerDraft(text: sharedText, attachments: prepared) + ) + ) + } + + private static func composerText(for envelope: T3IncomingShareEnvelope) -> String { + var fragments = [envelope.text].filter { !$0.isEmpty } + fragments.append(contentsOf: envelope.videos.map { video in + "Shared video: \(video.fileName) (representative frames attached as a contact sheet)." + }) + return fragments.joined(separator: "\n\n") } private static func stableAttachment( @@ -157,6 +298,132 @@ struct PlatformIncomingSharePipeline: Sendable { mimeType: attachment.mimeType ) } + + private static func stableAttachment( + _ attachment: FeatureDraftAttachment, + for video: T3IncomingShareVideo + ) -> FeatureDraftAttachment { + FeatureDraftAttachment( + id: UUID(uuidString: video.id) ?? attachment.id, + data: attachment.data, + thumbnailData: attachment.thumbnailData, + filename: attachment.filename, + mimeType: attachment.mimeType + ) + } +} + +enum PlatformSharedVideoProcessor { + private static let frameCount = 6 + private static let cellSize = CGSize(width: 640, height: 360) + + static func contactSheetAttachment( + videoURL: URL, + video: T3IncomingShareVideo, + ordinal: Int + ) async throws -> FeatureDraftAttachment { + let data = try await Task.detached(priority: .userInitiated) { + let asset = AVURLAsset(url: videoURL) + let duration = try await asset.load(.duration) + let seconds = duration.seconds + guard seconds.isFinite, seconds > 0 else { + throw PlatformIncomingShareError.unreadableVideo(video.fileName) + } + + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + generator.maximumSize = cellSize + generator.requestedTimeToleranceBefore = CMTime(seconds: 0.25, preferredTimescale: 600) + generator.requestedTimeToleranceAfter = CMTime(seconds: 0.25, preferredTimescale: 600) + + let fractions = (0.. Data { + let columns = min(2, frames.count) + let rows = Int(ceil(Double(frames.count) / Double(columns))) + let width = Int(cellSize.width) * columns + let height = Int(cellSize.height) * rows + guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB), + let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + throw PlatformIncomingShareError.unreadableVideo(videoName) + } + + context.setFillColor(CGColor(gray: 0.04, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + for (index, frame) in frames.enumerated() { + let column = index % columns + let row = rows - 1 - (index / columns) + let cell = CGRect( + x: CGFloat(column) * cellSize.width, + y: CGFloat(row) * cellSize.height, + width: cellSize.width, + height: cellSize.height + ) + let scale = min( + cell.width / CGFloat(frame.width), + cell.height / CGFloat(frame.height) + ) + let size = CGSize( + width: CGFloat(frame.width) * scale, + height: CGFloat(frame.height) * scale + ) + let frameRect = CGRect( + x: cell.midX - size.width / 2, + y: cell.midY - size.height / 2, + width: size.width, + height: size.height + ) + context.draw(frame, in: frameRect) + } + + guard let image = context.makeImage() else { + throw PlatformIncomingShareError.unreadableVideo(videoName) + } + let data = NSMutableData() + guard let destination = CGImageDestinationCreateWithData( + data, + UTType.jpeg.identifier as CFString, + 1, + nil + ) else { + throw PlatformIncomingShareError.unreadableVideo(videoName) + } + CGImageDestinationAddImage( + destination, + image, + [kCGImageDestinationLossyCompressionQuality: 0.82] as CFDictionary + ) + guard CGImageDestinationFinalize(destination) else { + throw PlatformIncomingShareError.unreadableVideo(videoName) + } + return data as Data + } } @MainActor @@ -175,17 +442,26 @@ final class PlatformIncomingShareCoordinator { /// Returns true once per pending envelope when the app cannot offer a /// destination. The envelope remains in the shared container. - func refresh(hasProjects: Bool) async -> Bool { + func refresh(preferredID: String? = nil, hasProjects: Bool) async -> Bool { guard pendingEnvelope == nil, !isRefreshing, !isImporting else { return pendingEnvelope != nil + && pendingEnvelope?.destination == nil && !hasProjects && markNoProjectNoticeIfNeeded() } isRefreshing = true let envelopes = await pipeline.pendingEnvelopes() isRefreshing = false - pendingEnvelope = envelopes.first - guard pendingEnvelope != nil, !hasProjects else { return false } + if let preferredID { + pendingEnvelope = envelopes.first { + $0.id.caseInsensitiveCompare(preferredID) == .orderedSame + } + } else { + pendingEnvelope = envelopes.first + } + guard pendingEnvelope != nil, + pendingEnvelope?.destination == nil, + !hasProjects else { return false } return markNoProjectNoticeIfNeeded() } @@ -194,6 +470,13 @@ final class PlatformIncomingShareCoordinator { pendingEnvelope = nil } + func requestAnotherDestination() async throws { + guard !isImporting else { return } + guard let id = pendingEnvelope?.id else { return } + try await pipeline.updateDestination(id: id, destination: nil) + pendingEnvelope?.destination = nil + } + func importPending(into project: FeatureProject) async throws { guard let pendingEnvelope, !isImporting else { return } isImporting = true @@ -208,6 +491,42 @@ final class PlatformIncomingShareCoordinator { } } + func importPending(into thread: FeatureThread) async throws -> PlatformIncomingShareImport? { + guard let pendingEnvelope, !isImporting else { return nil } + isImporting = true + do { + let imported = try await pipeline.importEnvelope(pendingEnvelope, into: thread) + self.pendingEnvelope = nil + lastNoProjectNoticeID = nil + isImporting = false + return imported + } catch { + isImporting = false + throw error + } + } + + func stagePendingForNewThread() async throws -> String? { + guard let pendingEnvelope, !isImporting else { return nil } + isImporting = true + do { + _ = try await pipeline.stageEnvelopeForNewThread(pendingEnvelope) + isImporting = false + return pendingEnvelope.id + } catch { + isImporting = false + throw error + } + } + + func acknowledgeStagedNewThread(id: String) async throws { + try await pipeline.acknowledgeEnvelope(id: id) + if pendingEnvelope?.id.caseInsensitiveCompare(id) == .orderedSame { + pendingEnvelope = nil + } + lastNoProjectNoticeID = nil + } + private func markNoProjectNoticeIfNeeded() -> Bool { guard let id = pendingEnvelope?.id, lastNoProjectNoticeID != id else { @@ -305,12 +624,18 @@ struct PlatformIncomingShareDestinationSheet: View { } private var summary: String { - if !envelope.text.isEmpty, !envelope.images.isEmpty { - return "\(envelope.text)\n\(envelope.images.count) image\(envelope.images.count == 1 ? "" : "s")" + var fragments = [envelope.text].filter { !$0.isEmpty } + if !envelope.images.isEmpty { + fragments.append( + "\(envelope.images.count) image\(envelope.images.count == 1 ? "" : "s")" + ) + } + if !envelope.videos.isEmpty { + fragments.append( + "\(envelope.videos.count) video\(envelope.videos.count == 1 ? "" : "s")" + ) } - if !envelope.text.isEmpty { return envelope.text } - guard !envelope.images.isEmpty else { return "" } - return "\(envelope.images.count) shared image\(envelope.images.count == 1 ? "" : "s")" + return fragments.joined(separator: "\n") } private func environmentName(for project: FeatureProject) -> String? { diff --git a/apps/swift-ios/App/Platform/PlatformRootView.swift b/apps/swift-ios/App/Platform/PlatformRootView.swift index a35d8cebfcc..91025294b5b 100644 --- a/apps/swift-ios/App/Platform/PlatformRootView.swift +++ b/apps/swift-ios/App/Platform/PlatformRootView.swift @@ -1,5 +1,22 @@ import SwiftUI +@MainActor +final class PlatformIncomingShareRoutingGate { + private var isRouting = false + private var pendingOperation: (@MainActor () async -> Void)? + + func request(_ operation: @escaping @MainActor () async -> Void) async { + pendingOperation = operation + guard !isRouting else { return } + isRouting = true + while let operation = pendingOperation { + pendingOperation = nil + await operation() + } + isRouting = false + } +} + struct PlatformRootView: View { @SwiftUI.Environment(\.scenePhase) private var scenePhase @Bindable private var model: FeatureRootModel @@ -11,6 +28,8 @@ struct PlatformRootView: View { @State private var incomingShareCoordinator = PlatformIncomingShareCoordinator() @State private var incomingShareNeedsProject = false @State private var importedShareProjectID: String? + @State private var stagedIncomingShareID: String? + @State private var incomingShareRoutingGate = PlatformIncomingShareRoutingGate() @State private var recentThreadsPersistenceTask: Task? init(model: FeatureRootModel) { @@ -24,6 +43,14 @@ struct PlatformRootView: View { onNavigationRequestConsumed: { requestID in guard navigationRequest?.id == requestID else { return } navigationRequest = nil + }, + acknowledgeIncomingShare: { shareID in + await acknowledgeStagedIncomingShare(id: shareID) + }, + releaseIncomingSharePresentation: { shareID in + if stagedIncomingShareID?.caseInsensitiveCompare(shareID) == .orderedSame { + stagedIncomingShareID = nil + } } ) .onOpenURL { url in @@ -68,9 +95,19 @@ struct PlatformRootView: View { synchronizeAgentAwareness() synchronizeCloudDelivery() } + .onChange(of: model.snapshot.settings.appearance, initial: true) { _, appearance in + T3SharedAppearanceStore.shared.update(appearance.sharedAppearance) + } .onChange(of: model.snapshot.projects.map(\.id)) { _, _ in refreshIncomingShares() } + .onChange(of: incomingShareConnectionStates) { _, _ in + guard incomingShareCoordinator.pendingEnvelope?.destination?.kind == .existingThread, + !incomingShareCoordinator.isImporting else { return } + Task { @MainActor in + await routePendingIncomingShareIfNeeded() + } + } .sheet(item: presentedIncomingShare, onDismiss: openImportedShareDraft) { envelope in PlatformIncomingShareDestinationSheet( envelope: envelope, @@ -106,6 +143,9 @@ struct PlatformRootView: View { Binding( get: { guard !incomingShareProjects.isEmpty else { return nil } + guard incomingShareCoordinator.pendingEnvelope?.destination == nil else { + return nil + } return incomingShareCoordinator.pendingEnvelope }, set: { value in @@ -190,13 +230,94 @@ struct PlatformRootView: View { ) } - private func refreshIncomingShares() { + private func refreshIncomingShares(preferredID: String? = nil) { guard !model.isLoading else { return } let hasProjects = !incomingShareProjects.isEmpty Task { @MainActor in - if await incomingShareCoordinator.refresh(hasProjects: hasProjects) { + if await incomingShareCoordinator.refresh( + preferredID: preferredID, + hasProjects: hasProjects + ) { incomingShareNeedsProject = true } + await routePendingIncomingShareIfNeeded() + } + } + + @MainActor + private func routePendingIncomingShareIfNeeded() async { + await incomingShareRoutingGate.request { + await performPendingIncomingShareRoute() + } + } + + private func performPendingIncomingShareRoute() async { + guard let envelope = incomingShareCoordinator.pendingEnvelope, + let destination = envelope.destination, + stagedIncomingShareID != envelope.id else { return } + do { + switch destination.kind { + case .newThread: + guard let shareID = try await incomingShareCoordinator.stagePendingForNewThread() + else { return } + stagedIncomingShareID = shareID + navigationRequest = FeatureWorkspaceNavigationRequest( + destination: .sharedNewTask(shareID: shareID) + ) + case .existingThread: + guard let threadID = destination.threadID else { + try await incomingShareCoordinator.requestAnotherDestination() + model.errorMessage = "That thread is no longer available. Choose another destination." + return + } + if let environmentID = destination.environmentID, + !model.snapshot.environments.contains(where: { $0.id == environmentID }) { + try await incomingShareCoordinator.requestAnotherDestination() + model.errorMessage = "That thread's environment is no longer saved. Choose another destination." + return + } + guard await enableEnvironmentIfNeeded(destination.environmentID) else { + // Keep the saved destination so a temporarily unavailable + // environment can retry when its connection recovers. + return + } + guard let thread = PlatformRouteResolver.thread( + in: model.snapshot, + environmentID: destination.environmentID, + id: threadID + ) else { + try await incomingShareCoordinator.requestAnotherDestination() + model.errorMessage = "That thread is no longer available. Choose another destination." + return + } + guard let imported = try await incomingShareCoordinator.importPending( + into: thread + ) else { return } + navigationRequest = FeatureWorkspaceNavigationRequest( + destination: .sharedThread( + id: thread.id, + importDraft: imported.sharedContent + ) + ) + } + PlatformHapticEngine.shared.emit( + .success, + enabled: model.snapshot.settings.hapticsEnabled + ) + } catch { + model.errorMessage = error.localizedDescription + } + } + + @MainActor + private func acknowledgeStagedIncomingShare(id: String) async { + do { + try await incomingShareCoordinator.acknowledgeStagedNewThread(id: id) + if stagedIncomingShareID?.caseInsensitiveCompare(id) == .orderedSame { + stagedIncomingShareID = nil + } + } catch { + model.errorMessage = error.localizedDescription } } @@ -307,6 +428,11 @@ struct PlatformRootView: View { return await model.setEnvironmentEnabled(id, enabled: true) } + private var incomingShareConnectionStates: [FeatureConnection.State?] { + [model.snapshot.connection.state] + + model.snapshot.environments.map(\.connectionState) + } + /// Home revisions are coalesced by FeatureRootModel, so this performs one /// bounded scan per meaningful snapshot change rather than on every render. private func processThreadChanges() { @@ -345,3 +471,13 @@ struct PlatformRootView: View { ) } } + +private extension FeatureAppearance { + var sharedAppearance: T3SharedAppearance { + switch self { + case .system: .system + case .light: .light + case .dark: .dark + } + } +} diff --git a/apps/swift-ios/App/Platform/PlatformShortcuts.swift b/apps/swift-ios/App/Platform/PlatformShortcuts.swift index b8378d98409..2c6d415a81d 100644 --- a/apps/swift-ios/App/Platform/PlatformShortcuts.swift +++ b/apps/swift-ios/App/Platform/PlatformShortcuts.swift @@ -1,25 +1,16 @@ import AppIntents import Foundation -struct PlatformRecentThreadRecord: Codable, Equatable, Sendable { - let id: String - let environmentID: String? - let wireID: String - let title: String - let environmentName: String? - let updatedAt: Date -} - final class PlatformRecentThreadStore: @unchecked Sendable { static let shared = PlatformRecentThreadStore() - private let defaults: UserDefaults - private let key: String - private let lock = NSLock() + private let sharedStore: T3SharedRecentThreadStore - init(defaults: UserDefaults = .standard, key: String = "swift-ios.recent-threads.v1") { - self.defaults = defaults - self.key = key + init( + defaults: UserDefaults? = UserDefaults(suiteName: T3SharedContainer.appGroupID), + key: String = "swift-ios.shared-recent-threads.v1" + ) { + sharedStore = T3SharedRecentThreadStore(defaults: defaults, key: key) } func update(from threads: [FeatureThread]) { @@ -29,9 +20,9 @@ final class PlatformRecentThreadStore: @unchecked Sendable { if lhs.updatedAt != rhs.updatedAt { return lhs.updatedAt > rhs.updatedAt } return lhs.id < rhs.id } - .prefix(12) + .prefix(T3SharedRecentThreadStore.maximumCount) .map { - PlatformRecentThreadRecord( + T3SharedRecentThreadRecord( id: $0.id, environmentID: $0.environmentID, wireID: $0.wireID ?? $0.id, @@ -40,16 +31,11 @@ final class PlatformRecentThreadStore: @unchecked Sendable { updatedAt: $0.updatedAt ) } - lock.withLock { - defaults.set(try? JSONEncoder().encode(records), forKey: key) - } + sharedStore.update(records) } - func records() -> [PlatformRecentThreadRecord] { - lock.withLock { - guard let data = defaults.data(forKey: key) else { return [] } - return (try? JSONDecoder().decode([PlatformRecentThreadRecord].self, from: data)) ?? [] - } + func records() -> [T3SharedRecentThreadRecord] { + sharedStore.records() } } @@ -70,7 +56,7 @@ struct PlatformRecentThreadEntity: AppEntity { ) } - init(record: PlatformRecentThreadRecord) { + init(record: T3SharedRecentThreadRecord) { id = record.id environmentID = record.environmentID wireID = record.wireID @@ -88,7 +74,9 @@ struct PlatformRecentThreadQuery: EntityQuery { } func suggestedEntities() async throws -> [PlatformRecentThreadEntity] { - PlatformRecentThreadStore.shared.records().map(PlatformRecentThreadEntity.init) + PlatformRecentThreadStore.shared.records() + .prefix(12) + .map(PlatformRecentThreadEntity.init) } } diff --git a/apps/swift-ios/Extensions/Share/Info.plist b/apps/swift-ios/Extensions/Share/Info.plist index 6ebc1feebd7..afd48d662eb 100644 --- a/apps/swift-ios/Extensions/Share/Info.plist +++ b/apps/swift-ios/Extensions/Share/Info.plist @@ -4,6 +4,8 @@ CFBundleDisplayName $(T3CODE_SHARE_DISPLAY_NAME) + T3CodeAppGroupIdentifier + $(T3CODE_APP_GROUP_IDENTIFIER) NSExtension NSExtensionAttributes @@ -14,6 +16,8 @@ 2 NSExtensionActivationSupportsImageWithMaxCount 8 + NSExtensionActivationSupportsMovieWithMaxCount + 1 NSExtensionActivationSupportsText NSExtensionActivationSupportsWebURLWithMaxCount diff --git a/apps/swift-ios/Extensions/Share/SharePayloadLoader.swift b/apps/swift-ios/Extensions/Share/SharePayloadLoader.swift index 7c8b650e39b..fb8d258790c 100644 --- a/apps/swift-ios/Extensions/Share/SharePayloadLoader.swift +++ b/apps/swift-ios/Extensions/Share/SharePayloadLoader.swift @@ -4,6 +4,7 @@ import UniformTypeIdentifiers struct T3LoadedSharePayload: Sendable { var textFragments: [String] var images: [T3PendingShareImage] + var videos: [T3PendingShareVideo] var warnings: [String] } @@ -11,8 +12,12 @@ enum T3SharePayloadLoader { static func load(from inputItems: [Any]) async -> T3LoadedSharePayload { var textFragments: [String] = [] var images: [T3PendingShareImage] = [] + var videos: [T3PendingShareVideo] = [] var skippedOversizedImage = false - var skippedExcessImage = false + var skippedOversizedVideo = false + var skippedUnreadableImage = false + var skippedUnreadableVideo = false + var skippedExcessMedia = false for case let item as NSExtensionItem in inputItems { if let attributedText = item.attributedContentText?.string { @@ -20,17 +25,52 @@ enum T3SharePayloadLoader { } for provider in item.attachments ?? [] { + if let videoType = provider.registeredTypeIdentifiers.first(where: { + UTType($0)?.conforms(to: .movie) == true + }) { + guard videos.count < T3IncomingShareStore.maximumVideoCount, + images.count + videos.count < T3IncomingShareStore.maximumAttachmentCount else { + skippedExcessMedia = true + continue + } + do { + let staged = try await loadStagedFile( + from: provider, + typeIdentifier: videoType, + maximumBytes: T3IncomingShareStore.maximumVideoBytes + ) + videos.append( + T3PendingShareVideo( + stagedFileURL: staged.url, + byteCount: staged.byteCount, + suggestedName: provider.suggestedName, + typeIdentifier: videoType + ) + ) + } catch T3SharePayloadLoaderError.fileTooLarge { + skippedOversizedVideo = true + } catch { + // A movie provider is terminal even if it also vends a + // thumbnail. Preserve the user's choice instead of + // silently substituting a still image. + skippedUnreadableVideo = true + } + continue + } + if let imageType = provider.registeredTypeIdentifiers.first(where: { UTType($0)?.conforms(to: .image) == true }) { - guard images.count < T3IncomingShareStore.maximumImageCount else { - skippedExcessImage = true + guard images.count < T3IncomingShareStore.maximumImageCount, + images.count + videos.count < T3IncomingShareStore.maximumAttachmentCount else { + skippedExcessMedia = true continue } do { - let staged = try await loadStagedImage( + let staged = try await loadStagedFile( from: provider, - typeIdentifier: imageType + typeIdentifier: imageType, + maximumBytes: T3IncomingShareStore.maximumImageBytes ) images.append( T3PendingShareImage( @@ -40,12 +80,13 @@ enum T3SharePayloadLoader { typeIdentifier: imageType ) ) - } catch T3SharePayloadLoaderError.imageTooLarge { + } catch T3SharePayloadLoaderError.fileTooLarge { skippedOversizedImage = true } catch { // An image provider is terminal even if it also vends a // URL or text representation. Falling through would // silently turn a rejected attachment into other input. + skippedUnreadableImage = true } continue } @@ -77,21 +118,32 @@ enum T3SharePayloadLoader { if skippedOversizedImage { warnings.append("One shared image exceeded the 10 MB attachment limit.") } - if skippedExcessImage { + if skippedOversizedVideo { + warnings.append("One shared video exceeded the 250 MB import limit.") + } + if skippedUnreadableImage { + warnings.append("One shared image could not be read and was not imported.") + } + if skippedUnreadableVideo { + warnings.append("One shared video could not be read and was not imported.") + } + if skippedExcessMedia { warnings.append( - "Only the first \(T3IncomingShareStore.maximumImageCount) shared images were attached." + "Only the first \(T3IncomingShareStore.maximumAttachmentCount) shared media items were kept." ) } return T3LoadedSharePayload( textFragments: textFragments, images: images, + videos: videos, warnings: warnings ) } - private static func loadStagedImage( + private static func loadStagedFile( from provider: NSItemProvider, - typeIdentifier: String + typeIdentifier: String, + maximumBytes: Int ) async throws -> (url: URL, byteCount: Int) { try await withCheckedThrowingContinuation { continuation in provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in @@ -99,7 +151,9 @@ enum T3SharePayloadLoader { guard let url else { throw error ?? CocoaError(.fileReadUnknown) } - continuation.resume(returning: try stageImage(from: url)) + continuation.resume( + returning: try stageFile(from: url, maximumBytes: maximumBytes) + ) } catch { continuation.resume(throwing: error) } @@ -110,7 +164,10 @@ enum T3SharePayloadLoader { /// The provider-owned URL expires when its callback returns. Stream it to /// an extension-owned temporary file while enforcing the byte limit, so a /// malicious or enormous provider never has to be materialized in memory. - private static func stageImage(from sourceURL: URL) throws -> (url: URL, byteCount: Int) { + private static func stageFile( + from sourceURL: URL, + maximumBytes: Int + ) throws -> (url: URL, byteCount: Int) { let fileManager = FileManager.default let stagingDirectory = fileManager.temporaryDirectory.appending( path: "T3CodeShareStaging", @@ -140,8 +197,8 @@ enum T3SharePayloadLoader { while let chunk = try source.read(upToCount: 64 * 1_024), !chunk.isEmpty { try Task.checkCancellation() byteCount += chunk.count - guard byteCount <= T3IncomingShareStore.maximumImageBytes else { - throw T3SharePayloadLoaderError.imageTooLarge + guard byteCount <= maximumBytes else { + throw T3SharePayloadLoaderError.fileTooLarge } try destination.write(contentsOf: chunk) } @@ -190,5 +247,5 @@ enum T3SharePayloadLoader { } private enum T3SharePayloadLoaderError: Error { - case imageTooLarge + case fileTooLarge } diff --git a/apps/swift-ios/Extensions/Share/ShareViewController.swift b/apps/swift-ios/Extensions/Share/ShareViewController.swift index c85d3f2a15e..bb4f05f6852 100644 --- a/apps/swift-ios/Extensions/Share/ShareViewController.swift +++ b/apps/swift-ios/Extensions/Share/ShareViewController.swift @@ -9,13 +9,17 @@ final class T3ShareViewController: UIViewController { view.backgroundColor = .systemBackground let content = T3ShareExtensionView( - save: { [weak self] in + recentThreads: T3SharedRecentThreadStore.shared.records(), + appearance: T3SharedAppearanceStore.shared.appearance(), + save: { [weak self] destination in let inputItems = self?.extensionContext?.inputItems ?? [] let payload = await T3SharePayloadLoader.load(from: inputItems) return try await Task.detached { try T3IncomingShareStore.write( textFragments: payload.textFragments, images: payload.images, + videos: payload.videos, + destination: destination, warnings: payload.warnings ) }.value @@ -47,143 +51,250 @@ struct T3ShareExtensionView: View { enum Phase: Equatable { case ready case saving - case saved(imageCount: Int) + case saved(message: String) case failed(message: String) } - let save: () async throws -> T3IncomingShareEnvelope + let recentThreads: [T3SharedRecentThreadRecord] + let appearance: T3SharedAppearance + let save: (T3IncomingShareDestination) async throws -> T3IncomingShareEnvelope let cancel: () -> Void let complete: () -> Void @State private var phase = Phase.ready + @State private var searchText = "" var body: some View { - VStack(spacing: 0) { - HStack { - Button("Cancel", action: cancel) - .foregroundStyle(.secondary) - .disabled(isSaving) - Spacer() - Text("T3 Code") - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(.primary) - Spacer() - Color.clear.frame(width: 52, height: 1) - } - .padding(.horizontal, 18) - .padding(.vertical, 15) - - Divider() - - VStack(spacing: 14) { - Image(systemName: phaseSymbol) - .font(.system(size: 32, weight: .medium)) - .foregroundStyle(phaseTint) - .accessibilityHidden(true) - Text(title) - .font(.system(size: 22, weight: .bold)) - .foregroundStyle(.primary) - .multilineTextAlignment(.center) - Text(message) - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .lineSpacing(3) + NavigationStack { + Group { + switch phase { + case .ready: + destinationList + case .saving: + statusView( + symbol: "arrow.down.doc", + tint: Color(uiColor: .label), + title: "Preparing your share", + message: "Keeping a durable copy before opening T3 Code.", + showsProgress: true + ) + case let .saved(message): + statusView( + symbol: "checkmark.circle.fill", + tint: Color(uiColor: .systemGreen), + title: "Ready in T3 Code", + message: message, + showsProgress: false + ) + case let .failed(message): + statusView( + symbol: "exclamationmark.triangle.fill", + tint: Color(uiColor: .systemRed), + title: "Could not add this", + message: message, + showsProgress: false + ) + } } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.horizontal, 28) - .padding(.vertical, 24) - - Button(action: primaryAction) { - Text(primaryTitle) - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(Color(uiColor: .systemBackground)) - .frame(maxWidth: .infinity) - .frame(height: 50) - .background(Color(uiColor: .label), in: RoundedRectangle(cornerRadius: 13)) + .navigationTitle("Share to T3 Code") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(isSaved ? "Done" : "Cancel") { + if isSaved { + complete() + } else { + cancel() + } + } + .disabled(isSaving) + } } - .buttonStyle(.plain) - .disabled(isSaving) - .opacity(isSaving ? 0.55 : 1) - .padding(.horizontal, 18) - .padding(.bottom, 18) } - .background(Color(uiColor: .systemBackground).ignoresSafeArea()) + .preferredColorScheme(preferredColorScheme) + .background(T3ShareTheme.background.ignoresSafeArea()) } private var isSaving: Bool { phase == .saving } - private var title: String { - switch phase { - case .ready: "Add to a new task" - case .saving: "Saving shared content" - case .saved: "Ready in T3 Code" - case .failed: "Could not add this" - } + private var isSaved: Bool { + if case .saved = phase { return true } + return false } - private var message: String { - switch phase { - case .ready: - "Text, links, and up to eight images will be waiting in the native composer." - case .saving: - "Keeping a durable copy so nothing gets lost." - case let .saved(imageCount): - imageCount == 0 - ? "Open T3 Code to choose a project and send it." - : "Saved \(imageCount) image\(imageCount == 1 ? "" : "s"). Open T3 Code to choose a project." - case let .failed(message): - message + private var destinationList: some View { + List { + Section { + destinationButton( + title: "New Thread", + subtitle: "Choose the project in T3 Code", + systemImage: "square.and.pencil" + ) { + beginShare(to: .newThread) + } + } + + if !recentThreads.isEmpty { + Section(searchText.isEmpty ? "Recent Threads" : "Threads") { + ForEach(filteredThreads) { thread in + destinationButton( + title: thread.title, + subtitle: thread.environmentName, + systemImage: "bubble.left.and.bubble.right" + ) { + beginShare( + to: .existingThread( + environmentID: thread.environmentID, + threadID: thread.wireID + ) + ) + } + } + } + } else { + Section("Existing Thread") { + Text("Open T3 Code once to make recent threads available here.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + Section { + Text("Text and images are staged as-is. A shared video becomes one contact-sheet image so the agent can inspect representative frames.") + .font(.footnote) + .foregroundStyle(.secondary) + } } + .scrollContentBackground(.hidden) + .background(T3ShareTheme.background) + .searchable( + text: $searchText, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Find a thread" + ) } - private var phaseSymbol: String { - switch phase { - case .ready: "square.and.arrow.up" - case .saving: "arrow.down.doc" - case .saved: "checkmark.circle.fill" - case .failed: "exclamationmark.triangle.fill" + private var filteredThreads: [T3SharedRecentThreadRecord] { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return recentThreads } + return recentThreads.filter { + $0.title.localizedCaseInsensitiveContains(query) + || ($0.environmentName?.localizedCaseInsensitiveContains(query) ?? false) } } - private var phaseTint: Color { - switch phase { - case .saved: Color(uiColor: .systemGreen) - case .failed: Color(uiColor: .systemRed) - default: Color(uiColor: .label) + private func destinationButton( + title: String, + subtitle: String?, + systemImage: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 12) { + Image(systemName: systemImage) + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 28) + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.body.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } + .frame(minHeight: 48) + .contentShape(Rectangle()) } + .buttonStyle(.plain) } - private var primaryTitle: String { - switch phase { - case .ready: "Add to T3 Code" - case .saving: "Saving…" - case .saved: "Done" - case .failed: "Try again" + private func statusView( + symbol: String, + tint: Color, + title: String, + message: String, + showsProgress: Bool + ) -> some View { + VStack(spacing: 14) { + if showsProgress { + ProgressView() + .controlSize(.large) + } else { + Image(systemName: symbol) + .font(.system(size: 34, weight: .medium)) + .foregroundStyle(tint) + } + Text(title) + .font(.title2.bold()) + Text(message) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineSpacing(3) + if case .failed = phase { + Button("Choose another destination") { + phase = .ready + } + .buttonStyle(.borderedProminent) + .padding(.top, 8) + } } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(28) } - private func primaryAction() { - switch phase { - case .ready, .failed: - phase = .saving - Task { - do { - let envelope = try await save() - phase = .saved(imageCount: envelope.images.count) - } catch { - phase = .failed( - message: (error as? LocalizedError)?.errorDescription - ?? "The shared content could not be saved." - ) - } + private func beginShare(to destination: T3IncomingShareDestination) { + guard phase == .ready else { return } + phase = .saving + Task { + do { + _ = try await save(destination) + phase = .saved(message: completionMessage(for: destination)) + } catch { + phase = .failed( + message: (error as? LocalizedError)?.errorDescription + ?? "The shared content could not be saved." + ) } - case .saved: - complete() - case .saving: - break } } + + private var preferredColorScheme: ColorScheme? { + switch appearance { + case .system: nil + case .light: .light + case .dark: .dark + } + } + + private func completionMessage(for destination: T3IncomingShareDestination) -> String { + switch destination.kind { + case .newThread: + "Tap Done, then open T3 Code. The project picker will open with your content in the composer." + case .existingThread: + "Tap Done, then open T3 Code. Your content will be waiting in that thread’s composer." + } + } +} + +private enum T3ShareTheme { + static let background = Color( + uiColor: UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(red: 10 / 255, green: 10 / 255, blue: 10 / 255, alpha: 1) + : UIColor(red: 242 / 255, green: 242 / 255, blue: 247 / 255, alpha: 1) + } + ) } diff --git a/apps/swift-ios/Extensions/Shared/ShareInbox.swift b/apps/swift-ios/Extensions/Shared/ShareInbox.swift index 4b14aafe49f..63bd1be7daf 100644 --- a/apps/swift-ios/Extensions/Shared/ShareInbox.swift +++ b/apps/swift-ios/Extensions/Shared/ShareInbox.swift @@ -8,15 +8,100 @@ struct T3IncomingShareImage: Codable, Hashable, Identifiable, Sendable { var byteCount: Int } +struct T3IncomingShareVideo: Codable, Hashable, Identifiable, Sendable { + var id: String + var fileName: String + var typeIdentifier: String + var relativePath: String + var byteCount: Int +} + +struct T3IncomingShareDestination: Codable, Hashable, Sendable { + enum Kind: String, Codable, Sendable { + case newThread + case existingThread + } + + var kind: Kind + var environmentID: String? + var threadID: String? + + static let newThread = T3IncomingShareDestination( + kind: .newThread, + environmentID: nil, + threadID: nil + ) + + static func existingThread( + environmentID: String?, + threadID: String + ) -> T3IncomingShareDestination { + T3IncomingShareDestination( + kind: .existingThread, + environmentID: environmentID, + threadID: threadID + ) + } +} + struct T3IncomingShareEnvelope: Codable, Hashable, Identifiable, Sendable { - static let schemaVersion = 1 + static let schemaVersion = 2 + static let supportedSchemaVersions = 1...schemaVersion var schemaVersion: Int var id: String var createdAt: Date var text: String var images: [T3IncomingShareImage] + var videos: [T3IncomingShareVideo] + var destination: T3IncomingShareDestination? var warnings: [String] + + init( + schemaVersion: Int = Self.schemaVersion, + id: String, + createdAt: Date, + text: String, + images: [T3IncomingShareImage], + videos: [T3IncomingShareVideo] = [], + destination: T3IncomingShareDestination? = nil, + warnings: [String] + ) { + self.schemaVersion = schemaVersion + self.id = id + self.createdAt = createdAt + self.text = text + self.images = images + self.videos = videos + self.destination = destination + self.warnings = warnings + } + + private enum CodingKeys: String, CodingKey { + case schemaVersion + case id + case createdAt + case text + case images + case videos + case destination + case warnings + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + schemaVersion = try container.decode(Int.self, forKey: .schemaVersion) + id = try container.decode(String.self, forKey: .id) + createdAt = try container.decode(Date.self, forKey: .createdAt) + text = try container.decode(String.self, forKey: .text) + images = try container.decodeIfPresent([T3IncomingShareImage].self, forKey: .images) ?? [] + videos = try container.decodeIfPresent([T3IncomingShareVideo].self, forKey: .videos) ?? [] + destination = try container.decodeIfPresent( + T3IncomingShareDestination.self, + forKey: .destination + ) + warnings = try container.decodeIfPresent([String].self, forKey: .warnings) ?? [] + } } struct T3PendingShareImage: Sendable { @@ -26,6 +111,87 @@ struct T3PendingShareImage: Sendable { var typeIdentifier: String } +struct T3PendingShareVideo: Sendable { + var stagedFileURL: URL + var byteCount: Int + var suggestedName: String? + var typeIdentifier: String +} + +struct T3SharedRecentThreadRecord: Codable, Equatable, Identifiable, Sendable { + let id: String + let environmentID: String? + let wireID: String + let title: String + let environmentName: String? + let updatedAt: Date +} + +enum T3SharedAppearance: String, Codable, Equatable, Sendable { + case system + case light + case dark +} + +final class T3SharedAppearanceStore: @unchecked Sendable { + static let shared = T3SharedAppearanceStore() + + private let defaults: UserDefaults? + private let key: String + private let lock = NSLock() + + init( + defaults: UserDefaults? = UserDefaults(suiteName: T3SharedContainer.appGroupID), + key: String = "swift-ios.shared-appearance.v1" + ) { + self.defaults = defaults + self.key = key + } + + func update(_ appearance: T3SharedAppearance) { + lock.withLock { + defaults?.set(appearance.rawValue, forKey: key) + } + } + + func appearance() -> T3SharedAppearance { + lock.withLock { + guard let rawValue = defaults?.string(forKey: key) else { return .system } + return T3SharedAppearance(rawValue: rawValue) ?? .system + } + } +} + +final class T3SharedRecentThreadStore: @unchecked Sendable { + static let shared = T3SharedRecentThreadStore() + static let maximumCount = 100 + + private let defaults: UserDefaults? + private let key: String + private let lock = NSLock() + + init( + defaults: UserDefaults? = UserDefaults(suiteName: T3SharedContainer.appGroupID), + key: String = "swift-ios.shared-recent-threads.v1" + ) { + self.defaults = defaults + self.key = key + } + + func update(_ records: [T3SharedRecentThreadRecord]) { + lock.withLock { + defaults?.set(try? JSONEncoder().encode(records), forKey: key) + } + } + + func records() -> [T3SharedRecentThreadRecord] { + lock.withLock { + guard let data = defaults?.data(forKey: key) else { return [] } + return (try? JSONDecoder().decode([T3SharedRecentThreadRecord].self, from: data)) ?? [] + } + } +} + enum T3IncomingShareStoreError: LocalizedError { case appGroupUnavailable case noSupportedContent @@ -33,9 +199,9 @@ enum T3IncomingShareStoreError: LocalizedError { var errorDescription: String? { switch self { case .appGroupUnavailable: - "T3 Code could not access its shared inbox." + "This build of T3 Code does not have access to its shared inbox. Install an App Group-enabled build and try again." case .noSupportedContent: - "This app did not provide text, a URL, or a supported image." + "This app did not provide text, a URL, or supported media." } } } @@ -45,12 +211,17 @@ enum T3IncomingShareStoreError: LocalizedError { enum T3IncomingShareStore { static let inboxRelativePath = "Library/Application Support/T3Code/IncomingShares" static let manifestFileName = "manifest.json" + static let maximumAttachmentCount = 8 static let maximumImageCount = 8 static let maximumImageBytes = 10 * 1_024 * 1_024 + static let maximumVideoCount = 1 + static let maximumVideoBytes = 250 * 1_024 * 1_024 static func write( textFragments: [String], images: [T3PendingShareImage], + videos: [T3PendingShareVideo] = [], + destination: T3IncomingShareDestination? = nil, warnings initialWarnings: [String] = [], now: Date = Date(), id: String = UUID().uuidString.lowercased() @@ -62,6 +233,9 @@ enum T3IncomingShareStore { for image in images { try? FileManager.default.removeItem(at: image.stagedFileURL) } + for video in videos { + try? FileManager.default.removeItem(at: video.stagedFileURL) + } } let normalizedText = deduplicatedText(textFragments) @@ -70,6 +244,7 @@ enum T3IncomingShareStore { .appending(path: id, directoryHint: .isDirectory) var warnings = initialWarnings var savedImages: [T3IncomingShareImage] = [] + var savedVideos: [T3IncomingShareVideo] = [] var validOverflowCount = 0 do { @@ -86,8 +261,11 @@ enum T3IncomingShareStore { guard values?.isRegularFile == true, let byteCount = values?.fileSize, byteCount > 0, - byteCount <= maximumImageBytes, byteCount == image.byteCount else { + warnings.append("One shared image could not be read and was not imported.") + continue + } + guard byteCount <= maximumImageBytes else { warnings.append("One shared image exceeded the 10 MB attachment limit.") continue } @@ -115,11 +293,52 @@ enum T3IncomingShareStore { ) } + for video in videos { + let values = try? video.stagedFileURL.resourceValues(forKeys: [ + .fileSizeKey, + .isRegularFileKey, + ]) + guard values?.isRegularFile == true, + let byteCount = values?.fileSize, + byteCount > 0, + byteCount == video.byteCount else { + warnings.append("One shared video could not be read and was not imported.") + continue + } + guard byteCount <= maximumVideoBytes else { + warnings.append("One shared video exceeded the 250 MB import limit.") + continue + } + guard savedVideos.count < maximumVideoCount, + savedImages.count + savedVideos.count < maximumAttachmentCount else { + validOverflowCount += 1 + continue + } + + let attachmentID = UUID().uuidString.lowercased() + let fileName = safeFileName( + video.suggestedName, + fallback: "shared-video-\(savedVideos.count + 1).\(fileExtension(for: video.typeIdentifier))" + ) + let storedName = "\(attachmentID)-\(fileName)" + let fileURL = itemDirectory.appending(path: storedName, directoryHint: .notDirectory) + try FileManager.default.copyItem(at: video.stagedFileURL, to: fileURL) + savedVideos.append( + T3IncomingShareVideo( + id: attachmentID, + fileName: fileName, + typeIdentifier: video.typeIdentifier, + relativePath: "\(inboxRelativePath)/\(id)/\(storedName)", + byteCount: byteCount + ) + ) + } + if validOverflowCount > 0 { - warnings.append("Only the first \(maximumImageCount) shared images were attached.") + warnings.append("Only the first \(maximumAttachmentCount) shared media items were kept.") } - guard !normalizedText.isEmpty || !savedImages.isEmpty else { + guard !normalizedText.isEmpty || !savedImages.isEmpty || !savedVideos.isEmpty else { throw T3IncomingShareStoreError.noSupportedContent } @@ -129,6 +348,8 @@ enum T3IncomingShareStore { createdAt: now, text: normalizedText, images: savedImages, + videos: savedVideos, + destination: destination, warnings: warnings ) let manifestURL = itemDirectory.appending( @@ -148,7 +369,7 @@ enum T3IncomingShareStore { let inboxURL = containerURL.appending(path: inboxRelativePath, directoryHint: .isDirectory) guard let directories = try? FileManager.default.contentsOfDirectory( at: inboxURL, - includingPropertiesForKeys: [.isDirectoryKey], + includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey], options: [.skipsHiddenFiles] ) else { return [] @@ -156,10 +377,18 @@ enum T3IncomingShareStore { return directories.compactMap { directory in let manifestURL = directory.appending(path: manifestFileName, directoryHint: .notDirectory) - guard let data = try? Data(contentsOf: manifestURL) else { return nil } + guard let data = try? Data(contentsOf: manifestURL) else { + let modified = try? directory.resourceValues( + forKeys: [.contentModificationDateKey] + ).contentModificationDate + if let modified, Date().timeIntervalSince(modified) > 3_600 { + try? FileManager.default.removeItem(at: directory) + } + return nil + } return try? decoder.decode(T3IncomingShareEnvelope.self, from: data) } - .filter { $0.schemaVersion == T3IncomingShareEnvelope.schemaVersion } + .filter { T3IncomingShareEnvelope.supportedSchemaVersions.contains($0.schemaVersion) } .sorted { $0.createdAt < $1.createdAt } } @@ -183,11 +412,47 @@ enum T3IncomingShareStore { try FileManager.default.removeItem(at: itemURL) } + static func updateDestination( + id: String, + destination: T3IncomingShareDestination? + ) throws { + guard let containerURL = T3SharedContainer.rootURL else { + throw T3IncomingShareStoreError.appGroupUnavailable + } + guard UUID(uuidString: id) != nil else { + throw T3IncomingShareStoreError.noSupportedContent + } + let inboxURL = containerURL + .appending(path: inboxRelativePath, directoryHint: .isDirectory) + .standardizedFileURL + let itemURL = inboxURL + .appending(path: id, directoryHint: .isDirectory) + .standardizedFileURL + guard itemURL.deletingLastPathComponent() == inboxURL else { + throw T3IncomingShareStoreError.noSupportedContent + } + let manifestURL = itemURL.appending(path: manifestFileName, directoryHint: .notDirectory) + var envelope = try decoder.decode( + T3IncomingShareEnvelope.self, + from: Data(contentsOf: manifestURL) + ) + envelope.destination = destination + try encoder.encode(envelope).write(to: manifestURL, options: .atomic) + } + static func fileURL(for image: T3IncomingShareImage) -> URL? { + fileURL(relativePath: image.relativePath) + } + + static func fileURL(for video: T3IncomingShareVideo) -> URL? { + fileURL(relativePath: video.relativePath) + } + + private static func fileURL(relativePath: String) -> URL? { guard let root = T3SharedContainer.rootURL?.standardizedFileURL else { return nil } let inbox = root.appending(path: inboxRelativePath, directoryHint: .isDirectory) .standardizedFileURL - let url = root.appending(path: image.relativePath, directoryHint: .notDirectory) + let url = root.appending(path: relativePath, directoryHint: .notDirectory) .standardizedFileURL guard url.path.hasPrefix(inbox.path + "/") else { return nil } return url @@ -216,6 +481,10 @@ enum T3IncomingShareStore { case "public.heic", "image/heic": "heic" case "public.webp", "image/webp": "webp" case "com.compuserve.gif", "image/gif": "gif" + case "public.mpeg-4", "video/mp4": "mp4" + case "com.apple.quicktime-movie", "video/quicktime": "mov" + case "public.movie": "mov" + case "public.mpeg", "video/mpeg": "mpeg" default: "png" } } diff --git a/apps/swift-ios/Extensions/Shared/SharedContainer.swift b/apps/swift-ios/Extensions/Shared/SharedContainer.swift index 751016113c6..7651775c7d9 100644 --- a/apps/swift-ios/Extensions/Shared/SharedContainer.swift +++ b/apps/swift-ios/Extensions/Shared/SharedContainer.swift @@ -2,13 +2,28 @@ import Foundation enum T3SharedContainer { #if DEBUG - static let appGroupID = "group.com.t3tools.t3code.swiftui.dev" + static let defaultAppGroupID = "group.com.t3tools.t3code.swiftui.dev" static let urlScheme = "t3code-swiftui-dev" #else - static let appGroupID = "group.com.t3tools.t3code.swiftui" + static let defaultAppGroupID = "group.com.t3tools.t3code.swiftui" static let urlScheme = "t3code-swiftui" #endif + static var appGroupID: String { + configuredAppGroupID( + Bundle.main.object(forInfoDictionaryKey: "T3CodeAppGroupIdentifier") as? String + ) + } + + static func configuredAppGroupID(_ value: String?) -> String { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + value.hasPrefix("group."), + value.count > "group.".count else { + return defaultAppGroupID + } + return value + } + static var rootURL: URL? { FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: appGroupID diff --git a/apps/swift-ios/Extensions/Tests/ExtensionContractTests.swift b/apps/swift-ios/Extensions/Tests/ExtensionContractTests.swift index 3e531463fc6..82f68ce5928 100644 --- a/apps/swift-ios/Extensions/Tests/ExtensionContractTests.swift +++ b/apps/swift-ios/Extensions/Tests/ExtensionContractTests.swift @@ -3,6 +3,30 @@ import XCTest @testable import T3Code final class ExtensionContractTests: XCTestCase { + func testIncomingShareDecodesLegacyEnvelopeWithoutDestinationOrVideos() throws { + let data = Data(#"{"createdAt":"2026-08-10T01:02:03Z","id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","images":[],"schemaVersion":1,"text":"Legacy","warnings":[]}"#.utf8) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let envelope = try decoder.decode(T3IncomingShareEnvelope.self, from: data) + + XCTAssertEqual(envelope.schemaVersion, 1) + XCTAssertEqual(envelope.text, "Legacy") + XCTAssertTrue(envelope.videos.isEmpty) + XCTAssertNil(envelope.destination) + } + + func testSharedContainerAcceptsAConfiguredTeamSpecificAppGroup() { + XCTAssertEqual( + T3SharedContainer.configuredAppGroupID("group.com.saphid.t3code.swiftui.dev"), + "group.com.saphid.t3code.swiftui.dev" + ) + XCTAssertEqual( + T3SharedContainer.configuredAppGroupID("not-an-app-group"), + T3SharedContainer.defaultAppGroupID + ) + } + func testLiveActivityDecodesTheRelayAPNSEnvelope() throws { let props = #"{"title":"T3 Code","subtitle":"2 active agents, 1 needs attention","activeCount":2,"updatedAt":"2026-08-01T12:00:00.000Z","activities":[{"environmentId":"env-1","threadId":"thread-working","projectTitle":"t3code","threadTitle":"Build the native app","modelTitle":"GPT-5.6 Sol","phase":"running","status":"Working","updatedAt":"2026-08-01T12:00:00.000Z","deepLink":"/env-1/thread-working"},{"environmentId":"env-2","threadId":"thread-approval","projectTitle":"uploadthing","threadTitle":"Ship upload recovery","modelTitle":"Claude Opus 5","phase":"waiting_for_approval","status":"Approval","updatedAt":"2026-08-01T11:59:00.000Z","deepLink":"/env-2/thread-approval"}]}"# let state = LiveActivityAttributes.ContentState( diff --git a/apps/swift-ios/Extensions/Widgets/Info.plist b/apps/swift-ios/Extensions/Widgets/Info.plist index d31f30c5f68..140cf57da64 100644 --- a/apps/swift-ios/Extensions/Widgets/Info.plist +++ b/apps/swift-ios/Extensions/Widgets/Info.plist @@ -4,6 +4,8 @@ CFBundleDisplayName $(T3CODE_WIDGET_DISPLAY_NAME) + T3CodeAppGroupIdentifier + $(T3CODE_APP_GROUP_IDENTIFIER) NSExtension NSExtensionPointIdentifier diff --git a/apps/swift-ios/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift index 44647e03962..c3a1be00be1 100644 --- a/apps/swift-ios/Features/Chat/ThreadDetailView.swift +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -8,6 +8,8 @@ public struct ThreadDetailView: View { @Bindable var model: FeatureRootModel let thread: FeatureThread + let composerDraftReloadRevision: Int + let composerDraftReloadImports: [FeatureComposerIncomingShareDraft] let submitMessage: (FeatureMessageSubmission) async -> Bool let onNavigateBack: () -> Void private let draftStore: FeatureComposerDraftStore @@ -18,7 +20,10 @@ public struct ThreadDetailView: View { @State private var isSending = false @State private var isLoading = true @State private var sendFailed = false + @State private var sharedAttachmentOverflowCount = 0 @State private var didRestoreDraft = false + @State private var restoredImportedShareIDs: Set = [] + @State private var handledComposerDraftReloadRevision: Int @State private var draftSaveTask: Task? @State private var toolSurface: FeatureThreadToolSurface? @FocusState private var composerFocused: Bool @@ -26,12 +31,19 @@ public struct ThreadDetailView: View { public init( model: FeatureRootModel, thread: FeatureThread, + composerDraftReloadRevision: Int = 0, + composerDraftReloadImports: [FeatureComposerIncomingShareDraft] = [], submitMessage: @escaping (FeatureMessageSubmission) async -> Bool, onNavigateBack: @escaping () -> Void = {}, draftStore: FeatureComposerDraftStore = .shared ) { self.model = model self.thread = thread + self.composerDraftReloadRevision = composerDraftReloadRevision + self.composerDraftReloadImports = composerDraftReloadImports + _handledComposerDraftReloadRevision = State( + initialValue: composerDraftReloadRevision + ) self.submitMessage = submitMessage self.onNavigateBack = onNavigateBack self.draftStore = draftStore @@ -71,6 +83,21 @@ public struct ThreadDetailView: View { await restoreDraft(from: restoreBaseline, key: restoreKey) isLoading = false } + .task(id: FeatureComposerDraftReloadTrigger( + revision: composerDraftReloadRevision, + didRestoreDraft: didRestoreDraft + )) { + guard handledComposerDraftReloadRevision != composerDraftReloadRevision, + didRestoreDraft else { return } + for imported in FeatureComposerIncomingShareReloadPolicy.pendingImports( + composerDraftReloadImports, + restoredShareIDs: restoredImportedShareIDs + ) { + guard await reloadImportedDraft(imported.draft) else { return } + restoredImportedShareIDs.insert(imported.shareID) + } + handledComposerDraftReloadRevision = composerDraftReloadRevision + } .onChange(of: draft) { scheduleDraftSave() } .onChange(of: attachments) { scheduleDraftSave() } .onChange(of: selection) { scheduleDraftSave() } @@ -106,6 +133,20 @@ public struct ThreadDetailView: View { } message: { Text("Your draft is still here. Check your connection and try again.") } + .alert( + "Remove attachments before sending", + isPresented: Binding( + get: { sharedAttachmentOverflowCount > 0 }, + set: { if !$0 { sharedAttachmentOverflowCount = 0 } } + ) + ) { + Button("OK") {} + } message: { + Text( + "The shared attachments were kept. Remove " + + "\(sharedAttachmentOverflowCount) before sending this draft." + ) + } .simultaneousGesture(edgeBackGesture) } @@ -472,14 +513,46 @@ public struct ThreadDetailView: View { FeatureComposerDraftStore.threadKey(currentThread) } + @MainActor + private func reloadImportedDraft(_ importedDraft: FeatureComposerDraft) async -> Bool { + guard didRestoreDraft else { return false } + let key = draftKey + let pendingSave = draftSaveTask + draftSaveTask = nil + pendingSave?.cancel() + await pendingSave?.value + guard !Task.isCancelled else { return false } + + let mergeResult = FeatureComposerIncomingShareMerge.merge( + current: composerDraft, + incoming: importedDraft + ) + let merged = mergeResult.draft + draft = merged.text + attachments = merged.attachments + sharedAttachmentOverflowCount = mergeResult.attachmentOverflowCount + draftSaveTask?.cancel() + draftSaveTask = nil + try? await draftStore.setDraft(composerDraft, for: key) + return true + } + @MainActor private func restoreDraft(from baseline: FeatureComposerDraft, key: String) async { - let saved = try? await draftStore.draft(for: key) + let snapshot = try? await draftStore.snapshot(for: key) guard !Task.isCancelled else { return } + restoreDraft(snapshot, from: baseline) + } + + @MainActor + private func restoreDraft( + _ snapshot: FeatureComposerDraftSnapshot?, + from baseline: FeatureComposerDraft + ) { let liveDraft = composerDraft var restored = FeatureComposerDraftRestoration.merge( - saved: saved, + saved: snapshot?.draft, baseline: baseline, current: liveDraft ) @@ -491,6 +564,7 @@ public struct ThreadDetailView: View { draft = restored.text attachments = restored.attachments selection = restored.selection + restoredImportedShareIDs = snapshot?.importedShareIDs ?? [] didRestoreDraft = true // Changes made while the file read or thread refresh was in flight did @@ -623,6 +697,70 @@ enum FeatureComposerDraftRestoration { } } +enum FeatureComposerIncomingShareMerge { + static func merge( + current: FeatureComposerDraft, + incoming: FeatureComposerDraft, + maximumAttachmentCount: Int = 8 + ) -> FeatureComposerIncomingShareMergeResult { + let incomingText = incoming.text.trimmingCharacters(in: .whitespacesAndNewlines) + let mergedText: String + if incomingText.isEmpty { + mergedText = current.text + } else if current.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + mergedText = incomingText + } else { + mergedText = "\(current.text)\n\n\(incomingText)" + } + + let currentAttachmentIDs = Set(current.attachments.map(\.id)) + let uniqueIncomingAttachments = incoming.attachments.filter { + !currentAttachmentIDs.contains($0.id) + } + let mergedAttachments = current.attachments + uniqueIncomingAttachments + return FeatureComposerIncomingShareMergeResult( + draft: FeatureComposerDraft( + text: mergedText, + attachments: mergedAttachments, + selection: current.selection, + workspace: current.workspace + ), + attachmentOverflowCount: max( + 0, + mergedAttachments.count - maximumAttachmentCount + ) + ) + } +} + +struct FeatureComposerIncomingShareMergeResult { + let draft: FeatureComposerDraft + let attachmentOverflowCount: Int +} + +enum FeatureComposerIncomingShareReloadPolicy { + static func appending( + _ imported: FeatureComposerIncomingShareDraft, + to imports: [FeatureComposerIncomingShareDraft], + maximumCount: Int = 32 + ) -> [FeatureComposerIncomingShareDraft] { + let deduplicated = imports.filter { $0.shareID != imported.shareID } + return Array((deduplicated + [imported]).suffix(maximumCount)) + } + + static func pendingImports( + _ imports: [FeatureComposerIncomingShareDraft], + restoredShareIDs: Set + ) -> [FeatureComposerIncomingShareDraft] { + imports.filter { !restoredShareIDs.contains($0.shareID) } + } +} + +private struct FeatureComposerDraftReloadTrigger: Hashable { + let revision: Int + let didRestoreDraft: Bool +} + /// A recycled transcript surface. SwiftUI still owns each message's rendering, /// while UIKit keeps offscreen messages out of the active view hierarchy. private struct FeatureTranscriptCollectionView: UIViewRepresentable { diff --git a/apps/swift-ios/Features/Root/FeatureRootView.swift b/apps/swift-ios/Features/Root/FeatureRootView.swift index 3505dca5157..ed9ac622831 100644 --- a/apps/swift-ios/Features/Root/FeatureRootView.swift +++ b/apps/swift-ios/Features/Root/FeatureRootView.swift @@ -4,21 +4,29 @@ public struct FeatureRootView: View { @State private var model: FeatureRootModel private let navigationRequest: FeatureWorkspaceNavigationRequest? private let onNavigationRequestConsumed: @MainActor (UUID) -> Void + private let acknowledgeIncomingShare: (String) async -> Void + private let releaseIncomingSharePresentation: @MainActor (String) -> Void public init(client: any FeatureClient) { _model = State(initialValue: FeatureRootModel(client: client)) navigationRequest = nil onNavigationRequestConsumed = { _ in } + acknowledgeIncomingShare = { _ in } + releaseIncomingSharePresentation = { _ in } } init( model: FeatureRootModel, navigationRequest: FeatureWorkspaceNavigationRequest? = nil, - onNavigationRequestConsumed: @escaping @MainActor (UUID) -> Void = { _ in } + onNavigationRequestConsumed: @escaping @MainActor (UUID) -> Void = { _ in }, + acknowledgeIncomingShare: @escaping (String) async -> Void = { _ in }, + releaseIncomingSharePresentation: @escaping @MainActor (String) -> Void = { _ in } ) { _model = State(initialValue: model) self.navigationRequest = navigationRequest self.onNavigationRequestConsumed = onNavigationRequestConsumed + self.acknowledgeIncomingShare = acknowledgeIncomingShare + self.releaseIncomingSharePresentation = releaseIncomingSharePresentation } public var body: some View { @@ -35,7 +43,9 @@ public struct FeatureRootView: View { }, submitMessage: { submission in await model.sendMessage(submission) - } + }, + acknowledgeIncomingShare: acknowledgeIncomingShare, + releaseIncomingSharePresentation: releaseIncomingSharePresentation ) } else { ConnectionOnboardingView(model: model) diff --git a/apps/swift-ios/Features/Shared/FeatureComposerDraftStore.swift b/apps/swift-ios/Features/Shared/FeatureComposerDraftStore.swift index 5319a8770f4..5d834a52603 100644 --- a/apps/swift-ios/Features/Shared/FeatureComposerDraftStore.swift +++ b/apps/swift-ios/Features/Shared/FeatureComposerDraftStore.swift @@ -23,6 +23,26 @@ public struct FeatureComposerDraft: Sendable, Equatable { } } +public struct FeatureComposerDraftSnapshot: Sendable, Equatable { + public let draft: FeatureComposerDraft? + public let importedShareIDs: Set + + public init(draft: FeatureComposerDraft?, importedShareIDs: Set) { + self.draft = draft + self.importedShareIDs = importedShareIDs + } +} + +public struct FeatureComposerIncomingShareDraft: Sendable, Equatable { + public let shareID: String + public let draft: FeatureComposerDraft + + public init(shareID: String, draft: FeatureComposerDraft) { + self.shareID = shareID + self.draft = draft + } +} + public struct FeatureComposerWorkspaceDraft: Sendable, Equatable { public var mode: FeatureWorkspaceMode public var branch: String? @@ -55,6 +75,22 @@ public enum FeatureComposerDraftImportError: LocalizedError, Equatable, Sendable } } +public struct FeatureComposerDraftImportResult: Equatable, Sendable { + public let draft: FeatureComposerDraft + public let didImport: Bool + + public init(draft: FeatureComposerDraft, didImport: Bool) { + self.draft = draft + self.didImport = didImport + } +} + +public enum FeatureComposerIncomingShareRoutingResult: Equatable, Sendable { + case routed(FeatureComposerDraft) + case alreadyRouted(FeatureComposerDraft?) + case sourceMissing +} + /// Persists composer state independently of view navigation. Draft writes are /// atomic, and callers debounce high-frequency text changes before reaching /// this actor so image data is not repeatedly encoded for every keystroke. @@ -159,9 +195,18 @@ public actor FeatureComposerDraftStore { } public func draft(for key: String) throws -> FeatureComposerDraft? { - guard let draft = try loadIfNeeded()[key]?.featureValue, - !draft.isEmpty else { return nil } - return draft + try snapshot(for: key).draft + } + + public func snapshot(for key: String) throws -> FeatureComposerDraftSnapshot { + guard let persisted = try loadIfNeeded()[key] else { + return FeatureComposerDraftSnapshot(draft: nil, importedShareIDs: []) + } + let draft = persisted.featureValue + return FeatureComposerDraftSnapshot( + draft: draft.isEmpty ? nil : draft, + importedShareIDs: Set(persisted.importedShareIDs ?? []) + ) } public func setDraft(_ draft: FeatureComposerDraft, for key: String) throws { @@ -197,10 +242,31 @@ public actor FeatureComposerDraftStore { for key: String, maximumAttachmentCount: Int = 8 ) throws -> FeatureComposerDraft { + try importSharedContentResult( + shareID: shareID, + text: text, + attachments: attachments, + for: key, + maximumAttachmentCount: maximumAttachmentCount + ).draft + } + + public func importSharedContentResult( + shareID: String, + text: String, + attachments: [FeatureDraftAttachment], + for key: String, + maximumAttachmentCount: Int = 8 + ) throws -> FeatureComposerDraftImportResult { var drafts = try loadIfNeeded() var persisted = drafts[key] ?? PersistedDraft(FeatureComposerDraft()) var importedIDs = persisted.importedShareIDs ?? [] - guard !importedIDs.contains(shareID) else { return persisted.featureValue } + guard !importedIDs.contains(shareID) else { + return FeatureComposerDraftImportResult( + draft: persisted.featureValue, + didImport: false + ) + } let existingIDs = Set(persisted.attachments.map(\.id)) let uniqueAttachments = attachments.filter { !existingIDs.contains($0.id) } @@ -225,7 +291,63 @@ public actor FeatureComposerDraftStore { drafts[key] = persisted try persist(drafts) loadedDrafts = drafts - return persisted.featureValue + return FeatureComposerDraftImportResult( + draft: persisted.featureValue, + didImport: true + ) + } + + /// Atomically moves a share that was staged before project selection into + /// the chosen new-task draft. The destination ledger prevents replay from + /// duplicating content if the host app is interrupted during inbox cleanup. + @discardableResult + public func routeIncomingShare( + shareID: String, + to key: String, + maximumAttachmentCount: Int = 8 + ) throws -> FeatureComposerIncomingShareRoutingResult { + var drafts = try loadIfNeeded() + let sourceKey = Self.incomingShareKey(shareID: shareID) + guard let source = drafts[sourceKey] else { + guard let destination = drafts[key], + destination.importedShareIDs?.contains(shareID) == true else { + return .sourceMissing + } + return .alreadyRouted(destination.featureValue) + } + + var destination = drafts[key] ?? PersistedDraft(FeatureComposerDraft()) + var importedIDs = destination.importedShareIDs ?? [] + let wasAlreadyRouted = importedIDs.contains(shareID) + if !wasAlreadyRouted { + let existingIDs = Set(destination.attachments.map(\.id)) + let uniqueAttachments = source.attachments.filter { !existingIDs.contains($0.id) } + let availableCount = max(0, maximumAttachmentCount - destination.attachments.count) + guard uniqueAttachments.count <= availableCount else { + throw FeatureComposerDraftImportError.attachmentLimitExceeded( + available: availableCount + ) + } + + let incomingText = source.text.trimmingCharacters(in: .whitespacesAndNewlines) + if !incomingText.isEmpty { + destination.text = destination.text.trimmingCharacters(in: .whitespacesAndNewlines) + destination.text = destination.text.isEmpty + ? incomingText + : "\(destination.text)\n\n\(incomingText)" + } + destination.attachments.append(contentsOf: uniqueAttachments) + importedIDs.append(shareID) + destination.importedShareIDs = Array(importedIDs.suffix(32)) + } + + drafts[key] = destination + drafts.removeValue(forKey: sourceKey) + try persist(drafts) + loadedDrafts = drafts + return wasAlreadyRouted + ? .alreadyRouted(destination.featureValue) + : .routed(destination.featureValue) } public func removeDraft(for key: String) throws { @@ -258,6 +380,10 @@ public actor FeatureComposerDraftStore { "logical-project:\(logicalProjectID):new-task" } + public static func incomingShareKey(shareID: String) -> String { + "incoming-share:\(shareID.lowercased())" + } + private func loadIfNeeded() throws -> [String: PersistedDraft] { if let loadedDrafts { return loadedDrafts } guard FileManager.default.fileExists(atPath: fileURL.path) else { diff --git a/apps/swift-ios/Features/Workspace/NewThreadView.swift b/apps/swift-ios/Features/Workspace/NewThreadView.swift index fece83a3340..d1c3d4cda5a 100644 --- a/apps/swift-ios/Features/Workspace/NewThreadView.swift +++ b/apps/swift-ios/Features/Workspace/NewThreadView.swift @@ -8,6 +8,7 @@ public struct NewThreadView: View { let onCreateProject: @MainActor () -> Void private let draftStore: FeatureComposerDraftStore private let initialProjectID: String? + private let acknowledgeIncomingShare: (String) async -> Void @State private var projectID = "" @State private var prompt = "" @@ -30,6 +31,9 @@ public struct NewThreadView: View { @State private var draftSaveTask: Task? @State private var immediateDraftSaveTasks: [String: Task] = [:] @State private var submittedSuccessfully = false + @State private var pendingIncomingShareID: String? + @State private var restoredIncomingShareID: String? + @State private var showingDiscardIncomingShare = false @FocusState private var promptFocused: Bool public init( @@ -38,6 +42,8 @@ public struct NewThreadView: View { onCreated: @escaping (FeatureThread) -> Void, onCreateProject: @escaping @MainActor () -> Void = {}, initialProjectID: String? = nil, + incomingShareID: String? = nil, + acknowledgeIncomingShare: @escaping (String) async -> Void = { _ in }, draftStore: FeatureComposerDraftStore = .shared ) { self.model = model @@ -45,7 +51,9 @@ public struct NewThreadView: View { self.onCreated = onCreated self.onCreateProject = onCreateProject self.initialProjectID = initialProjectID + self.acknowledgeIncomingShare = acknowledgeIncomingShare self.draftStore = draftStore + _pendingIncomingShareID = State(initialValue: incomingShareID) } public var body: some View { @@ -88,7 +96,11 @@ public struct NewThreadView: View { } } .onAppear { - if projectID.isEmpty { + if pendingIncomingShareID != nil { + if !creationProjects.isEmpty { + activePicker = .project + } + } else if projectID.isEmpty { let initialProject = creationProjects.first { $0.id == initialProjectID } let initialGroup = initialProject.flatMap { DailyUXProjectGrouping.group( @@ -105,6 +117,7 @@ public struct NewThreadView: View { .onChange(of: projectID) { prepareProjectIfNeeded(projectID) } .onChange(of: creationProjectIDs) { _, ids in guard !ids.contains(projectID) else { return } + guard pendingIncomingShareID == nil else { return } persistCurrentDraftImmediately() let previousProject = model.snapshot.projects.first { $0.id == projectID } let previousGroupID = previousProject.map { @@ -122,6 +135,7 @@ public struct NewThreadView: View { .onChange(of: selectedBranch) { scheduleDraftSave() } .onChange(of: startFromOrigin) { scheduleDraftSave() } .task(id: projectID) { await restoreDraftAndLoadBranches() } + .task(id: pendingIncomingShareID) { await restoreIncomingShareDraft() } .onDisappear { guard !submittedSuccessfully else { return } persistCurrentDraftImmediately() @@ -158,14 +172,30 @@ public struct NewThreadView: View { } message: { Text("Check your connection and try again.") } - .interactiveDismissDisabled(isSubmitting) + .confirmationDialog( + "Discard this shared draft?", + isPresented: $showingDiscardIncomingShare, + titleVisibility: .visible + ) { + Button("Keep editing", role: .cancel) {} + Button("Discard shared draft", role: .destructive) { + discardIncomingShare() + } + } + .interactiveDismissDisabled(isSubmitting || pendingIncomingShareID != nil) .presentationDetents([.large]) .presentationDragIndicator(.visible) } private var topBar: some View { HStack { - Button("Cancel") { dismiss() } + Button("Cancel") { + if pendingIncomingShareID == nil { + dismiss() + } else { + showingDiscardIncomingShare = true + } + } .font(.body) .foregroundStyle(T3Colors.textSecondary) .disabled(isSubmitting) @@ -593,6 +623,8 @@ public struct NewThreadView: View { private func prepareProjectIfNeeded(_ id: String) { guard draftRestoreContext?.projectID != id else { return } + persistIncomingShareDraftImmediately() + if selectionIsExplicit, let selection { preferredSelection = selection } @@ -702,12 +734,59 @@ public struct NewThreadView: View { draftRestoreContext?.projectID == requestedProjectID else { return } - let saved = try? await draftStore.draft(for: key) + let routedShareID = pendingIncomingShareID + if let routedShareID { + let sourceKey = FeatureComposerDraftStore.incomingShareKey(shareID: routedShareID) + await NewTaskDraftWriteFence.wait(immediateDraftSaveTasks[sourceKey]) + } guard !Task.isCancelled, projectID == requestedProjectID, + pendingIncomingShareID == routedShareID, draftRestoreContext?.projectID == requestedProjectID else { return } + let saved: FeatureComposerDraft? + var shouldAcknowledgeShare = false + var routingError: String? + if let routedShareID { + do { + let result = try await draftStore.routeIncomingShare( + shareID: routedShareID, + to: key + ) + switch result { + case let .routed(draft), let .alreadyRouted(draft?): + saved = draft + shouldAcknowledgeShare = true + case .alreadyRouted(nil): + saved = nil + shouldAcknowledgeShare = true + case .sourceMissing: + saved = nil + routingError = "The shared draft is no longer available. Share it again to retry." + } + } catch { + saved = nil + routingError = error.localizedDescription + } + } else { + saved = try? await draftStore.draft(for: key) + } + let routeIsCurrent = !Task.isCancelled + && projectID == requestedProjectID + && pendingIncomingShareID == routedShareID + && draftRestoreContext?.projectID == requestedProjectID + guard routeIsCurrent else { + if let routedShareID, + shouldAcknowledgeShare, + pendingIncomingShareID == routedShareID { + restoredIncomingShareID = nil + pendingIncomingShareID = nil + await acknowledgeIncomingShare(routedShareID) + } + return + } + if let routingError { model.errorMessage = routingError } let liveDraft = composerDraft let liveSelectionIsExplicit = selectionIsExplicit @@ -744,12 +823,47 @@ public struct NewThreadView: View { workspaceSelectionIsExplicit = liveWorkspaceSelectionIsExplicit || saved?.workspace != nil restoredDraftProjectID = requestedProjectID + if let routedShareID, shouldAcknowledgeShare { + restoredIncomingShareID = nil + pendingIncomingShareID = nil + await acknowledgeIncomingShare(routedShareID) + } if liveDraft != context.baseline { scheduleDraftSave() } await loadBranches() } + @MainActor + private func restoreIncomingShareDraft() async { + guard let shareID = pendingIncomingShareID, + projectID.isEmpty else { return } + let key = FeatureComposerDraftStore.incomingShareKey(shareID: shareID) + let saved: FeatureComposerDraft? + let readError: String? + do { + saved = try await draftStore.draft(for: key) + readError = nil + } catch { + saved = nil + readError = error.localizedDescription + } + guard !Task.isCancelled, + pendingIncomingShareID == shareID, + projectID.isEmpty else { return } + if let readError { + model.errorMessage = readError + return + } + guard let saved else { return } + restoredIncomingShareID = shareID + prompt = saved.text + attachments = saved.attachments + if !creationProjects.isEmpty { + activePicker = .project + } + } + private var currentDraftKey: String? { guard let project = selectedProject else { return nil } return draftKey(for: project) @@ -788,6 +902,10 @@ public struct NewThreadView: View { } private func scheduleDraftSave() { + if projectID.isEmpty, pendingIncomingShareID != nil { + scheduleIncomingShareDraftSave() + return + } guard restoredDraftProjectID == projectID, !isSubmitting, !submittedSuccessfully, @@ -812,6 +930,63 @@ public struct NewThreadView: View { } } + private func scheduleIncomingShareDraftSave() { + guard let shareID = pendingIncomingShareID, + NewTaskIncomingSharePersistencePolicy.canPersist( + pendingShareID: shareID, + restoredShareID: restoredIncomingShareID + ), + !isSubmitting, + !submittedSuccessfully else { return } + let key = FeatureComposerDraftStore.incomingShareKey(shareID: shareID) + let previousSave = immediateDraftSaveTasks[key] + previousSave?.cancel() + let snapshot = composerDraft + let task = Task { @MainActor in + await NewTaskDraftWriteFence.wait(previousSave) + do { + try await Task.sleep(for: .milliseconds(220)) + try Task.checkCancellation() + try await draftStore.setDraft(snapshot, for: key) + } catch { + return + } + } + immediateDraftSaveTasks[key] = task + } + + private func persistIncomingShareDraftImmediately() { + guard let shareID = pendingIncomingShareID, + NewTaskIncomingSharePersistencePolicy.canPersist( + pendingShareID: shareID, + restoredShareID: restoredIncomingShareID + ) else { return } + let key = FeatureComposerDraftStore.incomingShareKey(shareID: shareID) + let previousSave = immediateDraftSaveTasks[key] + previousSave?.cancel() + let snapshot = composerDraft + let task = Task { @MainActor in + await NewTaskDraftWriteFence.wait(previousSave) + guard !Task.isCancelled else { return } + try? await draftStore.setDraft(snapshot, for: key) + } + immediateDraftSaveTasks[key] = task + } + + private func discardIncomingShare() { + guard let shareID = pendingIncomingShareID else { return } + let key = FeatureComposerDraftStore.incomingShareKey(shareID: shareID) + let pendingSave = immediateDraftSaveTasks.removeValue(forKey: key) + restoredIncomingShareID = nil + pendingIncomingShareID = nil + Task { @MainActor in + await NewTaskDraftWriteFence.cancelAndWait(pendingSave) + try? await draftStore.removeDraft(for: key) + await acknowledgeIncomingShare(shareID) + dismiss() + } + } + private func persistCurrentDraftImmediately() { guard !submittedSuccessfully, let key = currentDraftKey else { @@ -866,6 +1041,13 @@ enum NewTaskDraftWriteFence { } } +enum NewTaskIncomingSharePersistencePolicy { + static func canPersist(pendingShareID: String?, restoredShareID: String?) -> Bool { + guard let pendingShareID else { return false } + return restoredShareID == pendingShareID + } +} + /// Captures the clean target-project state before its persisted draft is read. /// Async restore results can then merge live typing without ever borrowing state /// from the project that was previously selected. diff --git a/apps/swift-ios/Features/Workspace/WorkspaceView.swift b/apps/swift-ios/Features/Workspace/WorkspaceView.swift index 34c82e049c2..8a506827346 100644 --- a/apps/swift-ios/Features/Workspace/WorkspaceView.swift +++ b/apps/swift-ios/Features/Workspace/WorkspaceView.swift @@ -4,8 +4,10 @@ import UIKit struct FeatureWorkspaceNavigationRequest: Equatable, Sendable { enum Destination: Equatable, Sendable { case thread(id: String) + case sharedThread(id: String, importDraft: FeatureComposerIncomingShareDraft) case project(id: String) case newTask(projectID: String?) + case sharedNewTask(shareID: String) } let id: UUID @@ -25,6 +27,8 @@ public struct WorkspaceView: View { private let onNavigationRequestConsumed: @MainActor (UUID) -> Void private let submitNewTask: (NewTaskRequest) async -> FeatureThread? private let submitMessage: (FeatureMessageSubmission) async -> Bool + private let acknowledgeIncomingShare: (String) async -> Void + private let releaseIncomingSharePresentation: @MainActor (String) -> Void @State private var selectedThreadID: String? @State private var selectedProjectID: String? @@ -36,6 +40,10 @@ public struct WorkspaceView: View { @State private var settledLimit = 12 @State private var showingNewTask = false @State private var newTaskInitialProjectID: String? + @State private var newTaskIncomingShareID: String? + @State private var dismissingNewTaskContext: NewTaskDismissalContext? + @State private var threadDetailPresentationRevision = 0 + @State private var sharedThreadImports: [String: [FeatureComposerIncomingShareDraft]] = [:] @State private var showingAddProject = false @State private var showingSettings = false @State private var renamingThread: FeatureThread? @@ -55,7 +63,9 @@ public struct WorkspaceView: View { navigationRequest: nil, onNavigationRequestConsumed: { _ in }, submitNewTask: submitNewTask, - submitMessage: submitMessage + submitMessage: submitMessage, + acknowledgeIncomingShare: { _ in }, + releaseIncomingSharePresentation: { _ in } ) } @@ -64,11 +74,15 @@ public struct WorkspaceView: View { navigationRequest: FeatureWorkspaceNavigationRequest?, onNavigationRequestConsumed: @escaping @MainActor (UUID) -> Void, submitNewTask: ((NewTaskRequest) async -> FeatureThread?)? = nil, - submitMessage: ((FeatureMessageSubmission) async -> Bool)? = nil + submitMessage: ((FeatureMessageSubmission) async -> Bool)? = nil, + acknowledgeIncomingShare: @escaping (String) async -> Void = { _ in }, + releaseIncomingSharePresentation: @escaping @MainActor (String) -> Void = { _ in } ) { self.model = model self.navigationRequest = navigationRequest self.onNavigationRequestConsumed = onNavigationRequestConsumed + self.acknowledgeIncomingShare = acknowledgeIncomingShare + self.releaseIncomingSharePresentation = releaseIncomingSharePresentation self.submitNewTask = submitNewTask ?? { request in do { let thread = try await model.client.createThreadAndSend( @@ -124,7 +138,7 @@ public struct WorkspaceView: View { detail } .navigationSplitViewStyle(.balanced) - .sheet(isPresented: $showingNewTask) { + .sheet(isPresented: $showingNewTask, onDismiss: newTaskDidDismiss) { NewThreadView( model: model, submit: submitNewTask, @@ -133,7 +147,9 @@ public struct WorkspaceView: View { showingNewTask = false }, onCreateProject: openProjectCreation, - initialProjectID: newTaskInitialProjectID + initialProjectID: newTaskInitialProjectID, + incomingShareID: newTaskIncomingShareID, + acknowledgeIncomingShare: acknowledgeIncomingShare ) } .sheet(isPresented: $showingAddProject) { @@ -267,6 +283,8 @@ public struct WorkspaceView: View { ThreadDetailView( model: model, thread: thread, + composerDraftReloadRevision: threadDetailPresentationRevision, + composerDraftReloadImports: sharedThreadImports[id] ?? [], submitMessage: submitMessage, onNavigateBack: closeSelectedThread ) @@ -583,6 +601,17 @@ public struct WorkspaceView: View { guard model.snapshot.threads.contains(where: { $0.id == id }) else { return } dismissTransientPresentations() openThread(id) + case let .sharedThread(id, importDraft): + guard model.snapshot.threads.contains(where: { $0.id == id }) else { return } + dismissTransientPresentations() + if selectedThreadID == id { + sharedThreadImports[id] = FeatureComposerIncomingShareReloadPolicy.appending( + importDraft, + to: sharedThreadImports[id] ?? [] + ) + threadDetailPresentationRevision &+= 1 + } + openThread(id) case let .project(id): guard model.snapshot.projects.contains(where: { $0.id == id }) else { return } dismissTransientPresentations() @@ -598,17 +627,48 @@ public struct WorkspaceView: View { await Task.yield() openNewTaskOrProjectCreation(initialProjectID: projectID) } + case let .sharedNewTask(shareID): + dismissTransientPresentations() + Task { @MainActor in + await Task.yield() + newTaskInitialProjectID = nil + newTaskIncomingShareID = shareID + showingNewTask = true + } } onNavigationRequestConsumed(navigationRequest.id) } private func dismissTransientPresentations() { + if showingNewTask { + dismissingNewTaskContext = NewTaskDismissalContext( + initialProjectID: newTaskInitialProjectID, + incomingShareID: newTaskIncomingShareID + ) + } showingNewTask = false showingAddProject = false showingSettings = false renamingThread = nil } + private func newTaskDidDismiss() { + let dismissed = dismissingNewTaskContext ?? NewTaskDismissalContext( + initialProjectID: newTaskInitialProjectID, + incomingShareID: newTaskIncomingShareID + ) + let resolution = dismissed.resolve( + currentInitialProjectID: newTaskInitialProjectID, + currentIncomingShareID: newTaskIncomingShareID + ) + if let shareID = resolution.releasedIncomingShareID { + releaseIncomingSharePresentation(shareID) + } + newTaskInitialProjectID = resolution.remainingInitialProjectID + newTaskIncomingShareID = resolution.remainingIncomingShareID + dismissingNewTaskContext = nil + } + private func projectMenuTitle(_ project: FeatureProject) -> String { guard model.snapshot.environments.count > 1, let environment = model.snapshot.environments.first(where: { @@ -620,6 +680,32 @@ public struct WorkspaceView: View { } } +struct NewTaskDismissalContext: Equatable { + let initialProjectID: String? + let incomingShareID: String? + + func resolve( + currentInitialProjectID: String?, + currentIncomingShareID: String? + ) -> NewTaskDismissalResolution { + NewTaskDismissalResolution( + remainingInitialProjectID: currentInitialProjectID == initialProjectID + ? nil + : currentInitialProjectID, + remainingIncomingShareID: currentIncomingShareID == incomingShareID + ? nil + : currentIncomingShareID, + releasedIncomingShareID: incomingShareID + ) + } +} + +struct NewTaskDismissalResolution: Equatable { + let remainingInitialProjectID: String? + let remainingIncomingShareID: String? + let releasedIncomingShareID: String? +} + private extension FeatureDraftAttachment { var uploadValue: FeatureUploadAttachment { FeatureUploadAttachment(data: data, name: filename, mimeType: mimeType) diff --git a/apps/swift-ios/Resources/Info.plist b/apps/swift-ios/Resources/Info.plist index ca899785e60..3a41b19a0ed 100644 --- a/apps/swift-ios/Resources/Info.plist +++ b/apps/swift-ios/Resources/Info.plist @@ -40,6 +40,8 @@ $(T3CODE_CLERK_PUBLISHABLE_KEY) T3ConnectRelayHTTPURL $(T3CODE_RELAY_URL) + T3CodeAppGroupIdentifier + $(T3CODE_APP_GROUP_IDENTIFIER) UIBackgroundModes fetch diff --git a/apps/swift-ios/Tests/FeatureTests/ComposerDraftStoreTests.swift b/apps/swift-ios/Tests/FeatureTests/ComposerDraftStoreTests.swift index 4bc32c90166..91f2bbd35d2 100644 --- a/apps/swift-ios/Tests/FeatureTests/ComposerDraftStoreTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/ComposerDraftStoreTests.swift @@ -222,6 +222,133 @@ struct ComposerDraftStoreTests { #expect(merged.workspace == fallbackWorkspace) } + @Test func incomingShareAppendsToTheCurrentComposer() { + let liveDraft = FeatureComposerDraft( + text: "Typed before import", + attachments: [FeatureDraftAttachment( + data: Data([0x01]), + filename: "live.png", + mimeType: "image/png" + )] + ) + let importedAttachment = FeatureDraftAttachment( + data: Data([0x02]), + filename: "shared.png", + mimeType: "image/png" + ) + let incomingDraft = FeatureComposerDraft( + text: "Shared content", + attachments: [importedAttachment] + ) + + let result = FeatureComposerIncomingShareMerge.merge( + current: liveDraft, + incoming: incomingDraft + ) + + #expect(result.draft.text == "Typed before import\n\nShared content") + #expect(result.draft.attachments == liveDraft.attachments + [importedAttachment]) + #expect(result.attachmentOverflowCount == 0) + } + + @Test func incomingShareMergeKeepsAttachmentsAndReportsTheRequiredRemovalCount() { + let currentAttachments = (0..<8).map { value in + FeatureDraftAttachment( + data: Data([UInt8(value)]), + filename: "live-\(value).png", + mimeType: "image/png" + ) + } + let incoming = FeatureComposerDraft( + text: "Shared", + attachments: [FeatureDraftAttachment( + data: Data([0xFF]), + filename: "shared.png", + mimeType: "image/png" + )] + ) + + let result = FeatureComposerIncomingShareMerge.merge( + current: FeatureComposerDraft(text: " ", attachments: currentAttachments), + incoming: incoming + ) + + #expect(result.draft.text == "Shared") + #expect(result.draft.attachments == currentAttachments + incoming.attachments) + #expect(result.attachmentOverflowCount == 1) + } + + @Test func draftSnapshotReportsWhichShareImportsRestorationConsumed() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let key = "environment:one:thread:one" + _ = try await store.importSharedContent( + shareID: "share-one", + text: "Shared", + attachments: [], + for: key + ) + + let snapshot = try await store.snapshot(for: key) + + #expect(snapshot.draft?.text == "Shared") + #expect(snapshot.importedShareIDs == ["share-one"]) + } + + @Test func reloadPolicyRetainsEveryShareNotConsumedByInitialRestoration() { + let imports = [ + FeatureComposerIncomingShareDraft( + shareID: "already-restored", + draft: FeatureComposerDraft(text: "First") + ), + FeatureComposerIncomingShareDraft( + shareID: "pending-one", + draft: FeatureComposerDraft(text: "Second") + ), + FeatureComposerIncomingShareDraft( + shareID: "pending-two", + draft: FeatureComposerDraft(text: "Third") + ), + ] + + let pending = FeatureComposerIncomingShareReloadPolicy.pendingImports( + imports, + restoredShareIDs: ["already-restored"] + ) + + #expect(pending.map(\.shareID) == ["pending-one", "pending-two"]) + } + + @Test func reloadQueueMatchesThePersistedLedgerWindowAndDeduplicatesReplays() { + var imports: [FeatureComposerIncomingShareDraft] = [] + for index in 0..<34 { + imports = FeatureComposerIncomingShareReloadPolicy.appending( + FeatureComposerIncomingShareDraft( + shareID: "share-\(index)", + draft: FeatureComposerDraft(text: "Shared \(index)") + ), + to: imports + ) + } + imports = FeatureComposerIncomingShareReloadPolicy.appending( + FeatureComposerIncomingShareDraft( + shareID: "share-33", + draft: FeatureComposerDraft(text: "Replayed 33") + ), + to: imports + ) + + #expect(imports.count == 32) + #expect(imports.first?.shareID == "share-2") + #expect(imports.last?.shareID == "share-33") + #expect(imports.last?.draft.text == "Replayed 33") + #expect(imports.filter { $0.shareID == "share-33" }.count == 1) + } + @Test func successfulSubmissionFenceWaitsForCancelledDraftWrites() async { let started = AsyncStream.makeStream() let release = AsyncStream.makeStream() @@ -245,4 +372,74 @@ struct ComposerDraftStoreTests { #expect(await eventIterator.next() == "write finished") #expect(await eventIterator.next() == "draft removed") } + + @Test func incomingSharePersistenceWaitsForTheMatchingRestore() { + #expect(!NewTaskIncomingSharePersistencePolicy.canPersist( + pendingShareID: "share-a", + restoredShareID: nil + )) + #expect(!NewTaskIncomingSharePersistencePolicy.canPersist( + pendingShareID: "share-a", + restoredShareID: "share-b" + )) + #expect(NewTaskIncomingSharePersistencePolicy.canPersist( + pendingShareID: "share-a", + restoredShareID: "share-a" + )) + #expect(!NewTaskIncomingSharePersistencePolicy.canPersist( + pendingShareID: nil, + restoredShareID: "share-a" + )) + } + + @Test func sharedThreadNavigationRequestsAComposerReload() { + let importDraft = FeatureComposerIncomingShareDraft( + shareID: "share-1", + draft: FeatureComposerDraft(text: "Shared") + ) + let request = FeatureWorkspaceNavigationRequest( + destination: .sharedThread( + id: "thread-1", + importDraft: importDraft + ) + ) + + #expect(request.destination == .sharedThread( + id: "thread-1", + importDraft: importDraft + )) + #expect(request.destination != .thread(id: "thread-1")) + } + + @Test func dismissingOldShareSheetPreservesReplacementContext() { + let resolution = NewTaskDismissalContext( + initialProjectID: "project-a", + incomingShareID: "share-a" + ).resolve( + currentInitialProjectID: nil, + currentIncomingShareID: "share-b" + ) + + #expect(resolution == NewTaskDismissalResolution( + remainingInitialProjectID: nil, + remainingIncomingShareID: "share-b", + releasedIncomingShareID: "share-a" + )) + } + + @Test func dismissingCurrentShareSheetClearsItsContext() { + let resolution = NewTaskDismissalContext( + initialProjectID: "project-a", + incomingShareID: "share-a" + ).resolve( + currentInitialProjectID: "project-a", + currentIncomingShareID: "share-a" + ) + + #expect(resolution == NewTaskDismissalResolution( + remainingInitialProjectID: nil, + remainingIncomingShareID: nil, + releasedIncomingShareID: "share-a" + )) + } } diff --git a/apps/swift-ios/Tests/PlatformTests/PlatformFeedbackTests.swift b/apps/swift-ios/Tests/PlatformTests/PlatformFeedbackTests.swift index eb71e845b2b..b37f5959e8c 100644 --- a/apps/swift-ios/Tests/PlatformTests/PlatformFeedbackTests.swift +++ b/apps/swift-ios/Tests/PlatformTests/PlatformFeedbackTests.swift @@ -49,26 +49,38 @@ struct PlatformFeedbackTests { } @Test - func recentThreadStoreSortsLimitsAndSkipsArchived() throws { + func recentThreadStoreSortsAndSkipsArchived() throws { let suiteName = "PlatformFeedbackTests.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } let store = PlatformRecentThreadStore(defaults: defaults, key: "recent") - var threads = (0 ..< 14).map { index in + var threads = (0 ..< 102).map { index in thread( id: "thread-\(index)", state: .idle, updatedAt: Date(timeIntervalSince1970: TimeInterval(index)) ) } - threads[13].isArchived = true + threads[101].isArchived = true store.update(from: threads) let records = store.records() - #expect(records.count == 12) - #expect(records.first?.id == "thread-12") - #expect(!records.contains { $0.id == "thread-13" }) + #expect(records.count == T3SharedRecentThreadStore.maximumCount) + #expect(records.first?.id == "thread-100") + #expect(!records.contains { $0.id == "thread-101" }) + } + + @Test + func sharedAppearanceStoreDefaultsToSystemAndRoundTripsDarkMode() throws { + let suiteName = "PlatformFeedbackTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let store = T3SharedAppearanceStore(defaults: defaults) + + #expect(store.appearance() == .system) + store.update(.dark) + #expect(store.appearance() == .dark) } private func thread( diff --git a/apps/swift-ios/Tests/PlatformTests/PlatformIncomingShareTests.swift b/apps/swift-ios/Tests/PlatformTests/PlatformIncomingShareTests.swift index bdff3103454..578a9eca0f0 100644 --- a/apps/swift-ios/Tests/PlatformTests/PlatformIncomingShareTests.swift +++ b/apps/swift-ios/Tests/PlatformTests/PlatformIncomingShareTests.swift @@ -4,6 +4,33 @@ import Testing @Suite("Incoming share import") struct PlatformIncomingShareTests { + @Test @MainActor + func routingGateCoalescesARequestThatArrivesWhileRouting() async { + let gate = PlatformIncomingShareRoutingGate() + let started = AsyncStream.makeStream() + let release = AsyncStream.makeStream() + var runCount = 0 + + let first = Task { @MainActor in + await gate.request { + runCount += 1 + if runCount == 1 { + started.continuation.yield() + for await _ in release.stream { break } + } + } + } + var startedIterator = started.stream.makeAsyncIterator() + _ = await startedIterator.next() + + await gate.request { runCount += 1 } + await gate.request { runCount += 10 } + release.continuation.yield() + await first.value + + #expect(runCount == 11) + } + @Test func persistsMergedDraftBeforeRemovingInboxEnvelope() async throws { let recorder = IncomingShareTestRecorder() @@ -55,7 +82,7 @@ struct PlatformIncomingShareTests { ) await recorder.capture(draft: draft, key: key) await recorder.record("import:\(key)") - return draft + return PlatformIncomingShareDraftImport(draft: draft, didImport: true) } ), prepareImage: { data, ordinal in @@ -127,7 +154,10 @@ struct PlatformIncomingShareTests { drafts: PlatformIncomingShareDraftRepository( importContent: { _, _, _, _, _ in await recorder.record("import") - return FeatureComposerDraft() + return PlatformIncomingShareDraftImport( + draft: FeatureComposerDraft(), + didImport: true + ) } ), prepareImage: { _, _ in Self.attachment(id: UUID(), value: 1) } @@ -173,13 +203,14 @@ struct PlatformIncomingShareTests { drafts: PlatformIncomingShareDraftRepository( importContent: { shareID, text, attachments, key, maximumCount in await recorder.record("import") - return try await store.importSharedContent( + let draft = try await store.importSharedContent( shareID: shareID, text: text, attachments: attachments, for: key, maximumAttachmentCount: maximumCount ) + return PlatformIncomingShareDraftImport(draft: draft, didImport: true) } ), prepareImage: { _, _ in Self.attachment(id: UUID(), value: 1) } @@ -221,7 +252,7 @@ struct PlatformIncomingShareTests { var edited = once edited.text += "\nUser edit" try await store.setDraft(edited, for: key) - let twice = try await store.importSharedContent( + let replay = try await store.importSharedContentResult( shareID: "share-id", text: "Shared", attachments: [attachment], @@ -230,7 +261,203 @@ struct PlatformIncomingShareTests { #expect(once.text == "Existing\n\nShared") #expect(once.attachments == [attachment]) - #expect(twice == edited) + #expect(replay.draft == edited) + #expect(!replay.didImport) + } + + @Test + func replayAfterInboxRemovalFailureStillReturnsTheShareIdentityAndDelta() async throws { + let recorder = IncomingShareTestRecorder() + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let envelope = Self.envelope(text: "Shared once") + let thread = FeatureThread( + id: "thread:environment:thread", + wireID: "thread", + projectID: "project", + environmentID: "environment", + title: "Thread" + ) + let pipeline = PlatformIncomingSharePipeline( + source: PlatformIncomingShareSource( + loadAll: { [envelope] }, + data: { _ in Data() }, + remove: { id in + await recorder.record("remove:\(id)") + if await recorder.events.count == 1 { + throw IncomingShareTestError.removeFailed + } + } + ), + drafts: PlatformIncomingShareDraftRepository( + importContent: { shareID, text, attachments, key, maximumCount in + let result = try await store.importSharedContentResult( + shareID: shareID, + text: text, + attachments: attachments, + for: key, + maximumAttachmentCount: maximumCount + ) + return PlatformIncomingShareDraftImport( + draft: result.draft, + didImport: result.didImport + ) + } + ) + ) + + do { + _ = try await pipeline.importEnvelope(envelope, into: thread) + Issue.record("Expected inbox removal to fail") + } catch { + #expect(error as? IncomingShareTestError == .removeFailed) + } + + let replay = try await pipeline.importEnvelope(envelope, into: thread) + let snapshot = try await store.snapshot(for: FeatureComposerDraftStore.threadKey(thread)) + + #expect(replay.sharedContent.shareID == envelope.id) + #expect(replay.sharedContent.draft.text == "Shared once") + #expect(snapshot.draft?.text == "Shared once") + #expect(snapshot.importedShareIDs == [envelope.id]) + #expect(await recorder.events.count == 2) + } + + @Test + func existingThreadImportUsesItsDraftAndRepresentsVideoAsAContactSheet() async throws { + let recorder = IncomingShareTestRecorder() + let videoID = "12345678-1234-1234-1234-123456789abc" + let envelope = Self.envelope( + text: "Review this", + videos: [Self.video(id: videoID)] + ) + let thread = FeatureThread( + id: "thread:environment:thread", + wireID: "thread", + projectID: "project", + environmentID: "environment", + title: "Existing" + ) + let pipeline = PlatformIncomingSharePipeline( + source: PlatformIncomingShareSource( + loadAll: { [envelope] }, + data: { _ in Data() }, + videoURL: { video in + await recorder.record("video:\(video.id)") + return URL(fileURLWithPath: "/tmp/video.mov") + }, + remove: { id in await recorder.record("remove:\(id)") } + ), + drafts: PlatformIncomingShareDraftRepository( + importContent: { shareID, text, attachments, key, _ in + await recorder.capture( + draft: FeatureComposerDraft(text: text, attachments: attachments), + key: key + ) + await recorder.record("import:\(shareID)") + return PlatformIncomingShareDraftImport( + draft: FeatureComposerDraft(text: text, attachments: attachments), + didImport: true + ) + } + ), + prepareImage: { _, _ in Self.attachment(id: UUID(), value: 1) }, + prepareVideo: { _, _, _ in + Self.attachment(id: UUID(), value: 9) + } + ) + + let imported = try await pipeline.importEnvelope(envelope, into: thread) + let captured = await recorder.capturedDraft + + #expect(captured?.key == FeatureComposerDraftStore.threadKey(thread)) + #expect(imported.draft.text.contains("Review this")) + #expect(imported.sharedContent.shareID == envelope.id) + #expect(imported.sharedContent.draft.text.contains("Shared video: reference.mov")) + #expect( + imported.sharedContent.draft.attachments.first?.id.uuidString.lowercased() == videoID + ) + #expect(await recorder.events == [ + "video:\(videoID)", + "import:\(envelope.id)", + "remove:\(envelope.id)", + ]) + } + + @Test + func newThreadShareStaysDurableUntilProjectSelectionRoutesIt() async throws { + let recorder = IncomingShareTestRecorder() + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let envelope = Self.envelope(text: "Choose my project") + let pipeline = PlatformIncomingSharePipeline( + source: PlatformIncomingShareSource( + loadAll: { [envelope] }, + data: { _ in Data() }, + remove: { id in await recorder.record("remove:\(id)") } + ), + drafts: PlatformIncomingShareDraftRepository( + importContent: { shareID, text, attachments, key, maximumCount in + let draft = try await store.importSharedContent( + shareID: shareID, + text: text, + attachments: attachments, + for: key, + maximumAttachmentCount: maximumCount + ) + return PlatformIncomingShareDraftImport(draft: draft, didImport: true) + } + ), + prepareImage: { _, _ in Self.attachment(id: UUID(), value: 1) } + ) + + _ = try await pipeline.stageEnvelopeForNewThread(envelope) + let transferKey = FeatureComposerDraftStore.incomingShareKey(shareID: envelope.id) + #expect(try await store.draft(for: transferKey)?.text == "Choose my project") + #expect(await recorder.events.isEmpty) + + let project = Self.project() + let destinationKey = FeatureComposerDraftStore.newTaskKey(project: project) + let routed = try await store.routeIncomingShare( + shareID: envelope.id, + to: destinationKey + ) + guard case let .routed(routedDraft) = routed else { + Issue.record("Expected the staged share to be routed") + return + } + #expect(routedDraft.text == "Choose my project") + #expect(try await store.draft(for: transferKey) == nil) + #expect(try await store.draft(for: destinationKey) == routedDraft) + + try await pipeline.acknowledgeEnvelope(id: envelope.id) + #expect(await recorder.events == ["remove:\(envelope.id)"]) + } + + @Test + func missingStagedShareNeverMasqueradesAsAnExistingDestinationDraft() async throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureComposerDraftStore(fileURL: directory.appending(path: "drafts.json")) + let key = "new-task:project" + try await store.setDraft(FeatureComposerDraft(text: "Existing text"), for: key) + + let result = try await store.routeIncomingShare( + shareID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + to: key + ) + + #expect(result == .sourceMissing) + #expect(try await store.draft(for: key)?.text == "Existing text") } @Test @@ -245,7 +472,12 @@ struct PlatformIncomingShareTests { remove: { _ in } ), drafts: PlatformIncomingShareDraftRepository( - importContent: { _, _, _, _, _ in FeatureComposerDraft() } + importContent: { _, _, _, _, _ in + PlatformIncomingShareDraftImport( + draft: FeatureComposerDraft(), + didImport: true + ) + } ), prepareImage: { _, _ in Self.attachment(id: UUID(), value: 1) } ) @@ -257,9 +489,73 @@ struct PlatformIncomingShareTests { #expect(coordinator.pendingEnvelope == envelope) } + @Test + @MainActor + func staleThreadDestinationCanFallBackToThePicker() async throws { + var envelope = Self.envelope(text: "Pending") + envelope.destination = .existingThread(environmentID: "env", threadID: "deleted") + let coordinator = PlatformIncomingShareCoordinator( + pipeline: PlatformIncomingSharePipeline( + source: PlatformIncomingShareSource( + loadAll: { [envelope] }, + data: { _ in Data() }, + remove: { _ in }, + updateDestination: { _, _ in } + ), + drafts: PlatformIncomingShareDraftRepository( + importContent: { _, _, _, _, _ in + PlatformIncomingShareDraftImport( + draft: FeatureComposerDraft(), + didImport: true + ) + } + ), + prepareImage: { _, _ in Self.attachment(id: UUID(), value: 1) } + ) + ) + + _ = await coordinator.refresh(hasProjects: true) + try await coordinator.requestAnotherDestination() + + #expect(coordinator.pendingEnvelope?.destination == nil) + } + + @Test + @MainActor + func preferredEnvelopeDoesNotFallBackToAnotherPendingShare() async { + let envelope = Self.envelope(text: "Do not route me") + let coordinator = PlatformIncomingShareCoordinator( + pipeline: PlatformIncomingSharePipeline( + source: PlatformIncomingShareSource( + loadAll: { [envelope] }, + data: { _ in Data() }, + remove: { _ in } + ), + drafts: PlatformIncomingShareDraftRepository( + importContent: { _, _, _, _, _ in + PlatformIncomingShareDraftImport( + draft: FeatureComposerDraft(), + didImport: true + ) + } + ), + prepareImage: { _, _ in Self.attachment(id: UUID(), value: 1) } + ) + ) + + #expect( + !(await coordinator.refresh( + preferredID: "ffffffff-ffff-ffff-ffff-ffffffffffff", + hasProjects: true + )) + ) + #expect(coordinator.pendingEnvelope == nil) + } + private static func envelope( text: String = "", - images: [T3IncomingShareImage] = [] + images: [T3IncomingShareImage] = [], + videos: [T3IncomingShareVideo] = [] ) -> T3IncomingShareEnvelope { T3IncomingShareEnvelope( schemaVersion: T3IncomingShareEnvelope.schemaVersion, @@ -267,6 +563,7 @@ struct PlatformIncomingShareTests { createdAt: Date(timeIntervalSince1970: 100), text: text, images: images, + videos: videos, warnings: [] ) } @@ -281,6 +578,16 @@ struct PlatformIncomingShareTests { ) } + private static func video(id: String) -> T3IncomingShareVideo { + T3IncomingShareVideo( + id: id, + fileName: "reference.mov", + typeIdentifier: "com.apple.quicktime-movie", + relativePath: "video.mov", + byteCount: 2 + ) + } + private static func attachment(id: UUID, value: UInt8) -> FeatureDraftAttachment { FeatureDraftAttachment( id: id, @@ -303,6 +610,7 @@ struct PlatformIncomingShareTests { private enum IncomingShareTestError: Error, Equatable { case imageFailed + case removeFailed case saveFailed }