diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticBuildStamp.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticBuildStamp.swift index f1ab5965d93..395cbc8abef 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticBuildStamp.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticBuildStamp.swift @@ -5,9 +5,11 @@ import Foundation /// The git SHA and dev tag are signed bundle metadata, not runtime input. The /// helper keeps iOS and macOS reports comparable and applies the same bounded /// sanitization at their shared boundary. -public enum DiagnosticBuildStamp { +public struct DiagnosticBuildStamp { + public init() {} + /// Returns a bounded `name version (build) tag sha` stamp from bundle data. - public static func make( + public func make( infoDictionary: [String: Any]?, fallbackName: String = "cmux" ) -> String { @@ -30,7 +32,7 @@ public enum DiagnosticBuildStamp { return DiagnosticReport.sanitizeBuildStamp(result) } - private static func nonEmptyString(_ value: Any?) -> String? { + private func nonEmptyString(_ value: Any?) -> String? { guard let value = value as? String else { return nil } let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed diff --git a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticBuildStampTests.swift b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticBuildStampTests.swift index 3d281bae56a..3afc50029ba 100644 --- a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticBuildStampTests.swift +++ b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticBuildStampTests.swift @@ -3,7 +3,7 @@ import Testing @Suite struct DiagnosticBuildStampTests { @Test func includesSignedTagAndShaWithoutRuntimeInput() { - let stamp = DiagnosticBuildStamp.make(infoDictionary: [ + let stamp = DiagnosticBuildStamp().make(infoDictionary: [ "CFBundleName": "cmux", "CFBundleShortVersionString": "1.2.3", "CFBundleVersion": "42", @@ -15,7 +15,7 @@ import Testing } @Test func removesUnsafeBundleMetadata() { - let stamp = DiagnosticBuildStamp.make(infoDictionary: [ + let stamp = DiagnosticBuildStamp().make(infoDictionary: [ "CFBundleName": "cmux/unsafe", "CFBundleShortVersionString": "1\n2", "CFBundleVersion": "7", diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactAction.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactAction.swift index 84e33981376..49bb32d77bc 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactAction.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactAction.swift @@ -8,6 +8,8 @@ public enum ChatArtifactAction: Hashable, Sendable { case save /// Copies loaded image data to the system pasteboard. case copyImage + /// Copies a materialized artifact file to the system pasteboard. + case copyFile /// Copies the artifact's rendered text contents. case copyContents /// Copies the artifact's remote path. @@ -22,6 +24,8 @@ public enum ChatArtifactAction: Hashable, Sendable { String(localized: "chat.artifact.save_to_files", defaultValue: "Save to Files", bundle: .module) case .copyImage: String(localized: "chat.artifact.copy_image", defaultValue: "Copy image", bundle: .module) + case .copyFile: + String(localized: "chat.artifact.copy_file", defaultValue: "Copy file", bundle: .module) case .copyContents: String(localized: "chat.artifact.copy_contents", defaultValue: "Copy contents", bundle: .module) case .copyPath: @@ -36,7 +40,7 @@ public enum ChatArtifactAction: Hashable, Sendable { "square.and.arrow.up" case .save: "folder.badge.plus" - case .copyImage: + case .copyImage, .copyFile: "doc.on.doc" case .copyContents: "doc.on.doc" diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactActionVisibilityPolicy.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactActionVisibilityPolicy.swift index 7333c050165..461c4af8c6b 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactActionVisibilityPolicy.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactActionVisibilityPolicy.swift @@ -12,13 +12,13 @@ struct ChatArtifactActionVisibilityPolicy: Equatable { actions = Self.imageActions inlineStateIdentity = "image" case .pdf: - actions = [.share, .save] + actions = [.share, .save, .copyFile] inlineStateIdentity = "pdf" case .media: - actions = [.share, .save] + actions = [.share, .save, .copyFile] inlineStateIdentity = "media" case .quickLook: - actions = [.share, .save] + actions = [.share, .save, .copyFile] inlineStateIdentity = "quick-look" case .loading, .folder, .text, .markdown, .binary, .failure: actions = [] @@ -38,7 +38,7 @@ struct ChatArtifactActionVisibilityPolicy: Equatable { return } actions = isTextFile - ? [.share, .save, .copyContents, .copyPath] - : [.share, .save, .copyPath] + ? [.share, .save, .copyFile, .copyContents, .copyPath] + : [.share, .save, .copyFile, .copyPath] } } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactInlineViewer.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactInlineViewer.swift index b0c6afac467..0fca7d61129 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactInlineViewer.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactInlineViewer.swift @@ -132,6 +132,8 @@ public struct ChatArtifactInlineViewer: View { guard case .image(let data) = pageModel.snapshot.state else { return } UIPasteboard.general.image = UIImage(data: data) loader.recordDiagnostic(.artifactCopied) + case .copyFile: + Task { _ = await pageModel.copyFile(loader: loader) } case .copyContents, .copyPath: break } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageModel.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageModel.swift index d137f659ef2..c7c9fe2f01e 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageModel.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageModel.swift @@ -3,6 +3,10 @@ import CmuxAgentChat import Foundation import Observation +#if os(iOS) +import UIKit +#endif + /// Owns mutable content and controls for one stable artifact path. @Observable @MainActor @@ -230,6 +234,28 @@ final class ChatArtifactViewerPageModel { ) } + func copyFile(loader: ChatArtifactLoader) async -> Bool { + guard !fileActionState.isRunning else { return false } + fileActionState.failure = nil + fileActionState.isRunning = true + defer { fileActionState.isRunning = false } + do { + let fileURL = try await ChatArtifactFileActionStore.applicationDefault.materialize( + path: path, + loader: loader + ) + try Task.checkCancellation() + UIPasteboard.general.urls = [fileURL] + loader.recordDiagnostic(.artifactCopied) + return true + } catch is CancellationError { + return false + } catch { + fileActionState.failure = (error as? ChatArtifactError) ?? .loadFailed + return false + } + } + func setFileActionPresentation(_ presentation: ChatArtifactFileActionPresentation?) { fileActionState.presentation = presentation } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift index d0b0add9dc2..3f276161188 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift @@ -221,6 +221,11 @@ struct ChatArtifactViewerPager: View { UIPasteboard.general.image = UIImage(data: data) loader.recordDiagnostic(.artifactCopied) toasts.present(.copied()) + case .copyFile: + Task { + guard await model.copyFile(for: snapshot.path, loader: loader) else { return } + toasts.present(.copied()) + } case .copyContents: UIPasteboard.general.string = snapshot.renderedText loader.recordDiagnostic(.artifactCopied) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPagerModel.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPagerModel.swift index 1edb9050354..cf2f1a8453e 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPagerModel.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPagerModel.swift @@ -122,6 +122,11 @@ final class ChatArtifactViewerPagerModel { await page.prepareSave(loader: loader) } + func copyFile(for path: String, loader: ChatArtifactLoader) async -> Bool { + guard let page = pageModel(for: path) else { return false } + return await page.copyFile(loader: loader) + } + func setFileActionPresentation( _ presentation: ChatArtifactFileActionPresentation?, for path: String diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift index d334b236d80..67f35d99c5b 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift @@ -394,9 +394,30 @@ public struct ChatComposerView: View { } private var attachButton: some View { - Button { - onDiagnosticEvent(.photoPickerOpened) - isPhotoPickerPresented = true + Menu { + Button { + onDiagnosticEvent(.photoPickerOpened) + isPhotoPickerPresented = true + } label: { + Label( + String( + localized: "chat.composer.attach.photo", + defaultValue: "Photo Library", + bundle: .module + ), + systemImage: "photo.on.rectangle" + ) + } + Button(action: performPaste) { + Label( + String( + localized: "chat.composer.attach.paste", + defaultValue: "Paste Attachment", + bundle: .module + ), + systemImage: "doc.on.clipboard" + ) + } } label: { MobileComposerIconLabel( systemImage: "paperclip", diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings index 7364dd0c372..67d5767b996 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings @@ -460,6 +460,23 @@ } } }, + "chat.artifact.copy_file": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy file" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルをコピー" + } + } + } + }, "chat.artifact.copy_image": { "extractionState": "manual", "localizations": { @@ -3010,6 +3027,20 @@ } } }, + "chat.composer.attach.photo": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Photo Library" } }, + "ja": { "stringUnit": { "state": "translated", "value": "写真ライブラリ" } } + } + }, + "chat.composer.attach.paste": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Paste Attachment" } }, + "ja": { "stringUnit": { "state": "translated", "value": "添付ファイルをペースト" } } + } + }, "chat.unsupported": { "extractionState": "manual", "localizations": { diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactActionVisibilityPolicyTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactActionVisibilityPolicyTests.swift index 716a9ecde7f..2b40328bceb 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactActionVisibilityPolicyTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactActionVisibilityPolicyTests.swift @@ -16,6 +16,7 @@ struct ChatArtifactActionVisibilityPolicyTests { #expect(ChatArtifactAction.share.systemImage == "square.and.arrow.up") #expect(ChatArtifactAction.save.systemImage == "folder.badge.plus") #expect(ChatArtifactAction.copyImage.systemImage == "doc.on.doc") + #expect(ChatArtifactAction.copyFile.systemImage == "doc.on.doc") #expect(ChatArtifactAction.copyContents.systemImage == "doc.on.doc") #expect(ChatArtifactAction.copyPath.systemImage == "link") } @@ -24,9 +25,9 @@ struct ChatArtifactActionVisibilityPolicyTests { func documentPreviewsOfferShareAndSave() { let url = URL(fileURLWithPath: "/tmp/artifact") - #expect(ChatArtifactActionVisibilityPolicy(inlineState: .pdf(fileURL: url)).actions == [.share, .save]) - #expect(ChatArtifactActionVisibilityPolicy(inlineState: .media(fileURL: url)).actions == [.share, .save]) - #expect(ChatArtifactActionVisibilityPolicy(inlineState: .quickLook(fileURL: url)).actions == [.share, .save]) + #expect(ChatArtifactActionVisibilityPolicy(inlineState: .pdf(fileURL: url)).actions == [.share, .save, .copyFile]) + #expect(ChatArtifactActionVisibilityPolicy(inlineState: .media(fileURL: url)).actions == [.share, .save, .copyFile]) + #expect(ChatArtifactActionVisibilityPolicy(inlineState: .quickLook(fileURL: url)).actions == [.share, .save, .copyFile]) } @Test @@ -61,11 +62,11 @@ struct ChatArtifactActionVisibilityPolicyTests { #expect(ChatArtifactActionVisibilityPolicy( viewerHasFileActions: true, isTextFile: false - ).actions == [.share, .save, .copyPath]) + ).actions == [.share, .save, .copyFile, .copyPath]) #expect(ChatArtifactActionVisibilityPolicy( viewerHasFileActions: true, isTextFile: true - ).actions == [.share, .save, .copyContents, .copyPath]) + ).actions == [.share, .save, .copyFile, .copyContents, .copyPath]) #expect(ChatArtifactActionVisibilityPolicy( viewerHasFileActions: false, isTextFile: false diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskAttachments.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskAttachments.swift index b1cd72faa96..ea730c9c5b3 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskAttachments.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskAttachments.swift @@ -4,6 +4,25 @@ public import CmuxMobileShellModel public import Foundation extension MobileShellComposite { + /// Uploads a file selected for the active terminal composer and returns the + /// Mac-local path that can be inserted into the draft. + /// + /// - Parameter attachment: App-owned staged attachment file to upload. + /// - Returns: The final absolute Mac path, or a user-actionable failure. + public func uploadTerminalComposerAttachment( + _ attachment: TaskComposerAttachment + ) async -> Result { + guard let macDeviceID = foregroundMacDeviceID else { + return .failure(.notConnected(hostDisplayName: nil)) + } + return await uploadTaskAttachment( + attachment, + operationID: UUID(), + macDeviceID: macDeviceID, + instanceTag: activeMacInstanceTag + ) + } + /// Whether the selected Mac instance currently advertises task attachments. /// /// - Parameters: diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings index a209a2b1cbc..1c729e7747c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings @@ -1608,6 +1608,41 @@ "en" : { "stringUnit" : { "state" : "translated", "value" : "Update cmux on both devices. If needed, remove and pair the Mac again." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "両方のデバイスで cmux を更新してください。必要に応じて Mac を削除し、再度ペアリングしてください。" } } } }, + "mobile.taskComposer.attachments.paste" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Paste Attachment" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "添付ファイルをペースト" } } + } + }, + "mobile.composer.attach" : { "extractionState" : "manual", "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Add Attachment" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "添付ファイルを追加" } } + } }, + "mobile.composer.attach.photo" : { "extractionState" : "manual", "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Photo Library" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "写真ライブラリ" } } + } }, + "mobile.composer.attach.file" : { "extractionState" : "manual", "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Choose Files" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "ファイルを選択" } } + } }, + "mobile.composer.attach.paste" : { "extractionState" : "manual", "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Paste Attachment" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "添付ファイルをペースト" } } + } }, + "mobile.composer.attach.error" : { "extractionState" : "manual", "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Couldn’t Add Attachment" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "添付ファイルを追加できませんでした" } } + } }, + "mobile.composer.attach.unreadable" : { "extractionState" : "manual", "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "That file couldn’t be read. Choose another file." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "そのファイルを読み込めませんでした。別のファイルを選択してください。" } } + } }, + "mobile.composer.attach.uploadFailed" : { "extractionState" : "manual", "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "The file couldn’t be uploaded to your Mac." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "ファイルを Mac にアップロードできませんでした。" } } + } }, "mobile.iroh.diagnostics.failure.unknown" : { "extractionState" : "manual", "localizations" : { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAttachmentPickerMenu.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAttachmentPickerMenu.swift index 0f47452286e..4376c025314 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAttachmentPickerMenu.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAttachmentPickerMenu.swift @@ -13,6 +13,7 @@ struct TaskComposerAttachmentPickerMenu: View { let isDisabled: Bool let choosePhotos: () -> Void let chooseFiles: () -> Void + let paste: () -> Void var body: some View { Menu { @@ -34,6 +35,15 @@ struct TaskComposerAttachmentPickerMenu: View { systemImage: "folder" ) } + Button(action: paste) { + Label( + L10n.string( + "mobile.taskComposer.attachments.paste", + defaultValue: "Paste Attachment" + ), + systemImage: "doc.on.clipboard" + ) + } } label: { switch style { case .circularPlus: diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAttachmentStager.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAttachmentStager.swift index bf5f31c1999..3cab304d888 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAttachmentStager.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAttachmentStager.swift @@ -41,6 +41,47 @@ struct TaskComposerAttachmentStager: Sendable { ) } + /// Stages in-memory clipboard image bytes without performing file I/O on + /// the caller's actor. + func stageImage( + data: Data, + originalFileName: String + ) async throws -> TaskComposerAttachment { + try await withThrowingTaskGroup(of: TaskComposerAttachment.self) { group in + group.addTask(priority: .utility) { + let sourceURL = temporaryURL(fileExtension: "png") + defer { try? FileManager.default.removeItem(at: sourceURL) } + do { + try data.write(to: sourceURL, options: .atomic) + } catch { + throw StagingError.unreadableFile + } + return try await stageImage( + at: sourceURL, + originalFileName: originalFileName + ) + } + guard let attachment = try await group.next() else { + throw CancellationError() + } + return attachment + } + } + + /// Reads staged bytes on a utility child task before a terminal attachment + /// is handed to the existing in-memory image transport. + func data(for attachment: TaskComposerAttachment) async throws -> Data { + try await withThrowingTaskGroup(of: Data.self) { group in + group.addTask(priority: .utility) { + try Data(contentsOf: attachment.localStagedFileURL) + } + guard let data = try await group.next() else { + throw CancellationError() + } + return data + } + } + func stageFile(at sourceURL: URL) async throws -> TaskComposerAttachment { try await withThrowingTaskGroup(of: TaskComposerAttachment.self) { group in group.addTask(priority: .utility) { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerLayout.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerLayout.swift index 7b911dc60cc..7134de1a367 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerLayout.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerLayout.swift @@ -38,6 +38,7 @@ struct TaskComposerLayout: View { let requestStartAgain: () -> Void let chooseAttachmentPhotos: () -> Void let chooseAttachmentFiles: () -> Void + let pasteAttachments: () -> Bool let removeAttachment: (UUID) -> Void @State private var isPromptFocused = false @@ -96,7 +97,8 @@ struct TaskComposerLayout: View { "mobile.taskComposer.prompt", defaultValue: "Prompt" ), - accessibilityHint: promptPlaceholder + accessibilityHint: promptPlaceholder, + pasteAttachment: pasteAttachments ) .padding(.horizontal, 20) .padding(.vertical, 18) @@ -192,7 +194,8 @@ struct TaskComposerLayout: View { style: .circularPlus, isDisabled: isDisabled, choosePhotos: chooseAttachmentPhotos, - chooseFiles: chooseAttachmentFiles + chooseFiles: chooseAttachmentFiles, + paste: { _ = pasteAttachments() } ) } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPromptEditor.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPromptEditor.swift index dfb96d7888a..c1ba5c801e2 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPromptEditor.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPromptEditor.swift @@ -10,6 +10,7 @@ struct TaskComposerPromptEditor: UIViewRepresentable { let isDisabled: Bool let accessibilityLabel: String let accessibilityHint: String + let pasteAttachment: () -> Bool func makeCoordinator() -> TaskComposerPromptEditorCoordinator { TaskComposerPromptEditorCoordinator(text: $text, isFocused: $isFocused) @@ -34,6 +35,7 @@ struct TaskComposerPromptEditor: UIViewRepresentable { guard let textView else { return } coordinator?.restoreManualContentOffset(in: textView) } + textView.pasteAttachment = pasteAttachment updateInteractionState(of: textView) return textView } @@ -42,6 +44,7 @@ struct TaskComposerPromptEditor: UIViewRepresentable { context.coordinator.update(text: $text, isFocused: $isFocused) textView.accessibilityLabel = accessibilityLabel textView.accessibilityHint = accessibilityHint + textView.pasteAttachment = pasteAttachment updateInteractionState(of: textView) // Assigning the same text again resets UITextView's selection/caret diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPromptTextView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPromptTextView.swift index faee75c8a5a..ee3b74d3300 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPromptTextView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPromptTextView.swift @@ -6,10 +6,28 @@ import UIKit @MainActor final class TaskComposerPromptTextView: UITextView { var restoreManualContentOffset: (() -> Void)? + /// Returns `true` when the composer consumed a non-text pasteboard payload + /// as an attachment. Plain text stays on UIKit's native paste path. + var pasteAttachment: (() -> Bool)? override func layoutSubviews() { super.layoutSubviews() restoreManualContentOffset?() } + + override func paste(_ sender: Any?) { + guard pasteAttachment?() != true else { return } + super.paste(sender) + } + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + if action == #selector(paste(_:)) { + let pasteboard = UIPasteboard.general + if pasteboard.hasImages || (pasteboard.urls ?? []).contains(where: \.isFileURL) { + return true + } + } + return super.canPerformAction(action, withSender: sender) + } } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+Attachments.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+Attachments.swift index b2d3a67cd9a..31ba4798b37 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+Attachments.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+Attachments.swift @@ -6,6 +6,8 @@ import CmuxMobileSupport import Foundation import PhotosUI import SwiftUI +import UIKit +import UniformTypeIdentifiers extension TaskComposerSheet { var showsAttachmentButton: Bool { @@ -160,6 +162,54 @@ extension TaskComposerSheet { } } + /// Stages image or file URLs from the system pasteboard through the same + /// bounded attachment paths as the photo and document pickers. Returning + /// `false` leaves plain text to the prompt editor's native paste behavior. + func stagePasteboardAttachments() -> Bool { + guard remainingAttachmentCount > 0 else { + attachmentAlertMessage = Self.attachmentCountFailureMessage + return true + } + let pasteboard = UIPasteboard.general + if let data = pasteboardImageData(pasteboard) { + stagePastedImageData(data) + return true + } + let fileURLs = (pasteboard.urls ?? []).filter(\.isFileURL) + guard !fileURLs.isEmpty else { return false } + stageSelectedFiles(.success(fileURLs)) + return true + } + + private func pasteboardImageData(_ pasteboard: UIPasteboard) -> Data? { + for type in [UTType.png.identifier, UTType.jpeg.identifier, UTType.heic.identifier] { + if let data = pasteboard.data(forPasteboardType: type) { + return data + } + } + return pasteboard.image?.pngData() + } + + private func stagePastedImageData(_ data: Data) { + attachmentStagingTask?.cancel() + attachmentStagingTask = Task { @MainActor in + defer { attachmentStagingTask = nil } + do { + let attachment = try await TaskComposerAttachmentStager() + .stageImage(data: data, originalFileName: "pasted-image.png") + guard !Task.isCancelled else { + try? FileManager.default.removeItem(at: attachment.localStagedFileURL) + return + } + appendAttachment(attachment) + } catch is CancellationError { + return + } catch { + attachmentAlertMessage = Self.attachmentStagingFailureMessage(error) + } + } + } + func appendAttachment(_ attachment: TaskComposerAttachment) { guard !submissionPhase.disablesRequestEditing else { try? FileManager.default.removeItem( diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet.swift index 0fa3cd0d9cf..037cd294d93 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet.swift @@ -439,6 +439,7 @@ struct TaskComposerSheet: View { requestStartAgain: { isStartAgainConfirmationPresented = true }, chooseAttachmentPhotos: presentAttachmentPhotoPicker, chooseAttachmentFiles: presentAttachmentFileImporter, + pasteAttachments: stagePasteboardAttachments, removeAttachment: removeAttachment ) } @@ -1012,9 +1013,7 @@ struct TaskComposerSheet: View { store.persistTaskComposerDraft(draftSnapshot(), ifSessionGeneration: sessionGeneration) } -} - -private func validWorkspaceGroupID( + private func validWorkspaceGroupID( _ candidate: MobileWorkspaceGroupPreview.ID?, groups: [MobileWorkspaceGroupPreview], macDeviceID: String, @@ -1031,7 +1030,7 @@ private func validWorkspaceGroupID( return candidate } -private func filteredWorkspaceGroups( + private func filteredWorkspaceGroups( _ groups: [MobileWorkspaceGroupPreview], macDeviceID: String, instanceTag: String? @@ -1048,11 +1047,12 @@ private func filteredWorkspaceGroups( } } -private func normalizedWorkspaceOwner(_ value: String?) -> String? { + private func normalizedWorkspaceOwner(_ value: String?) -> String? { guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil } return value } +} #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerAttachmentInsertion.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerAttachmentInsertion.swift new file mode 100644 index 00000000000..844dffa0ce0 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerAttachmentInsertion.swift @@ -0,0 +1,17 @@ +#if os(iOS) +import Foundation + +/// Produces one shell-safe terminal draft after a file upload returns its Mac path. +struct TerminalComposerAttachmentInsertion: Equatable, Sendable { + let path: String + + func appending(to draft: String) -> String { + var result = draft + if !result.isEmpty, result.last?.isWhitespace == false { + result += " " + } + result += "'" + path.replacingOccurrences(of: "'", with: "'\\''") + "' " + return result + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift index 07f88d266cb..d3138ed2a98 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift @@ -22,8 +22,9 @@ import UniformTypeIdentifiers /// presented does NOT mean focused: the field appears with the keyboard down and /// takes focus only on a user tap or an explicit focus request from the store /// (an explicit open/reveal, or a terminal switch mid-compose). The button to -/// the left of the field opens the photo picker for image attachments; the -/// composer is dismissed from the accessory toolbar's compose toggle. +/// the left of the field opens an attachment menu for photos, files, and +/// clipboard content; the composer is dismissed from the accessory toolbar's +/// compose toggle. /// /// The bottom dock (terminal grid / composer band / accessory toolbar / keyboard) /// is owned entirely by `GhosttySurfaceView` in one coordinate system. This view is @@ -59,6 +60,8 @@ struct TerminalComposerView: View { @State private var pickerSelection: [PhotosPickerItem] = [] /// Drives the photo picker's presentation from the attach button. @State private var isPickerPresented = false + @State private var isFileImporterPresented = false + @State private var attachmentErrorMessage: String? /// Small downsampled thumbnails keyed by attachment id, built ONCE when each /// attachment is staged. The chip row renders these instead of decoding the /// full multi-MB `Data` from inside the view body on every composer @@ -319,17 +322,39 @@ struct TerminalComposerView: View { } HStack(alignment: .bottom, spacing: 8) { - MobileComposerIconButton( - systemImage: "paperclip", - foregroundStyle: AnyShapeStyle( - store.activeTerminalTheme.terminalChromeForegroundColor.opacity(0.78) - ), - size: controlHeight, - accessibilityIdentifier: "MobileComposerAttach", - accessibilityLabel: L10n.string("mobile.composer.attach", defaultValue: "Attach Photo") - ) { - presentPhotoPicker() + Menu { + Button(action: presentPhotoPicker) { + Label( + L10n.string("mobile.composer.attach.photo", defaultValue: "Photo Library"), + systemImage: "photo.on.rectangle" + ) + } + Button { + isFileImporterPresented = true + } label: { + Label( + L10n.string("mobile.composer.attach.file", defaultValue: "Choose Files"), + systemImage: "folder" + ) + } + Button(action: pasteAttachment) { + Label( + L10n.string("mobile.composer.attach.paste", defaultValue: "Paste Attachment"), + systemImage: "doc.on.clipboard" + ) + } + } label: { + MobileComposerIconLabel( + systemImage: "paperclip", + foregroundStyle: AnyShapeStyle( + store.activeTerminalTheme.terminalChromeForegroundColor.opacity(0.78) + ), + size: controlHeight + ) } + .buttonStyle(.plain) + .accessibilityIdentifier("MobileComposerAttach") + .accessibilityLabel(L10n.string("mobile.composer.attach", defaultValue: "Add Attachment")) micButton @@ -401,6 +426,21 @@ struct TerminalComposerView: View { maxSelectionCount: Self.maxAttachmentCount, matching: .images ) + .fileImporter( + isPresented: $isFileImporterPresented, + allowedContentTypes: [.item], + allowsMultipleSelection: true + ) { result in + switch result { + case .success(let urls): + stageFiles(urls) + case .failure: + attachmentErrorMessage = L10n.string( + "mobile.composer.attach.unreadable", + defaultValue: "That file couldn’t be read. Choose another file." + ) + } + } .onChange(of: pickerSelection) { _, items in guard !items.isEmpty else { return } store.recordAppEvent( @@ -423,6 +463,108 @@ struct TerminalComposerView: View { photoPickerDidDismiss() } } + .alert( + L10n.string("mobile.composer.attach.error", defaultValue: "Couldn’t Add Attachment"), + isPresented: Binding( + get: { attachmentErrorMessage != nil }, + set: { if !$0 { attachmentErrorMessage = nil } } + ) + ) { + Button(L10n.string("mobile.common.ok", defaultValue: "OK")) { + attachmentErrorMessage = nil + } + } message: { + Text(attachmentErrorMessage ?? "") + } + } + + private func pasteAttachment() { + let pasteboard = UIPasteboard.general + if let data = pasteboardImageData(pasteboard) { + stagePastedImage(data) + return + } + let fileURLs = (pasteboard.urls ?? []).filter(\.isFileURL) + if !fileURLs.isEmpty { + stageFiles(fileURLs) + return + } + if let string = pasteboard.string, !string.isEmpty { + store.terminalInputText += string + isFieldFocused = true + } + } + + private func pasteboardImageData(_ pasteboard: UIPasteboard) -> Data? { + for type in [UTType.png.identifier, UTType.jpeg.identifier, UTType.heic.identifier] { + if let data = pasteboard.data(forPasteboardType: type) { + return data + } + } + return pasteboard.image?.pngData() + } + + private func stagePastedImage(_ data: Data) { + let sessionGeneration = store.currentSessionGeneration + stagingTask.task?.cancel() + stagingTask.task = Task { @MainActor in + defer { requestHeightRemeasure() } + do { + let attachment = try await TaskComposerAttachmentStager().stageImage( + data: data, + originalFileName: "pasted-image.png" + ) + defer { try? FileManager.default.removeItem(at: attachment.localStagedFileURL) } + let stagedData = try await TaskComposerAttachmentStager().data(for: attachment) + guard + let id = store.addPendingAttachment( + stagedData, + format: attachment.localStagedFileURL.pathExtension, + forTerminalID: terminalID, + ifSessionGeneration: sessionGeneration + ) else { return } + if let thumbnailData = attachment.thumbnailData, + let thumbnail = UIImage(data: thumbnailData) { + thumbnailCache.set(thumbnail, for: id) + } + } catch { + attachmentErrorMessage = L10n.string( + "mobile.composer.attach.unreadable", + defaultValue: "That file couldn’t be read. Choose another file." + ) + } + } + } + + private func stageFiles(_ urls: [URL]) { + stagingTask.task?.cancel() + stagingTask.task = Task { @MainActor in + for url in urls.prefix(Self.maxAttachmentCount) { + guard !Task.isCancelled else { return } + do { + let attachment = try await TaskComposerAttachmentStager().stageFile(at: url) + defer { try? FileManager.default.removeItem(at: attachment.localStagedFileURL) } + let result = await store.uploadTerminalComposerAttachment(attachment) + guard case .success(let path) = result else { + attachmentErrorMessage = L10n.string( + "mobile.composer.attach.uploadFailed", + defaultValue: "The file couldn’t be uploaded to your Mac." + ) + return + } + store.terminalInputText = TerminalComposerAttachmentInsertion(path: path) + .appending(to: store.terminalInputText) + } catch { + attachmentErrorMessage = L10n.string( + "mobile.composer.attach.unreadable", + defaultValue: "That file couldn’t be read. Choose another file." + ) + return + } + } + isFieldFocused = true + requestHeightRemeasure() + } } @ViewBuilder diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalComposerAttachmentInsertionTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalComposerAttachmentInsertionTests.swift new file mode 100644 index 00000000000..c066237d466 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalComposerAttachmentInsertionTests.swift @@ -0,0 +1,27 @@ +#if os(iOS) +import Testing +@testable import CmuxMobileShellUI + +@Suite struct TerminalComposerAttachmentInsertionTests { + @Test func quotesMacPathsAndSeparatesThemFromExistingDraftText() { + let insertion = TerminalComposerAttachmentInsertion( + path: "/tmp/Customer's report.pdf" + ) + + #expect( + insertion.appending(to: "cat") + == "cat '/tmp/Customer'\\''s report.pdf' " + ) + #expect( + insertion.appending(to: "cat ") + == "cat '/tmp/Customer'\\''s report.pdf' " + ) + } + + @Test func producesAStandaloneQuotedArgumentForAnEmptyDraft() { + let insertion = TerminalComposerAttachmentInsertion(path: "/tmp/image.png") + + #expect(insertion.appending(to: "") == "'/tmp/image.png' ") + } +} +#endif diff --git a/ios/cmux/AppCompositionRoot.swift b/ios/cmux/AppCompositionRoot.swift index c516677cba8..11e04794085 100644 --- a/ios/cmux/AppCompositionRoot.swift +++ b/ios/cmux/AppCompositionRoot.swift @@ -303,7 +303,7 @@ final class AppCompositionRoot { /// Bundle-owned build identity used in explicit diagnostic exports. /// Values come only from signed app metadata, never user input. static var diagnosticBuildStamp: String { - DiagnosticBuildStamp.make(infoDictionary: Bundle.main.infoDictionary) + DiagnosticBuildStamp().make(infoDictionary: Bundle.main.infoDictionary) } private static var crashReportingEnabled: Bool { diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index 8422422ce1a..c7f76f82034 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -4934,6 +4934,37 @@ final class cmuxUITests: XCTestCase { ) } + /// A clipboard image follows the same bounded staging path as a photo + /// picker selection and appears as an editable task attachment. + @MainActor + func testTaskComposerPastesClipboardImageAsAttachment() throws { + let image = UIGraphicsImageRenderer(size: CGSize(width: 40, height: 30)).image { context in + UIColor.systemBlue.setFill() + context.fill(CGRect(x: 0, y: 0, width: 40, height: 30)) + } + UIPasteboard.general.image = image + defer { UIPasteboard.general.items = [] } + + let app = launchApp(mockData: false, environment: [ + "CMUX_UITEST_TASK_COMPOSER_PREVIEW": "1", + "CMUX_UITEST_TASK_COMPOSER_ATTACHMENTS": "1", + ]) + defer { app.terminate() } + + XCTAssertTrue(taskComposerPrompt(in: app).waitForExistence(timeout: 8)) + tap(app.buttons["MobileTaskComposerAttachmentButton"], in: app) + tapMenuItem(app.buttons["Paste Attachment"], in: app) + + let pastedPreview = app.buttons.matching( + NSPredicate( + format: "identifier BEGINSWITH %@", + "MobileTaskComposerAttachmentPreview-" + ) + ).firstMatch + XCTAssertTrue(pastedPreview.waitForExistence(timeout: 8)) + XCTAssertEqual(pastedPreview.label, "pasted-image.png") + } + /// Staged image and file chips must retain their app-owned bytes as native /// Quick Look previews while the composer draft remains editable. @MainActor