diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift index 2ff4b3f97b2c..26a0b45eb59c 100644 --- a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift @@ -539,7 +539,8 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable { } switch method { - case "mobile.workspace.list", "workspace.list": + case "mobile.workspace.list", "workspace.list", + "mobile.directory.list", "mobile.directory.search": return false case "workspace.create": return false diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession+IndependentEvents.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession+IndependentEvents.swift index c5048f70e3a0..c2b9172ceca2 100644 --- a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession+IndependentEvents.swift +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession+IndependentEvents.swift @@ -134,9 +134,9 @@ extension MobileCoreRPCSession { if (envelope["ok"] as? Bool) == true { let result = envelope["result"] ?? [:] if let data = try? JSONSerialization.data(withJSONObject: result) { - cont.resume(returning: .success(data)) + cont.resume(returning: .response(.success(data))) } else { - cont.resume(returning: .failure(.invalidResponse)) + cont.resume(returning: .response(.failure(.invalidResponse))) } return } @@ -145,11 +145,11 @@ extension MobileCoreRPCSession { let code = errorPayload?["code"] as? String switch code { case "unauthorized": - cont.resume(returning: .failure(.authorizationFailed(message))) + cont.resume(returning: .response(.failure(.authorizationFailed(message)))) case "account_mismatch": - cont.resume(returning: .failure(.accountMismatch(message))) + cont.resume(returning: .response(.failure(.accountMismatch(message)))) default: - cont.resume(returning: .failure(.rpcError(code, message))) + cont.resume(returning: .response(.failure(.rpcError(code, message)))) } } } diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession+RequestSettlement.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession+RequestSettlement.swift new file mode 100644 index 000000000000..e5b125dbbdb7 --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession+RequestSettlement.swift @@ -0,0 +1,40 @@ +import Foundation + +extension MobileCoreRPCSession { + static func resolvePendingSettlement( + _ settlement: PendingRequestSettlement, + isCancelled: Bool + ) throws -> Data { + switch settlement { + case .cancelled: + throw CancellationError() + case .response(.success(let data)): + // A decoded success followed by cancellation is still ambiguous: + // the host may have created a non-idempotent workspace even though + // the caller no longer owns the current session. + if isCancelled { throw CancellationError() } + return data + case .response(.failure(let error)): + // Preserve only failures decoded from a host response. Local + // transport/protocol failures cannot prove whether a legacy, + // non-idempotent workspace.create reached the host, so cancellation + // must keep that outcome ambiguous upstream. + if isCancelled, !error.isDefiniteHostResponseFailure { + throw CancellationError() + } + throw error + } + } +} + +private extension MobileShellConnectionError { + var isDefiniteHostResponseFailure: Bool { + switch self { + case .authorizationFailed, .accountMismatch, .rpcError: + true + case .invalidResponse, .connectionClosed, .requestTimedOut, .transportWriteTimedOut, + .insecureManualRoute, .attachTicketExpired: + false + } + } +} diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession.swift index 23f633ddccba..ac3e0ceaeef0 100644 --- a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession.swift +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession.swift @@ -6,7 +6,11 @@ actor MobileCoreRPCSession { typealias IndependentEventByteStreamFactory = @Sendable () async throws -> CmxIndependentEventByteStream typealias ConnectedCandidateHook = @Sendable (_ candidate: any CmxByteTransport) async -> Void typealias TransportConnectObserver = @Sendable (MobileRPCTransportConnectEvent) -> Void - typealias PendingContinuation = CheckedContinuation, Never> + enum PendingRequestSettlement { + case response(Result) + case cancelled + } + typealias PendingContinuation = CheckedContinuation typealias ConnectingTask = (id: UUID, lease: MobileRPCConnectAttemptLease?, task: Task, waiters: Set, completed: Bool) static let defaultAbandonedConnectCleanupTimeoutNanoseconds: UInt64 = 1_000_000_000 static let defaultLateAbandonedConnectCloseTimeoutNanoseconds: UInt64 = 5_000_000_000 @@ -123,10 +127,10 @@ actor MobileCoreRPCSession { let frame = try MobileSyncFrameCodec.encodeFrame(payload) let responseTimeoutNanoseconds = try taskTimeout.remainingNanoseconds(until: deadlineUptimeNanoseconds) - let result: Result = await withTaskCancellationHandler { + let settlement: PendingRequestSettlement = await withTaskCancellationHandler { await withCheckedContinuation { continuation in guard pending[requestID] == nil, queuedWriteIDs[requestID] == nil else { - continuation.resume(returning: .failure(.invalidResponse)) + continuation.resume(returning: .response(.failure(.invalidResponse))) return } let queuedWriteID = UUID() @@ -144,7 +148,7 @@ actor MobileCoreRPCSession { guard let queue = writeQueue else { requestTimeoutTasks.removeValue(forKey: requestID)?.cancel() pending.removeValue(forKey: requestID) - continuation.resume(returning: .failure(.connectionClosed)) + continuation.resume(returning: .response(.failure(.connectionClosed))) return } queuedWriteIDs[requestID] = queuedWriteID @@ -155,15 +159,7 @@ actor MobileCoreRPCSession { await self.cancelPendingRequest(requestID: requestID) } } - if Task.isCancelled { - throw CancellationError() - } - switch result { - case .success(let data): - return data - case .failure(let error): - throw error - } + return try Self.resolvePendingSettlement(settlement, isCancelled: Task.isCancelled) } func addEventListener(topics: Set) -> EventSubscription { @@ -197,7 +193,7 @@ actor MobileCoreRPCSession { task.cancel() } for (_, cont) in pendingSnapshot { - cont.resume(returning: .failure(error)) + cont.resume(returning: .response(.failure(error))) } let listenerSnapshot = listeners listeners.removeAll() @@ -631,7 +627,7 @@ actor MobileCoreRPCSession { private func failPending(requestID: String, error: MobileShellConnectionError) { guard let cont = pending.removeValue(forKey: requestID) else { return } requestTimeoutTasks.removeValue(forKey: requestID)?.cancel() - cont.resume(returning: .failure(error)) + cont.resume(returning: .response(.failure(error))) } private func cancelPendingRequest(requestID: String) async { guard let cont = pending.removeValue(forKey: requestID) else { return } @@ -640,7 +636,7 @@ actor MobileCoreRPCSession { cancelledQueuedWriteIDs.insert(queuedWriteID) } _ = await recycleTransportIfActiveWrite(requestID: requestID) - cont.resume(returning: .failure(.requestTimedOut)) + cont.resume(returning: .cancelled) } private func timeoutPendingRequest(requestID: String) async { @@ -656,7 +652,7 @@ actor MobileCoreRPCSession { } else { .requestTimedOut } - cont.resume(returning: .failure(error)) + cont.resume(returning: .response(.failure(error))) } private func shouldSendQueuedWrite(_ write: PendingWrite) -> Bool { diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectoryListEntries.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectoryListEntries.swift new file mode 100644 index 000000000000..e42ecf6318d9 --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectoryListEntries.swift @@ -0,0 +1,27 @@ +import Foundation + +/// Decodes a directory page without allocating an unbounded entry array first. +struct MobileTaskDirectoryListEntries: Decodable { + let values: [MobileTaskDirectoryListEntry] + + init(from decoder: any Decoder) throws { + var container = try decoder.unkeyedContainer() + var values: [MobileTaskDirectoryListEntry] = [] + values.reserveCapacity( + min( + container.count ?? MobileTaskDirectoryListRequest.maximumPageSize, + MobileTaskDirectoryListRequest.maximumPageSize + ) + ) + while !container.isAtEnd { + guard values.count < MobileTaskDirectoryListRequest.maximumPageSize else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Directory-list page exceeds the maximum entry count." + ) + } + values.append(try container.decode(MobileTaskDirectoryListEntry.self)) + } + self.values = values + } +} diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectoryListEntry.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectoryListEntry.swift new file mode 100644 index 000000000000..0fa5ac874da0 --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectoryListEntry.swift @@ -0,0 +1,111 @@ +import Foundation + +/// One directly browsable child directory returned by `mobile.directory.list`. +public struct MobileTaskDirectoryListEntry: Decodable, Equatable, Sendable { + /// The final path component shown to the user. + public let name: String + + /// The absolute Mac path to pass in the next directory-list request. + public let path: String + + /// Whether the child is hidden according to the Mac filesystem. + public let isHidden: Bool + + /// Whether the child is a directory package such as an app bundle. + public let isPackage: Bool + + /// Whether the child path is a symbolic link whose destination is a directory. + public let isSymbolicLink: Bool + + /// Whether the Mac reports the child path as readable before navigation. + public let isReadable: Bool + + /// Creates a validated directly browsable directory entry. + /// + /// - Parameters: + /// - name: A nonempty path component no larger than 1,024 UTF-8 bytes. + /// - path: An absolute Mac path no larger than 4,096 UTF-8 bytes. + /// - isHidden: Whether the filesystem marks the directory hidden. + /// - isPackage: Whether the directory is a package. + /// - isSymbolicLink: Whether the path is a symbolic link to a directory. + /// - isReadable: Whether the Mac reports the directory as readable. + /// - Returns: An entry, or `nil` when its name or path violates the wire contract. + public init?( + name: String, + path: String, + isHidden: Bool, + isPackage: Bool, + isSymbolicLink: Bool, + isReadable: Bool + ) { + guard Self.isValidName(name), Self.isValidAbsolutePath(path) else { + return nil + } + self.name = name + self.path = path + self.isHidden = isHidden + self.isPackage = isPackage + self.isSymbolicLink = isSymbolicLink + self.isReadable = isReadable + } + + private enum CodingKeys: String, CodingKey { + case name + case path + case isHidden = "is_hidden" + case isPackage = "is_package" + case isSymbolicLink = "is_symbolic_link" + case isReadable = "is_readable" + } + + /// Decodes and validates one directory entry from a response page. + /// + /// - Parameter decoder: The decoder containing the wire entry. + /// - Throws: ``DecodingError/dataCorrupted(_:)`` when a name or path exceeds its wire limit. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let name = try container.decode(String.self, forKey: .name) + let path = try container.decode(String.self, forKey: .path) + let isHidden = try container.decode(Bool.self, forKey: .isHidden) + let isPackage = try container.decode(Bool.self, forKey: .isPackage) + let isSymbolicLink = try container.decode(Bool.self, forKey: .isSymbolicLink) + let isReadable = try container.decode(Bool.self, forKey: .isReadable) + guard let entry = Self( + name: name, + path: path, + isHidden: isHidden, + isPackage: isPackage, + isSymbolicLink: isSymbolicLink, + isReadable: isReadable + ) else { + throw DecodingError.dataCorrupted( + .init( + codingPath: container.codingPath, + debugDescription: "Directory-list entry fields are outside the wire contract." + ) + ) + } + self = entry + } + + static func precedes(_ lhs: Self, _ rhs: Self) -> Bool { + if lhs.name.utf8.lexicographicallyPrecedes(rhs.name.utf8) { + return true + } + if rhs.name.utf8.lexicographicallyPrecedes(lhs.name.utf8) { + return false + } + return lhs.path.utf8.lexicographicallyPrecedes(rhs.path.utf8) + } + + private static func isValidName(_ name: String) -> Bool { + !name.isEmpty && name != "." && name != ".." && + name.utf8.count <= 1_024 && !name.contains("/") && + !name.unicodeScalars.contains(where: { $0.value == 0 }) + } + + static func isValidAbsolutePath(_ path: String) -> Bool { + path.hasPrefix("/") && path.utf8.count <= 4_096 && + !path.unicodeScalars.contains(where: { $0.value == 0 }) + } +} diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectoryListRequest.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectoryListRequest.swift new file mode 100644 index 000000000000..6607cb6f3358 --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectoryListRequest.swift @@ -0,0 +1,87 @@ +public import Foundation + +/// A validated page request for the `mobile.directory.list` RPC method. +/// +/// Paths are either absolute Mac paths or `~`-relative paths. Pagination uses +/// an entry offset so callers can continue until the response's +/// ``MobileTaskDirectoryListResponse/nextOffset`` becomes `nil`. +public struct MobileTaskDirectoryListRequest: Codable, Equatable, Sendable { + /// The page size used when a caller does not choose one explicitly. + public static let defaultPageSize = 50 + + /// The largest page the client and host accept on the wire. + public static let maximumPageSize = 100 + + /// The absolute or `~`-relative directory path to list. + public let path: String + + /// The zero-based entry offset in the directory's stable sort order. + public let offset: Int + + /// The maximum number of entries to return in this page. + public let limit: Int + + /// Creates a validated directory-list request. + /// + /// - Parameters: + /// - path: An absolute path, `~`, or a path beginning with `~/`. + /// - offset: A nonnegative entry offset in the stable directory order. + /// - limit: A page size from `1` through ``maximumPageSize``. + /// - Returns: A request, or `nil` when any argument is outside the wire contract. + public init?( + path: String, + offset: Int = 0, + limit: Int = Self.defaultPageSize + ) { + guard Self.isValidPath(path), offset >= 0, + (1...Self.maximumPageSize).contains(limit) else { + return nil + } + self.path = path + self.offset = offset + self.limit = limit + } + + /// Decodes and validates a raw JSON request payload. + /// + /// - Parameter data: JSON data containing `path`, `offset`, and `limit`. + /// - Returns: A request whose fields satisfy the wire limits. + /// - Throws: ``DecodingError/dataCorrupted(_:)`` when a field violates the contract. + public static func decode(_ data: Data) throws -> Self { + try JSONDecoder().decode(Self.self, from: data) + } + + private enum CodingKeys: String, CodingKey { + case path + case offset + case limit + } + + /// Decodes and validates a request from a decoder. + /// + /// - Parameter decoder: The decoder containing the wire request. + /// - Throws: ``DecodingError/dataCorrupted(_:)`` when a field violates the contract. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let path = try container.decode(String.self, forKey: .path) + let offset = try container.decode(Int.self, forKey: .offset) + let limit = try container.decode(Int.self, forKey: .limit) + guard let request = Self(path: path, offset: offset, limit: limit) else { + throw DecodingError.dataCorrupted( + .init( + codingPath: container.codingPath, + debugDescription: "Directory-list request fields are outside the wire contract." + ) + ) + } + self = request + } + + private static func isValidPath(_ path: String) -> Bool { + guard !path.isEmpty, path.utf8.count <= 4_096, + !path.unicodeScalars.contains(where: { $0.value == 0 }) else { + return false + } + return path.hasPrefix("/") || path == "~" || path.hasPrefix("~/") + } +} diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectoryListResponse.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectoryListResponse.swift new file mode 100644 index 000000000000..ed526913ef58 --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectoryListResponse.swift @@ -0,0 +1,162 @@ +public import Foundation + +/// A complete, defensively validated page from `mobile.directory.list`. +/// +/// The host reports an exact total and the next offset rather than truncating +/// silently. Callers can therefore browse directories of any size one page at +/// a time while preserving the host's stable bytewise name order. +public struct MobileTaskDirectoryListResponse: Decodable, Equatable, Sendable { + /// The normalized absolute path whose children were listed. + public let currentPath: String + + /// The normalized absolute parent path, or `nil` when ``currentPath`` is `/`. + public let parentPath: String? + + /// Direct child directories in stable bytewise name order. + public let entries: [MobileTaskDirectoryListEntry] + + /// The zero-based offset of ``entries`` in the full sorted directory. + public let offset: Int + + /// The page-size limit applied by the host. + public let limit: Int + + /// The exact number of directly browsable child directories. + public let totalCount: Int + + /// The offset for the next page, or `nil` after the final page. + public let nextOffset: Int? + + /// Creates a directory page after validating its sort and pagination invariants. + /// + /// - Parameters: + /// - currentPath: The normalized absolute directory path. + /// - parentPath: Its absolute parent, or `nil` only for `/`. + /// - entries: A complete page in stable bytewise name order. + /// - offset: The page's zero-based offset in the full listing. + /// - limit: The page-size limit applied by the host. + /// - totalCount: The exact number of browsable direct children. + /// - nextOffset: The next page offset, or `nil` after the final page. + /// - Returns: A response, or `nil` when the supplied fields are inconsistent. + public init?( + currentPath: String, + parentPath: String?, + entries: [MobileTaskDirectoryListEntry], + offset: Int, + limit: Int, + totalCount: Int, + nextOffset: Int? + ) { + guard Self.isValid( + currentPath: currentPath, + parentPath: parentPath, + entries: entries, + offset: offset, + limit: limit, + totalCount: totalCount, + nextOffset: nextOffset + ) else { + return nil + } + self.currentPath = currentPath + self.parentPath = parentPath + self.entries = entries + self.offset = offset + self.limit = limit + self.totalCount = totalCount + self.nextOffset = nextOffset + } + + private enum CodingKeys: String, CodingKey { + case currentPath = "current_path" + case parentPath = "parent_path" + case entries + case offset + case limit + case totalCount = "total_count" + case nextOffset = "next_offset" + } + + /// Decodes and validates a paginated directory response. + /// + /// - Parameter decoder: The decoder containing the wire response. + /// - Throws: A decoding error when pagination or entry invariants are inconsistent. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let currentPath = try container.decode(String.self, forKey: .currentPath) + let parentPath = try container.decodeIfPresent(String.self, forKey: .parentPath) + let entries = try container.decode( + MobileTaskDirectoryListEntries.self, + forKey: .entries + ).values + let offset = try container.decode(Int.self, forKey: .offset) + let limit = try container.decode(Int.self, forKey: .limit) + let totalCount = try container.decode(Int.self, forKey: .totalCount) + let nextOffset = try container.decodeIfPresent(Int.self, forKey: .nextOffset) + + guard let response = Self( + currentPath: currentPath, + parentPath: parentPath, + entries: entries, + offset: offset, + limit: limit, + totalCount: totalCount, + nextOffset: nextOffset + ) else { + throw DecodingError.dataCorrupted( + .init( + codingPath: container.codingPath, + debugDescription: "Directory-list response fields are inconsistent or outside the wire contract." + ) + ) + } + + self = response + } + + /// Decodes and validates a raw directory-list result payload. + /// + /// - Parameter data: The JSON result returned by the Mac. + /// - Returns: A page with internally consistent pagination metadata. + /// - Throws: A decoding error for malformed, oversized, unsorted, duplicated, or truncated pages. + public static func decode(_ data: Data) throws -> Self { + try JSONDecoder().decode(Self.self, from: data) + } + + private static func isValid( + currentPath: String, + parentPath: String?, + entries: [MobileTaskDirectoryListEntry], + offset: Int, + limit: Int, + totalCount: Int, + nextOffset: Int? + ) -> Bool { + guard MobileTaskDirectoryListEntry.isValidAbsolutePath(currentPath), + parentPath.map(MobileTaskDirectoryListEntry.isValidAbsolutePath) ?? true, + (currentPath == "/") == (parentPath == nil), + offset >= 0, + (1...MobileTaskDirectoryListRequest.maximumPageSize).contains(limit), + totalCount >= 0, + offset <= totalCount, + entries.count == min(limit, totalCount - offset), + entries.count <= MobileTaskDirectoryListRequest.maximumPageSize else { + return false + } + + let expectedNextOffset = offset + entries.count < totalCount + ? offset + entries.count + : nil + guard nextOffset == expectedNextOffset else { return false } + + var seenPaths = Set() + guard entries.allSatisfy({ seenPaths.insert(Data($0.path.utf8)).inserted }) else { + return false + } + let sortedEntries = entries.sorted(by: MobileTaskDirectoryListEntry.precedes) + return zip(entries, sortedEntries).allSatisfy { actual, sorted in + Data(actual.name.utf8) == Data(sorted.name.utf8) && + Data(actual.path.utf8) == Data(sorted.path.utf8) + } + } +} diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectorySearchResponse.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectorySearchResponse.swift new file mode 100644 index 000000000000..e906d0995770 --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectorySearchResponse.swift @@ -0,0 +1,108 @@ +public import Foundation + +/// Typed result for `mobile.directory.search`. +public struct MobileTaskDirectorySearchResponse: Decodable, Equatable, Sendable { + /// The source whose coverage produced the returned matches. + public enum SearchScope: String, Decodable, Equatable, Sendable { + /// Spotlight metadata from indexed local and mounted network volumes. + case allIndexedVolumes = "all_indexed_volumes" + /// Only open, recent, or otherwise contextual cmux paths were available. + case contextualCandidatesOnly = "contextual_candidates_only" + /// A host predating coverage metadata returned its bounded legacy index. + case legacyBounded = "legacy_bounded" + } + + /// Ranked directory paths, capped to the wire maximum. + public let directories: [String] + + /// The indexed source searched by the Mac. + public let searchScope: SearchScope + + /// Whether Spotlight finished gathering before the host deadline. + public let gatheringComplete: Bool + + /// Whether every reachable filesystem directory was searched. + public let filesystemComplete: Bool + + /// Whether additional ranked or indexed matches exceeded the response cap. + public let truncated: Bool + + /// The number of raw matches reported by Spotlight before wire ranking. + public let indexedMatchCount: Int + + /// Creates a bounded directory-search response. + /// + /// - Parameters: + /// - directories: Ranked absolute paths from the Mac. + /// - searchScope: The source whose coverage produced the matches. + /// - gatheringComplete: Whether indexed gathering reached completion. + /// - filesystemComplete: Whether the result covers the full filesystem. + /// - truncated: Whether additional matches were omitted. + /// - indexedMatchCount: Raw Spotlight match count before ranking. + public init( + directories: [String], + searchScope: SearchScope = .legacyBounded, + gatheringComplete: Bool = false, + filesystemComplete: Bool = false, + truncated: Bool = false, + indexedMatchCount: Int = 0 + ) { + let validDirectories = directories.filter(Self.isValidPath) + self.directories = Array(validDirectories.prefix(64)) + self.searchScope = searchScope + self.gatheringComplete = gatheringComplete + self.filesystemComplete = filesystemComplete + self.truncated = truncated || validDirectories.count > 64 + self.indexedMatchCount = max(0, indexedMatchCount) + } + + /// Decodes the Mac response and re-applies the wire cap defensively. + public static func decode(_ data: Data) throws -> Self { + try JSONDecoder().decode(Self.self, from: data) + } + + private enum CodingKeys: String, CodingKey { + case directories + case searchScope = "search_scope" + case gatheringComplete = "gathering_complete" + case filesystemComplete = "filesystem_complete" + case truncated + case indexedMatchCount = "indexed_match_count" + } + + /// Decodes coverage metadata while accepting legacy directory-only hosts. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let directories = try container.decode([String].self, forKey: .directories) + let searchScope = try container.decodeIfPresent(SearchScope.self, forKey: .searchScope) + ?? .legacyBounded + let gatheringComplete = try container.decodeIfPresent(Bool.self, forKey: .gatheringComplete) + ?? false + let filesystemComplete = try container.decodeIfPresent(Bool.self, forKey: .filesystemComplete) + ?? false + let truncated = try container.decodeIfPresent(Bool.self, forKey: .truncated) ?? false + let indexedMatchCount = try container.decodeIfPresent(Int.self, forKey: .indexedMatchCount) ?? 0 + guard indexedMatchCount >= 0, !filesystemComplete else { + throw DecodingError.dataCorruptedError( + forKey: filesystemComplete ? .filesystemComplete : .indexedMatchCount, + in: container, + debugDescription: filesystemComplete + ? "Indexed directory search cannot claim full filesystem coverage." + : "Indexed match count must be nonnegative." + ) + } + self.init( + directories: directories, + searchScope: searchScope, + gatheringComplete: gatheringComplete, + filesystemComplete: filesystemComplete, + truncated: truncated, + indexedMatchCount: indexedMatchCount + ) + } + + private static func isValidPath(_ path: String) -> Bool { + !path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && path.utf8.count <= 4_096 + } +} diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspacePreview+RemoteMapping.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspacePreview+RemoteMapping.swift index 9576d4c2270a..625b7515972f 100644 --- a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspacePreview+RemoteMapping.swift +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspacePreview+RemoteMapping.swift @@ -9,6 +9,7 @@ extension MobileWorkspacePreview { id: ID(rawValue: remote.id), windowID: remote.windowID, name: remote.title, + currentDirectory: remote.currentDirectory, isPinned: remote.isPinned ?? false, groupID: remote.groupID.map { MobileWorkspaceGroupPreview.ID(rawValue: $0) }, previewText: remote.preview, @@ -43,6 +44,7 @@ extension MobileTerminalPreview { self.init( id: ID(rawValue: remote.id), name: remote.title, + currentDirectory: remote.currentDirectory, isReady: remote.isReady ?? true, isFocused: remote.isFocused ) diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCClientTests.swift b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCClientTests.swift index b61fbdc6b0d6..e9853a600879 100644 --- a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCClientTests.swift +++ b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCClientTests.swift @@ -138,6 +138,8 @@ import Testing #expect(workspace.terminals.first?.isReady == true) let mapped = MobileWorkspacePreview(remote: workspace) #expect(mapped.windowID == "window-1") + #expect(mapped.currentDirectory == "/Users/test/project") + #expect(mapped.terminals.first?.currentDirectory == "/Users/test/project") } /// The Mac emits an optional per-workspace `preview` + `preview_at` (latest diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCRequestQueueTests.swift b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCRequestQueueTests.swift index fc8e31597d72..c9f6ba7d2e24 100644 --- a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCRequestQueueTests.swift +++ b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCRequestQueueTests.swift @@ -196,4 +196,56 @@ import Testing } } + @Test func settledSessionFailureOutranksAmbientCancellation() async { + let settledFailure = Task { + withUnsafeCurrentTask { $0?.cancel() } + #expect(Task.isCancelled) + return try MobileCoreRPCSession.resolvePendingSettlement( + .response(.failure(.rpcError("rejected", "workspace.create rejected"))), + isCancelled: Task.isCancelled + ) + } + do { + _ = try await settledFailure.value + Issue.record("Expected the already-settled host rejection") + } catch MobileShellConnectionError.rpcError(let code, let message) { + #expect(code == "rejected") + #expect(message == "workspace.create rejected") + } catch { + Issue.record("Expected the RPC rejection to outrank ambient cancellation, got \(error)") + } + } + + @Test func cancelledConnectionCloseRemainsAmbiguous() async { + await expectCancellationOutranksTransportFailure(.connectionClosed) + } + + @Test func cancelledRequestTimeoutRemainsAmbiguous() async { + await expectCancellationOutranksTransportFailure(.requestTimedOut) + } + + @Test func cancelledInvalidResponseRemainsAmbiguous() async { + await expectCancellationOutranksTransportFailure(.invalidResponse) + } + + private func expectCancellationOutranksTransportFailure( + _ error: MobileShellConnectionError + ) async { + let settlement = Task { + withUnsafeCurrentTask { $0?.cancel() } + #expect(Task.isCancelled) + return try MobileCoreRPCSession.resolvePendingSettlement( + .response(.failure(error)), + isCancelled: Task.isCancelled + ) + } + do { + _ = try await settlement.value + Issue.record("Expected cancellation to preserve the ambiguous create outcome") + } catch is CancellationError { + } catch { + Issue.record("Expected CancellationError, got \(error)") + } + } + } diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileTaskDirectoryListDTOTests.swift b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileTaskDirectoryListDTOTests.swift new file mode 100644 index 000000000000..a5ae466bcd63 --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileTaskDirectoryListDTOTests.swift @@ -0,0 +1,139 @@ +import Foundation +import Testing +@testable import CmuxMobileRPC + +@Suite struct MobileTaskDirectoryListDTOTests { + @Test func requestRejectsInvalidPathsAndPagination() throws { + #expect(MobileTaskDirectoryListRequest(path: "relative") == nil) + #expect(MobileTaskDirectoryListRequest(path: "/tmp", offset: -1) == nil) + #expect(MobileTaskDirectoryListRequest(path: "/tmp", limit: 0) == nil) + #expect(MobileTaskDirectoryListRequest(path: "/tmp", limit: 101) == nil) + + let request = try #require(MobileTaskDirectoryListRequest(path: "~/Dev")) + #expect(request.offset == 0) + #expect(request.limit == MobileTaskDirectoryListRequest.defaultPageSize) + + let invalidData = Data(#"{"path":"relative","offset":0,"limit":50}"#.utf8) + #expect(throws: DecodingError.self) { + try MobileTaskDirectoryListRequest.decode(invalidData) + } + } + + @Test func responseDecodesExactSortedPagination() throws { + let data = try Self.responseData( + entries: [ + Self.entry(name: ".hidden", path: "/Users/test/.hidden", isHidden: true), + Self.entry(name: "Bundle.app", path: "/Users/test/Bundle.app", isPackage: true), + ], + offset: 0, + limit: 2, + totalCount: 3, + nextOffset: 2 + ) + + let response = try MobileTaskDirectoryListResponse.decode(data) + + #expect(response.currentPath == "/Users/test") + #expect(response.parentPath == "/Users") + #expect(response.entries.map(\.name) == [".hidden", "Bundle.app"]) + #expect(response.entries[0].isHidden) + #expect(response.entries[1].isPackage) + #expect(response.nextOffset == 2) + } + + @Test func responseRejectsSilentTruncationAndOversizedPages() throws { + let truncated = try Self.responseData( + entries: [Self.entry(name: "a", path: "/tmp/a")], + offset: 0, + limit: 2, + totalCount: 3, + nextOffset: 1 + ) + #expect(throws: DecodingError.self) { + try MobileTaskDirectoryListResponse.decode(truncated) + } + + let oversizedEntries = (0...MobileTaskDirectoryListRequest.maximumPageSize).map { index in + Self.entry( + name: String(format: "%03d", index), + path: "/tmp/\(String(format: "%03d", index))" + ) + } + let oversized = try Self.responseData( + entries: oversizedEntries, + offset: 0, + limit: MobileTaskDirectoryListRequest.maximumPageSize, + totalCount: oversizedEntries.count, + nextOffset: MobileTaskDirectoryListRequest.maximumPageSize + ) + #expect(throws: DecodingError.self) { + try MobileTaskDirectoryListResponse.decode(oversized) + } + } + + @Test func responseRejectsUnsortedAndDuplicateEntries() throws { + let unsorted = try Self.responseData( + entries: [ + Self.entry(name: "z", path: "/tmp/z"), + Self.entry(name: "a", path: "/tmp/a"), + ], + offset: 0, + limit: 2, + totalCount: 2, + nextOffset: nil + ) + #expect(throws: DecodingError.self) { + try MobileTaskDirectoryListResponse.decode(unsorted) + } + + let duplicated = try Self.responseData( + entries: [ + Self.entry(name: "a", path: "/tmp/a"), + Self.entry(name: "b", path: "/tmp/a"), + ], + offset: 0, + limit: 2, + totalCount: 2, + nextOffset: nil + ) + #expect(throws: DecodingError.self) { + try MobileTaskDirectoryListResponse.decode(duplicated) + } + } + + private static func entry( + name: String, + path: String, + isHidden: Bool = false, + isPackage: Bool = false, + isSymbolicLink: Bool = false, + isReadable: Bool = true + ) -> [String: Any] { + [ + "name": name, + "path": path, + "is_hidden": isHidden, + "is_package": isPackage, + "is_symbolic_link": isSymbolicLink, + "is_readable": isReadable, + ] + } + + private static func responseData( + entries: [[String: Any]], + offset: Int, + limit: Int, + totalCount: Int, + nextOffset: Int? + ) throws -> Data { + try JSONSerialization.data(withJSONObject: [ + "current_path": "/Users/test", + "parent_path": "/Users", + "entries": entries, + "offset": offset, + "limit": limit, + "total_count": totalCount, + "next_offset": nextOffset.map { $0 as Any } ?? NSNull(), + ]) + } +} diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileTaskDirectorySearchResponseTests.swift b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileTaskDirectorySearchResponseTests.swift new file mode 100644 index 000000000000..a3fa20ff075f --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileTaskDirectorySearchResponseTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing +@testable import CmuxMobileRPC + +@Suite struct MobileTaskDirectorySearchResponseTests { + @Test func decodesBoundedNonemptyPathsWithoutCanonicalizingThem() throws { + let canonicallyDistinct = ["/tmp/café", "/tmp/cafe\u{301}"] + let raw = canonicallyDistinct + [" "] + (0..<80).map { "/tmp/project-\($0)" } + let data = try JSONEncoder().encode(["directories": raw]) + + let response = try MobileTaskDirectorySearchResponse.decode(data) + + #expect(response.directories.count == 64) + #expect(Array(response.directories.prefix(2)).map { Array($0.utf8) } == canonicallyDistinct.map { Array($0.utf8) }) + #expect(!response.directories.contains(" ")) + #expect(response.searchScope == .legacyBounded) + #expect(!response.gatheringComplete) + #expect(!response.filesystemComplete) + #expect(response.truncated) + #expect(response.indexedMatchCount == 0) + } + + @Test func decodesIndexedCoverageWithoutClaimingFilesystemCompleteness() throws { + let data = try JSONSerialization.data(withJSONObject: [ + "directories": ["/Users/test/Dev/cmux", "/Volumes/External/cmux"], + "search_scope": "all_indexed_volumes", + "gathering_complete": false, + "filesystem_complete": false, + "truncated": true, + "indexed_match_count": 81, + ]) + + let response = try MobileTaskDirectorySearchResponse.decode(data) + + #expect(response.directories.count == 2) + #expect(response.searchScope == .allIndexedVolumes) + #expect(!response.gatheringComplete) + #expect(!response.filesystemComplete) + #expect(response.truncated) + #expect(response.indexedMatchCount == 81) + } + + @Test func rejectsNegativeIndexedMatchCount() throws { + let data = try JSONSerialization.data(withJSONObject: [ + "directories": [], + "indexed_match_count": -1, + ]) + + #expect(throws: DecodingError.self) { + try MobileTaskDirectorySearchResponse.decode(data) + } + } + + @Test func rejectsIndexedSearchClaimingCompleteFilesystemCoverage() throws { + let data = try JSONSerialization.data(withJSONObject: [ + "directories": [], + "search_scope": "all_indexed_volumes", + "filesystem_complete": true, + ]) + + #expect(throws: DecodingError.self) { + try MobileTaskDirectorySearchResponse.decode(data) + } + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacSwitchState.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacSwitchState.swift index 69ca15ad3461..440e2f042979 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacSwitchState.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacSwitchState.swift @@ -73,6 +73,26 @@ extension MobileShellComposite { /// budget. public var isMacSwitchInFlight: Bool { macSwitchAttemptID != nil } + func cancelMacSwitchAttempt(_ attemptID: UUID) -> Task? { + macSwitchAttemptID == attemptID ? cancelPendingMacSwitch(restorePreviousOnCancel: true) : nil + } + + func isCurrentMacSwitchAttempt(_ attemptID: UUID) -> Bool { + macSwitchAttemptID == attemptID + && macSwitchAttemptSignInGeneration == signInGeneration + && isSignedIn + && !Task.isCancelled + } + + func finishMacSwitchAttempt(_ attemptID: UUID) { + if macSwitchAttemptID == attemptID { + macSwitchAttemptID = nil + macSwitchAttemptSignInGeneration = nil + macSwitchRestoreBaseline = nil + } + macSwitchRestorePreviousOnCancelAttemptIDs.remove(attemptID) + } + /// Assign any newly seen real Mac a stable in-memory color slot. /// /// Called from `recomputeDerivedWorkspaceState()` before deriving diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+RouteSelection.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+RouteSelection.swift new file mode 100644 index 000000000000..a4c4f7c1acf0 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+RouteSelection.swift @@ -0,0 +1,34 @@ +internal import CMUXMobileCore +internal import Foundation +internal import CmuxMobileSupport + +// Route selection for reconnect/attach: which published route a phone should +// dial for a Mac, and why loopback routes are only valid on the simulator. +// Extracted from MobileShellComposite.swift (Swift file length budget). +extension MobileShellComposite { + /// Whether stored-route selection must reject loopback routes. A loopback route + /// (`.debugLoopback`, `127.0.0.1`) names the host it runs on, so on a + /// physical device it can only ever reach the phone itself, never a remote + /// Mac. Simulators and explicit mock-data UI tests host their test server at + /// loopback, so those harnesses opt into it instead of weakening real-device + /// reconnect for every debug build. + static var prefersNonLoopbackRoutes: Bool { + #if os(iOS) && !targetEnvironment(simulator) + !UITestConfig.mockDataEnabled + #else + false + #endif + } + + /// Whether `host` is a numeric IP literal (IPv4 or IPv6) rather than a name + /// that needs DNS resolution. Used to prefer directly-dialable IP routes over + /// MagicDNS hostnames, which fail to resolve on some clients. + static func isIPLiteralHost(_ host: String) -> Bool { + if host.contains(":") { return true } // IPv6 literal + let octets = host.split(separator: ".", omittingEmptySubsequences: false) + return octets.count == 4 && octets.allSatisfy { part in + guard let value = Int(part), (0...255).contains(value), !part.isEmpty else { return false } + return String(value) == part // reject leading zeros / non-canonical + } + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskComposer.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskComposer.swift new file mode 100644 index 000000000000..5539dbcde0bd --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskComposer.swift @@ -0,0 +1,216 @@ +internal import CmuxMobilePairedMac +public import CmuxMobileRPC +public import CmuxMobileShellModel +internal import Foundation + +/// A user-actionable failure returned by task-composer directory search. +public enum MobileTaskDirectorySearchFailure: Error, Equatable, Sendable { + /// The selected Mac predates task-composer directory search. + case unsupported + /// The selected Mac could not be reached. + case unavailable + /// The Mac did not finish directory search before its deadline. + case timedOut + /// The phone or Mac must be signed in again before search can continue. + case authorizationRequired + /// The Mac rejected or could not decode the directory-search request. + case rejected + /// The caller superseded or cancelled this search. + case cancelled +} + +extension MobileShellComposite { + /// Returns matching Mac directories with explicit index and filesystem coverage. + public func searchTaskDirectories( + macDeviceID: String, + query rawQuery: String + ) async -> Result { + let query = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { + return .success(MobileTaskDirectorySearchResponse( + directories: [], + searchScope: .contextualCandidatesOnly + )) + } + if macDeviceID != foregroundMacDeviceID || remoteClient == nil { + guard await switchToMac(macDeviceID: macDeviceID) else { return .failure(.unavailable) } + } + guard !Task.isCancelled, foregroundMacDeviceID == macDeviceID, + let client = remoteClient else { return .failure(.cancelled) } + let generation = connectionGeneration + // The last learned capability set can be stale after a tagged Mac + // relaunch. This optional read is safe to probe; genuinely older Macs + // return an RPC error and the UI keeps its contextual suggestions. + do { + let request = try MobileCoreRPCClient.requestData( + method: "mobile.directory.search", + params: ["query": query] + ) + let data = try await client.sendRequest(request, timeoutNanoseconds: 4_000_000_000) + guard !Task.isCancelled, foregroundMacDeviceID == macDeviceID else { + return .failure(.cancelled) + } + return .success(try MobileTaskDirectorySearchResponse.decode(data)) + } catch let error as MobileShellConnectionError { + handleMacAvailabilityFailureIfCurrent( + after: error, + expectedClient: client, + expectedGeneration: generation + ) + switch error { + case let .rpcError(code, _) where [ + "method_not_found", + "unknown_method", + "unsupported_method", + ].contains(code?.lowercased() ?? ""): + return .failure(.unsupported) + case .requestTimedOut, + .rpcError("request_timeout", _): + return .failure(.timedOut) + case .authorizationFailed, + .accountMismatch, + .rpcError("unauthorized", _), + .rpcError("forbidden", _), + .rpcError("account_mismatch", _): + return .failure(.authorizationRequired) + case .rpcError("cancelled", _): + return .failure(.cancelled) + case .connectionClosed, + .transportWriteTimedOut, + .insecureManualRoute, + .attachTicketExpired: + return .failure(.unavailable) + case .invalidResponse, + .rpcError: + return .failure(.rejected) + } + } catch is CancellationError { + return .failure(.cancelled) + } catch { + return .failure(.rejected) + } + } + + /// Persists an unsent composer draft only for the signed-in session that + /// created the sheet. A stale disappearing sheet must not restore the + /// previous account's draft after sign-out has cleared it. + /// - Parameters: + /// - draft: Draft snapshot to persist. + /// - capturedGeneration: ``currentSessionGeneration`` captured when the + /// composer sheet was created. + /// - Returns: `true` when the draft belongs to the active session and was + /// handed to the configured template store. + @discardableResult + public func persistTaskComposerDraft( + _ draft: MobileTaskComposerDraft, + ifSessionGeneration capturedGeneration: Int + ) -> Bool { + guard isSignedIn, capturedGeneration == currentSessionGeneration else { + return false + } + taskTemplateStore?.setComposerDraft(draft) + return taskTemplateStore != nil + } + + /// Clears the composer draft only for the signed-in session that created + /// the sheet. A stale cancel or async success must not erase a newer + /// account's draft. + /// - Parameter capturedGeneration: ``currentSessionGeneration`` captured + /// when the composer sheet was created. + /// - Returns: `true` when the active session's draft store was cleared. + @discardableResult + public func clearTaskComposerDraft( + ifSessionGeneration capturedGeneration: Int + ) -> Bool { + guard isSignedIn, capturedGeneration == currentSessionGeneration, + let taskTemplateStore else { + return false + } + taskTemplateStore.setComposerDraft(nil) + return true + } + + /// Persists successful task-composer defaults and clears the submitted + /// draft as one generation-checked main-actor transaction. A completion + /// from a signed-out session must not repopulate the next account's store. + /// - Parameters: + /// - snapshot: Immutable values used by the successful submission. + /// - capturedGeneration: ``currentSessionGeneration`` captured when the + /// composer sheet was created. + /// - Returns: `true` when the success belonged to the active session and + /// was applied to the configured template store. + @discardableResult + public func completeTaskComposerSubmission( + _ snapshot: MobileTaskSubmissionSnapshot, + ifSessionGeneration capturedGeneration: Int + ) -> Bool { + guard isSignedIn, capturedGeneration == currentSessionGeneration, + let taskTemplateStore else { + return false + } + taskTemplateStore.setLastTemplateID(snapshot.templateID) + taskTemplateStore.setLastMacDeviceID(snapshot.macDeviceID) + taskTemplateStore.setLastDirectory( + snapshot.trimmedDirectory.isEmpty ? nil : snapshot.trimmedDirectory, + macDeviceID: snapshot.macDeviceID + ) + if !snapshot.trimmedDirectory.isEmpty { + taskTemplateStore.recordRecentDirectory( + snapshot.trimmedDirectory, + macDeviceID: snapshot.macDeviceID, + at: Date() + ) + } + taskTemplateStore.setComposerDraft(nil) + return true + } + + /// Submit a task-composer workspace create request to the selected Mac. + /// - Parameters: + /// - macDeviceID: Target Mac device id. + /// - spec: Workspace-create parameters derived from the selected template. + /// - willStartCreate: Optional main-actor callback invoked after the target + /// Mac and capability are resolved, immediately before the create begins. + /// - Returns: `success` when the workspace was created; otherwise the failure to display. + @discardableResult + public func submitTaskComposer( + macDeviceID: String, + spec: MobileWorkspaceCreateSpec, + willStartCreate: (@MainActor () -> Void)? = nil + ) async -> Result { + // A dropped connection can leave `foregroundMacDeviceID` pointing at the + // selected Mac while `remoteClient` is already gone; a matching id alone + // must not skip the switch, or the create fails as not-connected without + // ever attempting a re-dial. `switchToMac` short-circuits when the + // foreground connection to this Mac is genuinely live. + if macDeviceID != foregroundMacDeviceID || remoteClient == nil { + guard await switchToMac(macDeviceID: macDeviceID) else { + return .failure(.notConnected(hostDisplayName: taskComposerTargetName(macDeviceID: macDeviceID))) + } + } + guard !Task.isCancelled else { + return .failure(.notConnected(hostDisplayName: taskComposerTargetName(macDeviceID: macDeviceID))) + } + guard let pinnedContext = captureWorkspaceCreateContext(), + pinnedContext.macDeviceID == macDeviceID else { + return .failure(.notConnected(hostDisplayName: taskComposerTargetName(macDeviceID: macDeviceID))) + } + guard pinnedContext.supportedHostCapabilities.contains(Self.taskCreateCapability) else { + return .failure(.unsupported(hostDisplayName: taskComposerTargetName(macDeviceID: macDeviceID))) + } + guard !Task.isCancelled else { + return .failure(.notConnected(hostDisplayName: pinnedContext.hostDisplayName)) + } + return await createWorkspaceRequest( + spec: spec, + pinnedContext: pinnedContext, + willStartCreate: willStartCreate + ) + } + + private func taskComposerTargetName(macDeviceID: String) -> String { + displayPairedMacs.first { $0.macDeviceID == macDeviceID }?.resolvedName + ?? pairedMacs.first { $0.macDeviceID == macDeviceID }?.resolvedName + ?? macDeviceID + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskDirectoryList.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskDirectoryList.swift new file mode 100644 index 000000000000..4e225eb44d68 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskDirectoryList.swift @@ -0,0 +1,109 @@ +public import CmuxMobileRPC +internal import Foundation + +extension MobileShellComposite { + /// Lists one stable page of direct child directories on a selected Mac. + /// + /// This probes the method even when the cached capability set is stale. A + /// genuinely older Mac returns ``MobileTaskDirectoryListFailure/unsupported`` + /// so the UI can explain that browsing requires a Mac update. + /// + /// - Parameters: + /// - macDeviceID: The paired Mac that owns the filesystem. + /// - path: An absolute path, `~`, or a path beginning with `~/`. + /// - offset: A nonnegative entry offset in the directory's stable order. + /// - limit: A page size from `1` through + /// ``MobileTaskDirectoryListRequest/maximumPageSize``. + /// - Returns: A validated page, or a typed user-actionable failure. + public func listTaskDirectories( + macDeviceID: String, + path: String, + offset: Int = 0, + limit: Int = MobileTaskDirectoryListRequest.defaultPageSize + ) async -> Result { + guard let listRequest = MobileTaskDirectoryListRequest( + path: path, + offset: offset, + limit: limit + ) else { + return .failure(.invalidPath) + } + + if macDeviceID != foregroundMacDeviceID || remoteClient == nil { + guard await switchToMac(macDeviceID: macDeviceID) else { + return .failure(Task.isCancelled ? .cancelled : .unavailable) + } + } + guard !Task.isCancelled, foregroundMacDeviceID == macDeviceID, + let client = remoteClient else { + return .failure(.cancelled) + } + let generation = connectionGeneration + + do { + let requestData = try MobileCoreRPCClient.requestData( + method: "mobile.directory.list", + params: [ + "path": listRequest.path, + "offset": listRequest.offset, + "limit": listRequest.limit, + ] + ) + let data = try await client.sendRequest( + requestData, + timeoutNanoseconds: 4_000_000_000 + ) + guard !Task.isCancelled, foregroundMacDeviceID == macDeviceID else { + return .failure(.cancelled) + } + return .success(try MobileTaskDirectoryListResponse.decode(data)) + } catch let error as MobileShellConnectionError { + handleMacAvailabilityFailureIfCurrent( + after: error, + expectedClient: client, + expectedGeneration: generation + ) + switch error { + case let .rpcError(code, _) where [ + "method_not_found", + "unknown_method", + "unsupported_method", + ].contains(code?.lowercased() ?? ""): + return .failure(.unsupported) + case .requestTimedOut, + .rpcError("request_timeout", _): + return .failure(.timedOut) + case .authorizationFailed, + .accountMismatch, + .rpcError("unauthorized", _), + .rpcError("account_mismatch", _), + .rpcError("forbidden", _): + return .failure(.authorizationRequired) + case .rpcError("invalid_params", _): + return .failure(.invalidPath) + case .rpcError("directory_not_found", _): + return .failure(.notFound) + case .rpcError("not_a_directory", _): + return .failure(.notDirectory) + case .rpcError("permission_denied", _): + return .failure(.permissionDenied) + case .rpcError("directory_unreadable", _): + return .failure(.unreadable) + case .rpcError("cancelled", _): + return .failure(.cancelled) + case .connectionClosed, + .transportWriteTimedOut, + .insecureManualRoute, + .attachTicketExpired: + return .failure(.unavailable) + case .invalidResponse, + .rpcError: + return .failure(.rejected) + } + } catch is CancellationError { + return .failure(.cancelled) + } catch { + return .failure(.rejected) + } + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceActions.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceActions.swift index b7b8360ea197..8f3ec77e8df3 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceActions.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceActions.swift @@ -408,7 +408,7 @@ extension MobileShellComposite { return workspaceMutationTarget(for: anchorWorkspaceID) } - private func workspaceMutationFailure( + func workspaceMutationFailure( _ error: any Error, hostDisplayName: String? ) -> MobileWorkspaceMutationFailure { @@ -428,9 +428,12 @@ extension MobileShellComposite { ["unauthorized", "forbidden", "invalid_token", "token_expired", "expired_token", "auth_required", "account_mismatch"].contains(normalizedCode) { return .authorizationFailed(hostDisplayName: hostDisplayName) } - if normalizedCode == "unavailable" { - return .notConnected(hostDisplayName: hostDisplayName) - } + if normalizedCode == "unavailable" { return .notConnected(hostDisplayName: hostDisplayName) } + if normalizedCode == "request_timeout" { return .requestTimedOut(hostDisplayName: hostDisplayName) } + if normalizedCode == "busy" { return .busy(hostDisplayName: hostDisplayName) } + if normalizedCode == "invalid_working_directory" { return .invalidWorkingDirectory(hostDisplayName: hostDisplayName) } + if normalizedCode == "persistence_failed" { return .persistenceUnavailable(hostDisplayName: hostDisplayName) } + if normalizedCode == "already_completed" { return .alreadyCompleted(hostDisplayName: hostDisplayName) } return .rejected(hostDisplayName: hostDisplayName) case .invalidResponse: return .rejected(hostDisplayName: hostDisplayName) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceCreateRequest.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceCreateRequest.swift index f48d146359f4..07b57d0f7fdb 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceCreateRequest.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceCreateRequest.swift @@ -5,24 +5,43 @@ internal import Foundation extension MobileShellComposite { /// Create a workspace and surface success/failure to the caller. /// - Parameter groupID: Optional destination group for the new workspace. + /// - Parameter spec: Optional workspace-create parameters for task creation. /// - Returns: `success` when the connected Mac created the workspace, /// otherwise the failure the UI should surface. @discardableResult public func createWorkspaceRequest( - inGroup groupID: MobileWorkspaceGroupPreview.ID? = nil + inGroup groupID: MobileWorkspaceGroupPreview.ID? = nil, + spec: MobileWorkspaceCreateSpec? = nil ) async -> Result { - guard remoteClient != nil else { + guard let context = captureWorkspaceCreateContext() else { return .failure(.notConnected(hostDisplayName: connectedHostName)) } - guard groupID == nil || allowsMacScopedWorkspaceMutations(targetClient: remoteClient) else { - return .failure(.authorizationFailed(hostDisplayName: connectedHostName)) + return await createWorkspaceRequest( + inGroup: groupID, + spec: spec, + pinnedContext: context + ) + } + + func createWorkspaceRequest( + inGroup groupID: MobileWorkspaceGroupPreview.ID? = nil, + spec: MobileWorkspaceCreateSpec? = nil, + pinnedContext context: WorkspaceCreatePinnedContext, + willStartCreate: (@MainActor () -> Void)? = nil + ) async -> Result { + guard groupID == nil || allowsMacScopedWorkspaceMutations(targetClient: context.client) else { + return .failure(.authorizationFailed(hostDisplayName: context.hostDisplayName)) } if let createWorkspaceTask { - guard createWorkspaceTaskGroupID == groupID else { - return .failure(.busy(hostDisplayName: connectedHostName)) + guard spec == nil, createWorkspaceTaskSpec == nil, createWorkspaceTaskGroupID == groupID else { + return .failure(.busy(hostDisplayName: context.hostDisplayName)) } return await createWorkspaceTask.value } + guard !Task.isCancelled else { + return .failure(.notConnected(hostDisplayName: context.hostDisplayName)) + } + willStartCreate?() let taskID = UUID() createWorkspaceTaskID = taskID let task = Task, Never> { @MainActor [weak self] in @@ -30,44 +49,95 @@ extension MobileShellComposite { guard let self else { return .success(()) } return await self.createRemoteWorkspace( inGroup: groupID, - appliesOperationalError: false + appliesOperationalError: false, + spec: spec, + pinnedContext: context ) } createWorkspaceTask = task createWorkspaceTaskGroupID = groupID + createWorkspaceTaskSpec = spec return await task.value } func createRemoteWorkspace( inGroup groupID: MobileWorkspaceGroupPreview.ID? = nil, - appliesOperationalError: Bool = true + appliesOperationalError: Bool = true, + spec: MobileWorkspaceCreateSpec? = nil, + pinnedContext suppliedContext: WorkspaceCreatePinnedContext? = nil ) async -> Result { - guard let client = remoteClient else { + guard let context = suppliedContext ?? captureWorkspaceCreateContext() else { return .failure(.notConnected(hostDisplayName: connectedHostName)) } + let client = context.client guard groupID == nil || allowsMacScopedWorkspaceMutations(targetClient: client) else { - return .failure(.authorizationFailed(hostDisplayName: connectedHostName)) + return .failure(.authorizationFailed(hostDisplayName: context.hostDisplayName)) } - let generation = connectionGeneration do { var params: [String: Any] = [:] if let groupID { params["group_id"] = groupID.rawValue } + if let title = spec?.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { + params["title"] = title + } + if let workingDirectory = spec?.workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines), + !workingDirectory.isEmpty { + params["working_directory"] = workingDirectory + } + if let initialCommand = spec?.initialCommand, + !initialCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + params["initial_command"] = initialCommand + } + if let initialEnv = spec?.initialEnv, !initialEnv.isEmpty { + params["initial_env"] = initialEnv + } + if let operationID = spec?.operationID { + params["operation_id"] = operationID.uuidString + } + guard isCurrentWorkspaceCreateContext(context), !Task.isCancelled else { + return .failure(.notConnected(hostDisplayName: context.hostDisplayName)) + } let resultData = try await client.sendRequest( MobileCoreRPCClient.requestData(method: "workspace.create", params: params) ) let response = try MobileSyncWorkspaceListResponse.decode(resultData) - guard isCurrentRemoteOperation(client: client, generation: generation), !Task.isCancelled else { + let createdWorkspace: MobileWorkspacePreview.ID? + if spec != nil { + guard let createdWorkspaceID = response.createdWorkspaceID, + !createdWorkspaceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + response.workspaces.contains(where: { $0.id == createdWorkspaceID }) else { + return .failure(.rejected(hostDisplayName: context.hostDisplayName)) + } + createdWorkspace = MobileWorkspacePreview.ID(rawValue: createdWorkspaceID) + } else { + // Legacy workspace creates predate `created_workspace_id`. Keep + // accepting their list response, while spec creates require the + // exact created workspace so callers can navigate reliably. + createdWorkspace = response.createdWorkspaceID.map(MobileWorkspacePreview.ID.init(rawValue:)) + } + switch WorkspaceCreatePinnedContext.postResponseDisposition( + operationID: spec?.operationID, + isCancelled: Task.isCancelled, + isCurrent: isCurrentWorkspaceCreateContext(context) + ) { + case .preserveSuccess: + // Creates without an idempotency key cannot be retried safely + // after the host returns success. Preserve that decoded result + // across cancellation or connection replacement, but do not + // apply its now-stale workspace list to the current session. return .success(()) + case .failClosed: + return .failure(.notConnected(hostDisplayName: context.hostDisplayName)) + case .apply: + break } applyRemoteWorkspaceList(response, mergeExistingWorkspaces: true) - let createdWorkspace = response.createdWorkspaceID.map(MobileWorkspacePreview.ID.init(rawValue:)) if let createdWorkspace { setSelectedWorkspaceID( rowWorkspaceID( forRemoteWorkspaceID: createdWorkspace, - macDeviceID: foregroundMacDeviceID + macDeviceID: context.macDeviceID ) ?? createdWorkspace ) } @@ -78,41 +148,62 @@ extension MobileShellComposite { } return .success(()) } catch { - guard generation == connectionGeneration, !Task.isCancelled else { return .success(()) } - if disconnectForAuthorizationFailureIfNeeded(error) { - return .failure(.authorizationFailed(hostDisplayName: connectedHostName)) + let isCurrentContext = isCurrentWorkspaceCreateContext(context) + if isCurrentContext { + handleMacAvailabilityFailureIfCurrent( + after: error, + expectedClient: context.client, + expectedGeneration: context.generation + ) } - handleMacAvailabilityFailureIfCurrent( - after: error, - expectedClient: client, - expectedGeneration: generation - ) - if appliesOperationalError { - applyOperationalError(error) + switch WorkspaceCreatePinnedContext.caughtErrorDisposition( + operationID: spec?.operationID, + error: error + ) { + case .preserveSuccess: + // A legacy create has no idempotency key, so an interrupted + // request may already have succeeded and cannot be retried + // safely. A definite host rejection must remain a failure even + // when cancellation races its delivery. Task-composer creates + // have an operation ID and fail closed instead. + return .success(()) + case .failClosed: + return .failure(.notConnected(hostDisplayName: context.hostDisplayName)) + case .surfaceError: + break } - if let connectionError = error as? MobileShellConnectionError { - switch connectionError { - case .connectionClosed, .transportWriteTimedOut: - return .failure(.notConnected(hostDisplayName: connectedHostName)) - case .requestTimedOut: - return .failure(.requestTimedOut(hostDisplayName: connectedHostName)) - case .attachTicketExpired, .authorizationFailed, .accountMismatch, .insecureManualRoute: - return .failure(.authorizationFailed(hostDisplayName: connectedHostName)) - case let .rpcError(code, _): - let normalizedCode = code?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if let normalizedCode, - ["unauthorized", "forbidden", "invalid_token", "token_expired", "expired_token", "auth_required", "account_mismatch"].contains(normalizedCode) { - return .failure(.authorizationFailed(hostDisplayName: connectedHostName)) - } - if normalizedCode == "unavailable" { - return .failure(.notConnected(hostDisplayName: connectedHostName)) - } - return .failure(.rejected(hostDisplayName: connectedHostName)) - case .invalidResponse: - return .failure(.rejected(hostDisplayName: connectedHostName)) + // A stale operation must not mutate the replacement connection, + // but task-composer failures must remain retryable with the same ID. + if isCurrentContext { + if disconnectForAuthorizationFailureIfNeeded(error) { + return .failure(.authorizationFailed(hostDisplayName: context.hostDisplayName)) + } + if appliesOperationalError { + applyOperationalError(error) } } - return .failure(.rejected(hostDisplayName: connectedHostName)) + return .failure( + workspaceMutationFailure(error, hostDisplayName: context.hostDisplayName) + ) } } + + func captureWorkspaceCreateContext() -> WorkspaceCreatePinnedContext? { + guard connectionState == .connected, let remoteClient else { return nil } + return WorkspaceCreatePinnedContext( + macDeviceID: foregroundMacDeviceID, + client: remoteClient, + generation: connectionGeneration, + supportedHostCapabilities: supportedHostCapabilities, + hostDisplayName: connectedHostName + ) + } + + private func isCurrentWorkspaceCreateContext(_ context: WorkspaceCreatePinnedContext) -> Bool { + context.isCurrent( + macDeviceID: foregroundMacDeviceID, + client: remoteClient, + generation: connectionGeneration + ) && isSignedIn && connectionState == .connected + } } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index d2c53510f546..591f99968d3c 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -75,7 +75,6 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } private static let hasKnownPairedMacDefaultsKey = "cmux.mobile.hasKnownPairedMac" - /// Max seconds the launch reconnect may keep the restoring gate /// (``RestoringSessionView``) on screen before resolving to the /// disconnected/add-device UI. A stored Mac whose route went stale makes the @@ -94,6 +93,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { static let workspaceMoveCapability = "workspace.move.v1" static let workspaceGroupActionsCapability = "workspace.group_actions.v1" static let workspaceCreateInGroupCapability = "workspace.create_in_group.v1", workspaceGroupCreateCapability = "workspace.group_create.v1" + static let taskCreateCapability = "workspace.task_create.v1" static let chatArtifactCapability = "chat.artifact.v1" static let chatArtifactGalleryCapability = "chat.artifact.gallery.v1" static let terminalArtifactCapability = "terminal.artifact.v1" @@ -332,6 +332,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// Mac, then phone-owned. `@ObservationIgnored` (views read `workspaceGroups`); /// injected so tests/previews can pass a suite-scoped `UserDefaults`. @ObservationIgnored var groupCollapseStore: MobileWorkspaceGroupCollapseStore + /// Device-local task templates used by the iOS task composer. + @ObservationIgnored public let taskTemplateStore: (any MobileTaskTemplateStoring)? /// The connected Mac's `mobile.host.status` capabilities. Feature gates are /// computed from this set so version-skew checks cannot drift from the raw /// host payload. @@ -493,7 +495,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// a sign-out bumps the token, so stale continuations are dropped instead of /// writing the previous user's state under ids the next account may reuse. Not /// observed: it gates async hand-backs, not view state. - @ObservationIgnored private var signInGeneration = 0 + @ObservationIgnored var signInGeneration = 0 public var selectedWorkspaceID: MobileWorkspacePreview.ID? { didSet { syncSelectedTerminalForWorkspace() @@ -733,6 +735,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { var notificationReconcileTask: Task? var createWorkspaceTask: Task, Never>? var createWorkspaceTaskGroupID: MobileWorkspaceGroupPreview.ID? + var createWorkspaceTaskSpec: MobileWorkspaceCreateSpec? private var createTerminalTask: Task? private var workspaceListRefreshTask: Task? /// The user pull-to-refresh round-trip, kept on its own handle so the @@ -744,9 +747,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { var connectionGeneration: UUID var connectionAttemptGeneration: UUID @ObservationIgnored var macSwitchAttemptID: UUID? - @ObservationIgnored private var macSwitchAttemptSignInGeneration: Int? - @ObservationIgnored private var macSwitchRestorePreviousOnCancelAttemptIDs: Set = [] - @ObservationIgnored private var macSwitchRestoreBaseline: MobilePairedMac? + @ObservationIgnored var macSwitchAttemptSignInGeneration: Int? + @ObservationIgnored var macSwitchRestorePreviousOnCancelAttemptIDs: Set = [] + @ObservationIgnored var macSwitchRestoreBaseline: MobilePairedMac? @ObservationIgnored private var macSwitchCancelRestoreGeneration: UInt64 = 0 private var chatEventSourceGeneration: UUID /// The per-Mac connection pool (P2 of the multi-Mac work), keyed by @@ -933,11 +936,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { feedbackEmailSubmitter: (any MobileFeedbackEmailSubmitting)? = nil, feedbackStampProvider: @escaping @MainActor () -> MobileFeedbackStamp = { MobileShellComposite.emptyFeedbackStamp }, draftStore: (any TerminalDraftStoring)? = nil, - groupCollapseStore: MobileWorkspaceGroupCollapseStore = MobileWorkspaceGroupCollapseStore() + groupCollapseStore: MobileWorkspaceGroupCollapseStore = MobileWorkspaceGroupCollapseStore(), + taskTemplateStore: (any MobileTaskTemplateStoring)? = nil ) { self.runtime = runtime self.draftStore = draftStore self.groupCollapseStore = groupCollapseStore + self.taskTemplateStore = taskTemplateStore self.pairedMacStore = pairedMacStore self.buildCompatibilityPolicy = buildCompatibilityPolicy self.pairedMacRestoreBoundary = pairedMacRestoreBoundary @@ -997,6 +1002,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { self.terminalSubscriptionRefreshTask = nil self.createWorkspaceTask = nil self.createWorkspaceTaskGroupID = nil + self.createWorkspaceTaskSpec = nil self.createTerminalTask = nil self.workspaceListRefreshTask = nil self.pullToRefreshTask = nil @@ -1128,10 +1134,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { connectedHostName = "" pairingCode = "" clearPairingVersionWarning() - // Wipe every saved draft so the next account never sees the previous - // user's unsent text. Guard the in-memory clear (and the selection resets - // below) so the per-terminal draft hooks do not write partial state into a - // store we are about to empty wholesale. + // Wipe every draft so the next account never sees its predecessor's text. + // Guard the in-memory clear and selection resets so per-terminal hooks do + // not write partial state into a store we are emptying wholesale. isLoadingDraft = true terminalInputText = "" chatSessionSnapshotsByWorkspaceID = [:] @@ -1141,6 +1146,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { if let draftStore { enqueueDraftOperation { await draftStore.clearAllDrafts() } } + taskTemplateStore?.clearAllUserData() // Drop unflushed keystroke snapshots too: an armed flush that runs // before the wipe would only write text the wipe then deletes, but the // buffer itself must not carry one account's text into the next. @@ -1641,6 +1647,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { ) guard isCurrentPairingAttempt(attemptID) else { return } if connectionState == .connected { + // `connect()` persists the manual pairing, while Settings, + // the Mac picker, and the task composer read the shared + // in-memory list. Refresh it before dismissing PairingView so + // those surfaces can use the new Mac immediately. + await loadPairedMacs() + guard isCurrentPairingAttempt(attemptID) else { return } recordPairingSucceeded() } else { // `connect()` returned without connecting and already set a @@ -2305,6 +2317,30 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { guard let pairedMacStore else { return false } let switchAttemptID = beginMacSwitchAttempt() let liveForegroundRestoreBaseline = liveForegroundMacForSwitchRestore() + return await withTaskCancellationHandler { + guard !Task.isCancelled else { + await restoreMacSwitchBaselineIfCancelled(switchAttemptID) + return false + } + return await performMacSwitch( + macDeviceID: macDeviceID, + instanceTag: instanceTag, + pairedMacStore: pairedMacStore, + switchAttemptID: switchAttemptID, + liveForegroundRestoreBaseline: liveForegroundRestoreBaseline + ) + } onCancel: { + Task { @MainActor [weak self] in _ = self?.cancelMacSwitchAttempt(switchAttemptID) } + } + } + + private func performMacSwitch( + macDeviceID: String, + instanceTag: String?, + pairedMacStore: any MobilePairedMacStoring, + switchAttemptID: UUID, + liveForegroundRestoreBaseline: MobilePairedMac? + ) async -> Bool { defer { finishMacSwitchAttempt(switchAttemptID) } // FAST PATH: if a live read-only connection to this Mac already exists, // promote it to the foreground (reuse the client) instead of re-dialing. @@ -2617,32 +2653,6 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { didFinishStoredMacReconnectAttempt = false } - /// Whether stored-route selection must reject loopback routes. A loopback route - /// (`.debugLoopback`, `127.0.0.1`) names the host it runs on, so on a - /// physical device it can only ever reach the phone itself, never a remote - /// Mac. Simulators and explicit mock-data UI tests host their test server at - /// loopback, so those harnesses opt into it instead of weakening real-device - /// reconnect for every debug build. - static var prefersNonLoopbackRoutes: Bool { - #if os(iOS) && !targetEnvironment(simulator) - !UITestConfig.mockDataEnabled - #else - false - #endif - } - - /// Whether `host` is a numeric IP literal (IPv4 or IPv6) rather than a name - /// that needs DNS resolution. Used to prefer directly-dialable IP routes over - /// MagicDNS hostnames, which fail to resolve on some clients. - static func isIPLiteralHost(_ host: String) -> Bool { - if host.contains(":") { return true } // IPv6 literal - let octets = host.split(separator: ".", omittingEmptySubsequences: false) - return octets.count == 4 && octets.allSatisfy { part in - guard let value = Int(part), (0...255).contains(value), !part.isEmpty else { return false } - return String(value) == part // reject leading zeros / non-canonical - } - } - /// Enqueues one paired-Mac store mutation on the serialized write chain. /// /// All `markActive` writes go through here so they execute strictly in @@ -3011,6 +3021,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { let noThrowFailure = try await connect(ticket: ticket) guard isCurrentPairingAttempt(attemptID) else { return .superseded } if connectionState == .connected && activeTicket != nil { + // Fresh pairing persists the Mac during `connect(ticket:)`, but + // presentation surfaces read the shared in-memory list. Refresh + // it before reporting success so an immediately opened picker or + // task composer sees the Mac without a manual Computers refresh. + await loadPairedMacs() + guard isCurrentPairingAttempt(attemptID) else { return .superseded } recordPairingSucceeded() return .connected } @@ -4113,6 +4129,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return await self.createRemoteWorkspace(inGroup: groupID) } createWorkspaceTaskGroupID = groupID + createWorkspaceTaskSpec = nil return } guard groupID == nil else { return } @@ -5520,6 +5537,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { createWorkspaceTask?.cancel() createWorkspaceTask = nil createWorkspaceTaskGroupID = nil + createWorkspaceTaskSpec = nil createWorkspaceTaskID = nil createTerminalTask?.cancel() createTerminalTask = nil @@ -5684,21 +5702,6 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return attemptID } - func isCurrentMacSwitchAttempt(_ attemptID: UUID) -> Bool { - macSwitchAttemptID == attemptID - && macSwitchAttemptSignInGeneration == signInGeneration - && isSignedIn - } - - private func finishMacSwitchAttempt(_ attemptID: UUID) { - if macSwitchAttemptID == attemptID { - macSwitchAttemptID = nil - macSwitchAttemptSignInGeneration = nil - macSwitchRestoreBaseline = nil - } - macSwitchRestorePreviousOnCancelAttemptIDs.remove(attemptID) - } - private func clearMacSwitchAttemptState(invalidateUnderlyingConnectionAttempt: Bool = false) { macSwitchCancelRestoreGeneration &+= 1 macSwitchAttemptID = nil @@ -5716,6 +5719,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { _ attemptID: UUID, fallback: MobilePairedMac? = nil ) async -> Bool { + if Task.isCancelled { + _ = cancelMacSwitchAttempt(attemptID) + } guard consumeMacSwitchRestorePreviousOnCancel(attemptID) else { return false } let restoreGeneration = macSwitchCancelRestoreGeneration let restored = await restorePreviousMacIfNeeded( @@ -5877,6 +5883,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { guard createWorkspaceTaskID == id else { return } createWorkspaceTask = nil createWorkspaceTaskGroupID = nil + createWorkspaceTaskSpec = nil createWorkspaceTaskID = nil } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskDirectoryListFailure.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskDirectoryListFailure.swift new file mode 100644 index 000000000000..7eb59e48b6bd --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskDirectoryListFailure.swift @@ -0,0 +1,25 @@ +/// A user-actionable failure returned by task-composer directory browsing. +public enum MobileTaskDirectoryListFailure: Error, Equatable, Sendable { + /// The requested path or pagination values are not valid for the browse RPC. + case invalidPath + /// The selected Mac could not be reached. + case unavailable + /// The Mac did not finish the directory listing before its deadline. + case timedOut + /// The phone or Mac must be signed in again before browsing can continue. + case authorizationRequired + /// The selected Mac predates hierarchical directory browsing. + case unsupported + /// The requested path no longer exists on the Mac. + case notFound + /// The requested path exists but is not a directory. + case notDirectory + /// macOS denied permission to enumerate the requested directory. + case permissionDenied + /// The requested directory exists but cannot be read. + case unreadable + /// The Mac rejected the request or returned a malformed page. + case rejected + /// The caller superseded or cancelled this listing. + case cancelled +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskTemplateStore.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskTemplateStore.swift new file mode 100644 index 000000000000..29851e33bdd8 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskTemplateStore.swift @@ -0,0 +1,205 @@ +public import CmuxMobileShellModel +internal import CmuxMobileSupport +public import Foundation + +/// `UserDefaults`-backed mobile task template store. Not `@Observable`: it has +/// no tracked stored state; views re-read via `listTemplates()` after mutations. +@MainActor +public final class UserDefaultsMobileTaskTemplateStore: MobileTaskTemplateStoring { + // UserDefaults is Apple-documented thread-safe; this main-actor store reads + // and writes synchronously through an injected defaults instance. + private nonisolated(unsafe) let defaults: UserDefaults + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + // v4 resets the unshipped seeds onto the environment-only prompt contract. + private static let templatesKey = "cmux.mobile.taskTemplates.v4" + private static let seededKey = "cmux.mobile.taskTemplates.seeded.v4" + private static let legacyKeys = [ + "cmux.mobile.taskTemplates.v1", + "cmux.mobile.taskTemplates.seeded.v1", + "cmux.mobile.taskTemplates.v2", + "cmux.mobile.taskTemplates.seeded.v2", + "cmux.mobile.taskTemplates.v3", + "cmux.mobile.taskTemplates.seeded.v3", + ] + private static let lastTemplateIDKey = "cmux.mobile.taskComposer.lastTemplateID" + private static let lastMacDeviceIDKey = "cmux.mobile.taskComposer.lastMacDeviceID" + private static let lastDirectoryPrefix = "cmux.mobile.taskComposer.lastDirectory." + private static let recentDirectoriesPrefix = "cmux.mobile.taskComposer.recentDirectories.v1." + private static let composerDraftKey = "cmux.mobile.taskComposer.draft.v1" + private static let recentDirectoryLimit = 20 + + /// Creates a task template store backed by `defaults`. + /// - Parameter defaults: The `UserDefaults` instance to persist into. + public init(defaults: UserDefaults) { + self.defaults = defaults + } + + /// Returns all stored templates, seeding defaults on the first read. + public func listTemplates() -> [MobileTaskTemplate] { + seedIfNeeded() + return loadTemplates() + } + + /// Appends a template and persists the full list. + public func addTemplate(_ template: MobileTaskTemplate) { + var templates = listTemplates() + templates.append(template) + saveTemplates(templates) + } + + /// Replaces an existing template with the same id. + public func updateTemplate(_ template: MobileTaskTemplate) { + var templates = listTemplates() + guard let index = templates.firstIndex(where: { $0.id == template.id }) else { return } + templates[index] = template + saveTemplates(templates) + } + + /// Deletes templates in one load, scan, and persistence update. + public func deleteTemplates(ids: Set) { + guard !ids.isEmpty else { return } + var templates = listTemplates() + templates.removeAll { ids.contains($0.id) } + saveTemplates(templates) + if let lastTemplateID = lastTemplateID(), ids.contains(lastTemplateID) { + setLastTemplateID(nil) + } + } + + /// Returns the last selected template id, if any. + public func lastTemplateID() -> MobileTaskTemplate.ID? { + guard let raw = defaults.string(forKey: Self.lastTemplateIDKey) else { return nil } + return UUID(uuidString: raw) + } + + /// Stores the last selected template id. + public func setLastTemplateID(_ id: MobileTaskTemplate.ID?) { + setOptional(id?.uuidString, forKey: Self.lastTemplateIDKey) + } + + /// Returns the last selected Mac device id, if any. + public func lastMacDeviceID() -> String? { + defaults.string(forKey: Self.lastMacDeviceIDKey) + } + + /// Stores the last selected Mac device id. + public func setLastMacDeviceID(_ id: String?) { + setOptional(id, forKey: Self.lastMacDeviceIDKey) + } + + /// Returns the last successful directory for one Mac. + public func lastDirectory(macDeviceID: String) -> String? { + defaults.string(forKey: Self.lastDirectoryPrefix + macDeviceID) + } + + /// Stores the last successful directory for one Mac. + public func setLastDirectory(_ directory: String?, macDeviceID: String) { + setOptional(directory, forKey: Self.lastDirectoryPrefix + macDeviceID) + } + + /// Returns successful directories for one Mac, newest first. + public func recentDirectories(macDeviceID: String) -> [MobileTaskRecentDirectory] { + guard let data = defaults.data(forKey: Self.recentDirectoriesPrefix + macDeviceID), + let directories = try? decoder.decode([MobileTaskRecentDirectory].self, from: data) else { + return [] + } + return directories + } + + /// Records one successful directory with exact UTF-8 identity and bounded storage. + public func recordRecentDirectory(_ directory: String, macDeviceID: String, at date: Date) { + guard !directory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + let identity = MobileTaskDirectoryPathID(path: directory) + var directories = recentDirectories(macDeviceID: macDeviceID) + let previousUseCount = directories.first { MobileTaskDirectoryPathID(path: $0.path) == identity }?.useCount ?? 0 + let nextUseCount = previousUseCount == Int.max ? Int.max : previousUseCount + 1 + directories.removeAll { MobileTaskDirectoryPathID(path: $0.path) == identity } + directories.insert( + MobileTaskRecentDirectory( + path: directory, + lastUsedAt: date, + useCount: nextUseCount + ), + at: 0 + ) + if directories.count > Self.recentDirectoryLimit { + directories.removeLast(directories.count - Self.recentDirectoryLimit) + } + guard let data = try? encoder.encode(directories) else { return } + defaults.set(data, forKey: Self.recentDirectoriesPrefix + macDeviceID) + } + + /// Returns the unsent task-composer draft, if one was saved. + public func composerDraft() -> MobileTaskComposerDraft? { + guard let data = defaults.data(forKey: Self.composerDraftKey) else { return nil } + return try? decoder.decode(MobileTaskComposerDraft.self, from: data) + } + + /// Stores or clears the unsent task-composer draft. + public func setComposerDraft(_ draft: MobileTaskComposerDraft?) { + guard let draft else { + defaults.removeObject(forKey: Self.composerDraftKey) + return + } + guard let data = try? encoder.encode(draft) else { return } + defaults.set(data, forKey: Self.composerDraftKey) + } + + /// Removes every account-derived template, selection, directory, and draft. + public func clearAllUserData() { + let keys = [ + Self.templatesKey, + Self.seededKey, + Self.lastTemplateIDKey, + Self.lastMacDeviceIDKey, + Self.composerDraftKey, + ] + Self.legacyKeys + for key in keys { + defaults.removeObject(forKey: key) + } + for key in defaults.dictionaryRepresentation().keys where key.hasPrefix(Self.lastDirectoryPrefix) { + defaults.removeObject(forKey: key) + } + for key in defaults.dictionaryRepresentation().keys where key.hasPrefix(Self.recentDirectoriesPrefix) { + defaults.removeObject(forKey: key) + } + } + + private func seedIfNeeded() { + guard !defaults.bool(forKey: Self.seededKey) else { return } + for key in Self.legacyKeys { + defaults.removeObject(forKey: key) + } + saveTemplates(MobileTaskTemplate.seedDefaults( + claudeName: L10n.string("mobile.taskComposer.template.seed.claude", defaultValue: "Claude"), + codexName: L10n.string("mobile.taskComposer.template.seed.codex", defaultValue: "Codex"), + openCodeName: L10n.string("mobile.taskComposer.template.seed.opencode", defaultValue: "OpenCode"), + shellName: L10n.string("mobile.taskComposer.template.seed.shell", defaultValue: "Shell") + )) + defaults.set(true, forKey: Self.seededKey) + } + + private func loadTemplates() -> [MobileTaskTemplate] { + guard let data = defaults.data(forKey: Self.templatesKey), + let templates = try? decoder.decode([MobileTaskTemplate].self, from: data) else { + return [] + } + return templates + } + + private func saveTemplates(_ templates: [MobileTaskTemplate]) { + guard let data = try? encoder.encode(templates) else { return } + defaults.set(data, forKey: Self.templatesKey) + } + + private func setOptional(_ value: String?, forKey key: String) { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmed, !trimmed.isEmpty { + defaults.set(trimmed, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskTemplateStoring.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskTemplateStoring.swift new file mode 100644 index 000000000000..cd054103e683 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskTemplateStoring.swift @@ -0,0 +1,44 @@ +public import CmuxMobileShellModel +public import Foundation + +/// Device-local persistence for mobile task templates and composer defaults. +@MainActor +public protocol MobileTaskTemplateStoring: AnyObject { + /// Returns all stored templates, seeding defaults on the first read. + func listTemplates() -> [MobileTaskTemplate] + /// Appends a template and persists the full list. + func addTemplate(_ template: MobileTaskTemplate) + /// Replaces an existing template with the same id. + func updateTemplate(_ template: MobileTaskTemplate) + /// Deletes the templates with the provided ids in one persistence update. + func deleteTemplates(ids: Set) + /// Returns the last selected template id, if any. + func lastTemplateID() -> MobileTaskTemplate.ID? + /// Stores the last selected template id. + func setLastTemplateID(_ id: MobileTaskTemplate.ID?) + /// Returns the last selected Mac device id, if any. + func lastMacDeviceID() -> String? + /// Stores the last selected Mac device id. + func setLastMacDeviceID(_ id: String?) + /// Returns the last successful directory for one Mac. + func lastDirectory(macDeviceID: String) -> String? + /// Stores the last successful directory for one Mac. + func setLastDirectory(_ directory: String?, macDeviceID: String) + /// Returns bounded successful directory history for one Mac, newest first. + func recentDirectories(macDeviceID: String) -> [MobileTaskRecentDirectory] + /// Promotes one successful directory in the per-Mac history. + func recordRecentDirectory(_ directory: String, macDeviceID: String, at date: Date) + /// Returns the unsent task-composer draft, if one was saved. + func composerDraft() -> MobileTaskComposerDraft? + /// Stores or clears the unsent task-composer draft. + func setComposerDraft(_ draft: MobileTaskComposerDraft?) + /// Removes all templates and composer state owned by the signed-out user. + func clearAllUserData() +} + +public extension MobileTaskTemplateStoring { + /// Deletes one template. + func deleteTemplate(id: MobileTaskTemplate.ID) { + deleteTemplates(ids: [id]) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceCreateSpec.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceCreateSpec.swift new file mode 100644 index 000000000000..c9fd45d07f07 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceCreateSpec.swift @@ -0,0 +1,35 @@ +public import Foundation + +/// Optional parameters for a mobile `workspace.create` request. +public struct MobileWorkspaceCreateSpec: Equatable, Sendable { + /// Workspace title. + public var title: String? + /// Initial working directory. + public var workingDirectory: String? + /// Initial terminal command. + public var initialCommand: String? + /// Initial terminal environment. + public var initialEnv: [String: String]? + /// Stable identity for idempotent retry of one logical create operation. + public var operationID: UUID? + + /// Creates optional workspace-create parameters. + /// - Parameters: + /// - title: Workspace title. + /// - workingDirectory: Initial working directory. + /// - initialCommand: Initial terminal command. + /// - initialEnv: Initial terminal environment. + public init( + title: String? = nil, + workingDirectory: String? = nil, + initialCommand: String? = nil, + initialEnv: [String: String]? = nil, + operationID: UUID? = nil + ) { + self.title = title + self.workingDirectory = workingDirectory + self.initialCommand = initialCommand + self.initialEnv = initialEnv + self.operationID = operationID + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceMutationFailure.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceMutationFailure.swift index 2ee4cd14d4ac..fad15d603d87 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceMutationFailure.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceMutationFailure.swift @@ -13,6 +13,12 @@ public enum MobileWorkspaceMutationFailure: Error, Equatable, Sendable { case busy(hostDisplayName: String?) /// The target Mac rejected the requested mutation. case rejected(hostDisplayName: String?) + /// The requested task directory does not exist as a directory on the target Mac. + case invalidWorkingDirectory(hostDisplayName: String?) + /// The target Mac could not durably reserve an idempotent task request. + case persistenceUnavailable(hostDisplayName: String?) + /// The Mac accepted the task but no recoverable workspace is available. + case alreadyCompleted(hostDisplayName: String?) /// The current host does not support the requested mutation. case unsupported(hostDisplayName: String?) } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceCreatePinnedContext.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceCreatePinnedContext.swift new file mode 100644 index 000000000000..d585c92ef162 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceCreatePinnedContext.swift @@ -0,0 +1,68 @@ +internal import CmuxMobileRPC +internal import Foundation + +extension MobileShellComposite { + enum WorkspaceCreatePostResponseDisposition: Equatable { + case apply + case preserveSuccess + case failClosed + } + + enum WorkspaceCreateCaughtErrorDisposition: Equatable { + case preserveSuccess + case failClosed + case surfaceError + } + + /// Exact remote target captured before a workspace-create request suspends. + struct WorkspaceCreatePinnedContext { + let macDeviceID: String? + let client: MobileCoreRPCClient + let generation: UUID + let supportedHostCapabilities: Set + let hostDisplayName: String + + /// Whether the caller still exposes the same Mac, client, and generation. + func isCurrent( + macDeviceID currentMacDeviceID: String?, + client currentClient: MobileCoreRPCClient?, + generation currentGeneration: UUID + ) -> Bool { + macDeviceID == currentMacDeviceID + && client === currentClient + && generation == currentGeneration + } + + /// Settles a decoded host success without inviting an unsafe duplicate. + static func postResponseDisposition( + operationID: UUID?, + isCancelled: Bool, + isCurrent: Bool + ) -> WorkspaceCreatePostResponseDisposition { + guard isCancelled || !isCurrent else { return .apply } + return operationID == nil ? .preserveSuccess : .failClosed + } + + /// Settles a thrown create by what actually interrupted the request. + /// Ambient task cancellation can race delivery of a definite host error, + /// so it cannot classify that error as an ambiguous legacy create. + static func caughtErrorDisposition( + operationID: UUID?, + error: any Error + ) -> WorkspaceCreateCaughtErrorDisposition { + let isAmbiguous: Bool + switch error { + case is CancellationError, + MobileShellConnectionError.connectionClosed, + MobileShellConnectionError.requestTimedOut, + MobileShellConnectionError.transportWriteTimedOut, + MobileShellConnectionError.invalidResponse: + isAmbiguous = true + default: + isAmbiguous = false + } + guard isAmbiguous else { return .surfaceError } + return operationID == nil ? .preserveSuccess : .failClosed + } + } +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingStoreBuilders.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingStoreBuilders.swift new file mode 100644 index 000000000000..b6df9fe2b53c --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingStoreBuilders.swift @@ -0,0 +1,149 @@ +import CMUXMobileCore +import CmuxMobilePairedMac +import CmuxMobileRPC +import CmuxMobileShellModel +import Foundation +import Testing +@testable import CmuxMobileShell + +// Connected-store builders for the composer send-routing tests, split from +// ComposerSubmitRoutingTestSupport.swift (Swift file length threshold). + +/// Build a store with a workspace of two terminals (term-a selected) and a real +/// `MobileCoreRPCClient` wired DIRECTLY onto the store, backed by the recording +/// transport. This deliberately bypasses the pairing/connect handshake (which +/// the scripted-host harness cannot complete in this environment): the composer +/// send path only needs a live `remoteClient` to reach the wire, and the +/// session connects its transport lazily on the first request. The result is a +/// deterministic end-to-end exercise of submitComposer's routing over the real +/// terminal.paste / terminal.paste_image RPC frames. +@MainActor +func makeRoutingConnectedStore( + router: RoutingHostRouter, + pendingDismissQueue: PendingNotificationDismissQueue = PendingNotificationDismissQueue( + defaults: UserDefaults(suiteName: "routing-dismiss-\(UUID().uuidString)")! + ), + macScopedWorkspaceMutations: Bool = false, + hostCapabilities: Set = ["workspace.task_create.v1"], + pairedMacStore: (any MobilePairedMacStoring)? = nil +) async throws -> MobileShellComposite { + let runtime = RoutingTestRuntime( + transportFactory: RoutingTransportFactory(router: router) + ) + let terminals = [ + MobileTerminalPreview(id: .init(rawValue: RoutingHostRouter.terminalA), name: "A"), + MobileTerminalPreview(id: .init(rawValue: RoutingHostRouter.terminalB), name: "B"), + ] + let store = MobileShellComposite( + runtime: runtime, + isSignedIn: true, + connectionState: .connected, + workspaces: [ + MobileWorkspacePreview( + id: .init(rawValue: RoutingHostRouter.workspaceID), + name: "Routing Workspace", + terminals: terminals + ), + ], + pairedMacStore: pairedMacStore, + identityProvider: StaticIdentityProvider(userID: "routing-user"), + pendingDismissQueue: pendingDismissQueue + ) + // 127.0.0.1 is a Stack-auth-trusted route, so authorized requests carry the + // Stack token and do not throw insecureManualRoute before reaching the + // transport. Enable the fallback to match the trusted-route production path. + let route = try CmxAttachRoute( + id: "debug_loopback", + kind: .debugLoopback, + endpoint: .hostPort(host: "127.0.0.1", port: 56585) + ) + let ticket = try CmxAttachTicket( + workspaceID: macScopedWorkspaceMutations ? "" : RoutingHostRouter.workspaceID, + terminalID: macScopedWorkspaceMutations ? nil : RoutingHostRouter.terminalA, + macDeviceID: "test-mac", + macDisplayName: "Test Mac", + routes: [route], + expiresAt: Date().addingTimeInterval(3600), + authToken: macScopedWorkspaceMutations ? "ticket-secret" : nil + ) + store.remoteClient = MobileCoreRPCClient( + runtime: runtime, + route: route, + ticket: ticket, + allowsStackAuthFallback: true + ) + store.foregroundMacDeviceID = "test-mac" + store.supportedHostCapabilities = hostCapabilities + return store +} + +/// Install a fresh `remoteClient` on an already-built store, backed by `router`. +/// Models the new transport a reconnect / account switch / Mac switch installs: +/// the mid-submit identity guard must abort BEFORE any further image or the text +/// reaches this second router, so a test can assert that router recorded nothing. +@MainActor +func installFreshRemoteClient(on store: MobileShellComposite, router: RoutingHostRouter) throws { + let runtime = RoutingTestRuntime( + transportFactory: RoutingTransportFactory(router: router) + ) + let route = try CmxAttachRoute( + id: "debug_loopback", + kind: .debugLoopback, + endpoint: .hostPort(host: "127.0.0.1", port: 56586) + ) + let ticket = try CmxAttachTicket( + workspaceID: RoutingHostRouter.workspaceID, + terminalID: RoutingHostRouter.terminalA, + macDeviceID: "test-mac-2", + macDisplayName: "Test Mac 2", + routes: [route], + expiresAt: Date().addingTimeInterval(3600) + ) + store.remoteClient = MobileCoreRPCClient( + runtime: runtime, + route: route, + ticket: ticket, + allowsStackAuthFallback: true + ) + store.foregroundMacDeviceID = "test-mac-2" +} + +/// Install a live read-only secondary client on `store`, backed by `router`. +@MainActor +func installSecondaryClient( + on store: MobileShellComposite, + macDeviceID: String, + router: RoutingHostRouter, + supportedHostCapabilities: Set = [] +) throws { + let runtime = RoutingTestRuntime( + transportFactory: RoutingTransportFactory(router: router) + ) + let route = try CmxAttachRoute( + id: "debug_loopback_\(macDeviceID)", + kind: .debugLoopback, + endpoint: .hostPort(host: "127.0.0.1", port: 56587) + ) + let ticket = try CmxAttachTicket( + workspaceID: RoutingHostRouter.workspaceID, + terminalID: RoutingHostRouter.terminalA, + macDeviceID: macDeviceID, + macDisplayName: macDeviceID, + routes: [route], + expiresAt: Date().addingTimeInterval(3600) + ) + let client = MobileCoreRPCClient( + runtime: runtime, + route: route, + ticket: ticket, + allowsStackAuthFallback: true + ) + store.secondaryMacSubscriptions[macDeviceID] = SecondaryMacSubscription( + macDeviceID: macDeviceID, + client: client, + route: route, + ticket: ticket, + supportedHostCapabilities: supportedHostCapabilities, + actionCapabilities: .none + ) +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTestSupport.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTestSupport.swift index 550aa6cde020..744392ce8998 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTestSupport.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTestSupport.swift @@ -13,7 +13,7 @@ import Testing // MARK: - Runtime double -private struct RoutingTestRuntime: MobileSyncRuntime { +struct RoutingTestRuntime: MobileSyncRuntime { var transportFactory: any CmxByteTransportFactory var stackAccessTokenProvider: @Sendable () async throws -> String = { "test-stack-token" } var stackAccessTokenForceRefresher: @Sendable () async throws -> String = { "test-stack-token" } @@ -40,10 +40,19 @@ actor RoutingHostRouter { var surfaceID: String var text: String } + struct WorkspaceCreateRecord: Sendable, Equatable { + var groupID: String? + var title: String? + var workingDirectory: String? + var initialCommand: String? + var initialEnv: [String: String]? + var operationID: String? = nil + } private(set) var pasteImages: [PasteImageRecord] = [] private(set) var pastes: [PasteRecord] = [] + private(set) var directorySearchQueries: [String] = [] private(set) var dismisses: [(notificationIDs: [String], clientID: String?)] = [] - private var workspaceCreateGroupIDs: [String?] = [] + private var workspaceCreates: [WorkspaceCreateRecord] = [] /// Reject the Nth (0-based) and later paste_image requests; `nil` accepts all. private var rejectPasteImageFromIndex: Int? private var holdFirstPasteImage = false @@ -51,7 +60,14 @@ actor RoutingHostRouter { private var firstPasteImageContinuation: CheckedContinuation? private var firstPasteImageReachedWaiters: [CheckedContinuation] = [] private var workspaceCreateCount = 0 + private var hostCapabilities = ["workspace.task_create.v1"] private var rejectWorkspaceCreate = false + private var workspaceCreateError: (code: String?, message: String)? + private var workspaceCreateResponseCreatedWorkspaceID: String? = "workspace-created" + private var workspaceCreateResponseIncludesCreatedWorkspace = true + private(set) var directoryListRequests: [(path: String, offset: Int, limit: Int)] = [] + private var directoryListError: (code: String?, message: String)? + private var directorySearchError: (code: String?, message: String)? private var holdFirstWorkspaceCreate = false private var firstWorkspaceCreateHeld = false private var firstWorkspaceCreateContinuation: CheckedContinuation? @@ -98,6 +114,30 @@ actor RoutingHostRouter { rejectWorkspaceCreate = reject } + func setWorkspaceCreateError(code: String?, message: String) { + workspaceCreateError = (code, message) + } + + func setWorkspaceCreateResponse( + createdWorkspaceID: String?, + includesCreatedWorkspace: Bool = true + ) { + workspaceCreateResponseCreatedWorkspaceID = createdWorkspaceID + workspaceCreateResponseIncludesCreatedWorkspace = includesCreatedWorkspace + } + + func setDirectorySearchError(code: String?, message: String) { + directorySearchError = (code, message) + } + + func setDirectoryListError(code: String?, message: String) { + directoryListError = (code, message) + } + + func setHostCapabilities(_ capabilities: [String]) { + hostCapabilities = capabilities + } + func setHoldFirstWorkspaceCreate(_ hold: Bool) { holdFirstWorkspaceCreate = hold } @@ -114,10 +154,15 @@ actor RoutingHostRouter { } func recordedWorkspaceCreateCount() -> Int { workspaceCreateCount } - func recordedWorkspaceCreateGroupIDs() -> [String?] { workspaceCreateGroupIDs } + func recordedWorkspaceCreateGroupIDs() -> [String?] { workspaceCreates.map(\.groupID) } + func recordedWorkspaceCreates() -> [WorkspaceCreateRecord] { workspaceCreates } func recordedPasteImages() -> [PasteImageRecord] { pasteImages } func recordedPastes() -> [PasteRecord] { pastes } + func recordedDirectorySearchQueries() -> [String] { directorySearchQueries } + func recordedDirectoryListRequests() -> [(path: String, offset: Int, limit: Int)] { + directoryListRequests + } func recordedDismisses() -> [(notificationIDs: [String], clientID: String?)] { dismisses } /// Sendable extract of the request fields the router needs, pulled off the @@ -131,6 +176,15 @@ actor RoutingHostRouter { var notificationIDs: [String]? var clientID: String? var groupID: String? + var title: String? + var workingDirectory: String? + var initialCommand: String? + var initialEnv: [String: String]? + var operationID: String? + var query: String? + var directoryPath: String? + var directoryOffset: Int? + var directoryLimit: Int? } func response(_ info: RequestInfo) async -> Data? { @@ -167,12 +221,7 @@ actor RoutingHostRouter { case "mobile.host.status": return try? Self.resultFrame(id: id, result: [ "terminal_fidelity": "render_grid", - "capabilities": [ - "events.v1", - "terminal.render_grid.v1", - "terminal.replay.v1", - "workspace.group_actions.v1", - ], + "capabilities": hostCapabilities, ]) case "mobile.events.subscribe": return try? Self.resultFrame(id: id, result: [ @@ -182,7 +231,14 @@ actor RoutingHostRouter { ]) case "workspace.create": workspaceCreateCount += 1 - workspaceCreateGroupIDs.append(info.groupID) + workspaceCreates.append(WorkspaceCreateRecord( + groupID: info.groupID, + title: info.title, + workingDirectory: info.workingDirectory, + initialCommand: info.initialCommand, + initialEnv: info.initialEnv, + operationID: info.operationID + )) if workspaceCreateCount == 1 && holdFirstWorkspaceCreate { firstWorkspaceCreateHeld = true let reachedWaiters = firstWorkspaceCreateReachedWaiters @@ -193,29 +249,100 @@ actor RoutingHostRouter { if rejectWorkspaceCreate { return try? Self.errorFrame(id: id, message: "workspace.create rejected") } - return try? Self.resultFrame(id: id, result: [ - "workspaces": [ - [ - "id": Self.workspaceID, - "title": "Routing Workspace", - "terminals": [], - ], - [ - "id": "workspace-created", - "title": "Created Workspace", - "is_selected": true, - "terminals": [ - [ - "id": "terminal-created", - "title": "Created", - "is_focused": true, - "is_ready": true, - ], + if let workspaceCreateError { + return try? Self.errorFrame( + id: id, + code: workspaceCreateError.code, + message: workspaceCreateError.message + ) + } + var workspaces: [[String: Any]] = [[ + "id": Self.workspaceID, + "title": "Routing Workspace", + "current_directory": "/tmp/route", + "is_selected": false, + "terminals": [], + ]] + if workspaceCreateResponseIncludesCreatedWorkspace { + workspaces.append([ + "id": "workspace-created", + "title": "Created Workspace", + "current_directory": "/tmp/created", + "is_selected": true, + "terminals": [ + [ + "id": "terminal-created", + "title": "Created", + "current_directory": "/tmp/created", + "is_focused": true, + "is_ready": true, ], ], - ], - "created_workspace_id": "workspace-created", + ]) + } + var result: [String: Any] = [ + "workspaces": workspaces, "created_terminal_id": "terminal-created", + ] + if let workspaceCreateResponseCreatedWorkspaceID { + result["created_workspace_id"] = workspaceCreateResponseCreatedWorkspaceID + } + return try? Self.resultFrame(id: id, result: result) + case "mobile.directory.search": + directorySearchQueries.append(info.query ?? "") + if let directorySearchError { + return try? Self.errorFrame( + id: id, + code: directorySearchError.code, + message: directorySearchError.message + ) + } + return try? Self.resultFrame(id: id, result: [ + "directories": [ + "/Users/test/Dev/Manaflow/cmux", + "/Users/test/Dev/Manaflow/cmuxterm-hq", + ], + ]) + case "mobile.directory.list": + let path = info.directoryPath ?? "" + let offset = info.directoryOffset ?? 0 + let limit = info.directoryLimit ?? 50 + directoryListRequests.append((path, offset, limit)) + if let directoryListError { + return try? Self.errorFrame( + id: id, + code: directoryListError.code, + message: directoryListError.message + ) + } + let allEntries: [[String: Any]] = [ + [ + "name": ".hidden", + "path": "/Users/test/.hidden", + "is_hidden": true, + "is_package": false, + "is_symbolic_link": false, + "is_readable": true, + ], + [ + "name": "Projects", + "path": "/Users/test/Projects", + "is_hidden": false, + "is_package": false, + "is_symbolic_link": false, + "is_readable": true, + ], + ] + let boundedOffset = min(max(offset, 0), allEntries.count) + let end = min(boundedOffset + max(limit, 0), allEntries.count) + return try? Self.resultFrame(id: id, result: [ + "current_path": "/Users/test", + "parent_path": "/Users", + "entries": Array(allEntries[boundedOffset.. Data { + private static func errorFrame(id: String?, code: String? = nil, message: String) throws -> Data { + var error: [String: Any] = ["message": message] + if let code { + error["code"] = code + } let envelope: [String: Any] = [ "id": id ?? UUID().uuidString, "ok": false, - "error": ["message": message], + "error": error, ] return try MobileSyncFrameCodec.encodeFrame(JSONSerialization.data(withJSONObject: envelope)) } } -private struct RoutingTransportFactory: CmxByteTransportFactory { +struct RoutingTransportFactory: CmxByteTransportFactory { let router: RoutingHostRouter func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport { @@ -318,7 +449,16 @@ private actor RoutingTransport: CmxByteTransport { text: params?["text"] as? String, notificationIDs: params?["notification_ids"] as? [String], clientID: params?["client_id"] as? String, - groupID: params?["group_id"] as? String + groupID: params?["group_id"] as? String, + title: params?["title"] as? String, + workingDirectory: params?["working_directory"] as? String, + initialCommand: params?["initial_command"] as? String, + initialEnv: params?["initial_env"] as? [String: String], + operationID: params?["operation_id"] as? String, + query: params?["query"] as? String, + directoryPath: params?["path"] as? String, + directoryOffset: params?["offset"] as? Int, + directoryLimit: params?["limit"] as? Int ) Task { [router, weak self] in guard let response = await router.response(info) else { @@ -347,137 +487,3 @@ private actor RoutingTransport: CmxByteTransport { waiter.resume(returning: frame) } } - -// MARK: - Connected-store builder - -/// Build a store with a workspace of two terminals (term-a selected) and a real -/// `MobileCoreRPCClient` wired DIRECTLY onto the store, backed by the recording -/// transport. This deliberately bypasses the pairing/connect handshake (which -/// the scripted-host harness cannot complete in this environment): the composer -/// send path only needs a live `remoteClient` to reach the wire, and the -/// session connects its transport lazily on the first request. The result is a -/// deterministic end-to-end exercise of submitComposer's routing over the real -/// terminal.paste / terminal.paste_image RPC frames. -@MainActor -func makeRoutingConnectedStore( - router: RoutingHostRouter, - pendingDismissQueue: PendingNotificationDismissQueue = PendingNotificationDismissQueue( - defaults: UserDefaults(suiteName: "routing-dismiss-\(UUID().uuidString)")! - ), - macScopedWorkspaceMutations: Bool = false -) async throws -> MobileShellComposite { - let runtime = RoutingTestRuntime( - transportFactory: RoutingTransportFactory(router: router) - ) - let terminals = [ - MobileTerminalPreview(id: .init(rawValue: RoutingHostRouter.terminalA), name: "A"), - MobileTerminalPreview(id: .init(rawValue: RoutingHostRouter.terminalB), name: "B"), - ] - let store = MobileShellComposite( - runtime: runtime, - isSignedIn: true, - workspaces: [ - MobileWorkspacePreview( - id: .init(rawValue: RoutingHostRouter.workspaceID), - name: "Routing Workspace", - terminals: terminals - ), - ], - pendingDismissQueue: pendingDismissQueue - ) - // 127.0.0.1 is a Stack-auth-trusted route, so authorized requests carry the - // Stack token and do not throw insecureManualRoute before reaching the - // transport. Enable the fallback to match the trusted-route production path. - let route = try CmxAttachRoute( - id: "debug_loopback", - kind: .debugLoopback, - endpoint: .hostPort(host: "127.0.0.1", port: 56585) - ) - let ticket = try CmxAttachTicket( - workspaceID: macScopedWorkspaceMutations ? "" : RoutingHostRouter.workspaceID, - terminalID: macScopedWorkspaceMutations ? nil : RoutingHostRouter.terminalA, - macDeviceID: "test-mac", - macDisplayName: "Test Mac", - routes: [route], - expiresAt: Date().addingTimeInterval(3600), - authToken: macScopedWorkspaceMutations ? "ticket-secret" : nil - ) - store.remoteClient = MobileCoreRPCClient( - runtime: runtime, - route: route, - ticket: ticket, - allowsStackAuthFallback: true - ) - store.foregroundMacDeviceID = "test-mac" - return store -} - -/// Install a fresh `remoteClient` on an already-built store, backed by `router`. -/// Models the new transport a reconnect / account switch / Mac switch installs: -/// the mid-submit identity guard must abort BEFORE any further image or the text -/// reaches this second router, so a test can assert that router recorded nothing. -@MainActor -func installFreshRemoteClient(on store: MobileShellComposite, router: RoutingHostRouter) throws { - let runtime = RoutingTestRuntime( - transportFactory: RoutingTransportFactory(router: router) - ) - let route = try CmxAttachRoute( - id: "debug_loopback", - kind: .debugLoopback, - endpoint: .hostPort(host: "127.0.0.1", port: 56586) - ) - let ticket = try CmxAttachTicket( - workspaceID: RoutingHostRouter.workspaceID, - terminalID: RoutingHostRouter.terminalA, - macDeviceID: "test-mac-2", - macDisplayName: "Test Mac 2", - routes: [route], - expiresAt: Date().addingTimeInterval(3600) - ) - store.remoteClient = MobileCoreRPCClient( - runtime: runtime, - route: route, - ticket: ticket, - allowsStackAuthFallback: true - ) - store.foregroundMacDeviceID = "test-mac-2" -} - -/// Install a live read-only secondary client on `store`, backed by `router`. -@MainActor -func installSecondaryClient( - on store: MobileShellComposite, - macDeviceID: String, - router: RoutingHostRouter -) throws { - let runtime = RoutingTestRuntime( - transportFactory: RoutingTransportFactory(router: router) - ) - let route = try CmxAttachRoute( - id: "debug_loopback_\(macDeviceID)", - kind: .debugLoopback, - endpoint: .hostPort(host: "127.0.0.1", port: 56587) - ) - let ticket = try CmxAttachTicket( - workspaceID: RoutingHostRouter.workspaceID, - terminalID: RoutingHostRouter.terminalA, - macDeviceID: macDeviceID, - macDisplayName: macDeviceID, - routes: [route], - expiresAt: Date().addingTimeInterval(3600) - ) - let client = MobileCoreRPCClient( - runtime: runtime, - route: route, - ticket: ticket, - allowsStackAuthFallback: true - ) - store.secondaryMacSubscriptions[macDeviceID] = SecondaryMacSubscription( - macDeviceID: macDeviceID, - client: client, - route: route, - ticket: ticket, - supportedHostCapabilities: [], - actionCapabilities: .none - ) -} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DelayedTeamPairedMacStore.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DelayedTeamPairedMacStore.swift index 721e395ee656..a20ef85672a2 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DelayedTeamPairedMacStore.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DelayedTeamPairedMacStore.swift @@ -8,7 +8,7 @@ actor DelayedTeamPairedMacStore: MobilePairedMacStoring, PairedMacBackupRefreshi private let blockedTeams: Set private var startedTeams: Set = [] private var startWaiters: [String: [CheckedContinuation]] = [:] - private var blockers: [String: CheckedContinuation] = [:] + private var blockers: [String: [CheckedContinuation]] = [:] private var upsertCount = 0 private var loadAllCount = 0 private var recordReplacement: ( @@ -151,7 +151,7 @@ actor DelayedTeamPairedMacStore: MobilePairedMacStoring, PairedMacBackupRefreshi markStarted(key) if blockedTeams.contains(key) { await withCheckedContinuation { continuation in - blockers[key] = continuation + blockers[key, default: []].append(continuation) } } let result: [MobilePairedMac] @@ -262,7 +262,14 @@ actor DelayedTeamPairedMacStore: MobilePairedMacStoring, PairedMacBackupRefreshi func release(teamID: String?) { let key = teamID ?? "" - blockers.removeValue(forKey: key)?.resume() + guard var queued = blockers[key], !queued.isEmpty else { return } + let blocker = queued.removeFirst() + if queued.isEmpty { + blockers.removeValue(forKey: key) + } else { + blockers[key] = queued + } + blocker.resume() } func waitUntilUpsertCount(_ count: Int) async { diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacInstanceConnectionAuthorityTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacInstanceConnectionAuthorityTests.swift index e5cc19066e73..81b740656607 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacInstanceConnectionAuthorityTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacInstanceConnectionAuthorityTests.swift @@ -100,6 +100,7 @@ import Testing #expect(mac.instanceTag == nil) #expect(mac.displayName == "Legacy Studio") #expect(!mac.routes.isEmpty) + #expect(shell.displayPairedMacs.map(\.macDeviceID) == ["test-mac"]) } @Test func explicitRegistryBCommitsOnlyAfterExactStatusAndDropsARouteFallbacks() async throws { diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCreateTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCreateTests.swift index d19a93a46b9b..4d51019f9eb2 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCreateTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCreateTests.swift @@ -1,3 +1,5 @@ +import CmuxMobileRPC +import Foundation import Testing @testable import CmuxMobileShell @@ -65,6 +67,130 @@ import Testing #expect(store.connectionErrorGuidance == nil) } + @Test func specLessCreateReturnsSuccessWhenConnectionChangesAfterHostCreatedWorkspace() async throws { + let router = RoutingHostRouter() + await router.setHoldFirstWorkspaceCreate(true) + let store = try await makeRoutingConnectedStore(router: router) + + let create = Task { @MainActor in + await store.createWorkspaceRequest() + } + await router.awaitFirstWorkspaceCreateReached() + store.connectionGeneration = UUID() + await router.releaseFirstWorkspaceCreate() + let result = await create.value + + guard case .success = result else { + return #expect(Bool(false), "accepted legacy create must not invite a duplicate retry") + } + #expect(await router.recordedWorkspaceCreateCount() == 1) + } + + @Test func nonIdempotentSpecCreateReturnsSuccessAfterHostCreatedWorkspace() async throws { + let router = RoutingHostRouter() + await router.setHoldFirstWorkspaceCreate(true) + let store = try await makeRoutingConnectedStore(router: router) + + let create = Task { @MainActor in + await store.createWorkspaceRequest(spec: MobileWorkspaceCreateSpec(title: "Legacy titled create")) + } + await router.awaitFirstWorkspaceCreateReached() + store.connectionGeneration = UUID() + await router.releaseFirstWorkspaceCreate() + let result = await create.value + + guard case .success = result else { + return #expect(Bool(false), "accepted non-idempotent create must not invite a duplicate retry") + } + #expect(await router.recordedWorkspaceCreateCount() == 1) + } + + @Test func specLessDecodedSuccessSurvivesCancellation() { + let spec: MobileWorkspaceCreateSpec? = nil + + let disposition = MobileShellComposite.WorkspaceCreatePinnedContext.postResponseDisposition( + operationID: spec?.operationID, + isCancelled: true, + isCurrent: true + ) + + #expect(disposition == .preserveSuccess) + } + + @Test func nonIdempotentSpecDecodedSuccessSurvivesCancellation() { + let spec = MobileWorkspaceCreateSpec(title: "Legacy titled create") + + let disposition = MobileShellComposite.WorkspaceCreatePinnedContext.postResponseDisposition( + operationID: spec.operationID, + isCancelled: true, + isCurrent: true + ) + + #expect(disposition == .preserveSuccess) + } + + @Test func specLessCreateCancellationAfterSendDoesNotInviteDuplicateRetry() async throws { + let router = RoutingHostRouter() + await router.setHoldFirstWorkspaceCreate(true) + let store = try await makeRoutingConnectedStore(router: router) + + let create = Task { @MainActor in + await store.createWorkspaceRequest() + } + await router.awaitFirstWorkspaceCreateReached() + store.cancelRemoteOperationTasks() + await router.releaseFirstWorkspaceCreate() + let result = await create.value + + guard case .success = result else { + return #expect(Bool(false), "ambiguous legacy create must not invite a duplicate retry") + } + #expect(await router.recordedWorkspaceCreateCount() == 1) + } + + @Test func specLessCreateCancellationRacingHostRejectionStillSurfacesFailure() async { + let disposition = await Task { + withUnsafeCurrentTask { $0?.cancel() } + #expect(Task.isCancelled) + return MobileShellComposite.WorkspaceCreatePinnedContext.caughtErrorDisposition( + operationID: nil, + error: MobileWorkspaceMutationFailure.rejected(hostDisplayName: "Test Mac") + ) + }.value + + #expect(disposition == .surfaceError) + } + + @Test func ambiguousLegacyTransportFailuresPreserveAtMostOnceSuccess() { + for error in [ + MobileShellConnectionError.connectionClosed, + MobileShellConnectionError.requestTimedOut, + MobileShellConnectionError.invalidResponse, + ] { + #expect( + MobileShellComposite.WorkspaceCreatePinnedContext.caughtErrorDisposition( + operationID: nil, + error: error + ) == .preserveSuccess + ) + #expect( + MobileShellComposite.WorkspaceCreatePinnedContext.caughtErrorDisposition( + operationID: UUID(), + error: error + ) == .failClosed + ) + } + } + + @Test func definiteLegacyHostFailureStillSurfaces() { + #expect( + MobileShellComposite.WorkspaceCreatePinnedContext.caughtErrorDisposition( + operationID: nil, + error: MobileShellConnectionError.rpcError("rejected", "Host rejected create") + ) == .surfaceError + ) + } + @Test func differentGroupCreateWorkspaceRequestDoesNotJoinInFlightResult() async throws { let router = RoutingHostRouter() await router.setHoldFirstWorkspaceCreate(true) diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskComposerCancellationTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskComposerCancellationTests.swift new file mode 100644 index 000000000000..02cc6c485dee --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskComposerCancellationTests.swift @@ -0,0 +1,138 @@ +import CmuxMobilePairedMac +import Foundation +import Testing +@testable import CmuxMobileShell + +@MainActor +@Suite struct MobileTaskComposerCancellationTests { + @Test func cancellationInvalidatesOwnedMacSwitchBeforeLateSecondaryPromotion() async throws { + let pairedStore = DelayedTeamPairedMacStore( + recordsByTeam: ["": [Self.pairedMac(macDeviceID: "secondary-current")]], + blockedTeams: [""] + ) + let targetRouter = RoutingHostRouter() + let store = try await makeRoutingConnectedStore( + router: RoutingHostRouter(), + pairedMacStore: pairedStore + ) + try installSecondaryClient( + on: store, + macDeviceID: "secondary-current", + router: targetRouter, + supportedHostCapabilities: ["workspace.task_create.v1"] + ) + var boundaryCallCount = 0 + + let submission = Task { @MainActor in + await store.submitTaskComposer( + macDeviceID: "secondary-current", + spec: MobileWorkspaceCreateSpec(title: "Cancelled", operationID: UUID()), + willStartCreate: { boundaryCallCount += 1 } + ) + } + let firstAuthorityReadStarted = try await pollUntil { + await pairedStore.currentLoadAllCount() == 1 + } + #expect(firstAuthorityReadStarted) + await pairedStore.release(teamID: nil) + let finalAuthorityReadStarted = try await pollUntil { + await pairedStore.currentLoadAllCount() == 2 + } + #expect(finalAuthorityReadStarted) + + submission.cancel() + let ownedAttemptWasInvalidated = try await pollUntil { + !store.isMacSwitchInFlight + } + #expect(ownedAttemptWasInvalidated) + + await pairedStore.release(teamID: nil) + _ = await submission.value + + #expect(store.foregroundMacDeviceID == "test-mac") + #expect(boundaryCallCount == 0) + #expect(await targetRouter.recordedWorkspaceCreateCount() == 0) + } + + @Test func cancellingSupersededSubmissionDoesNotCancelNewerMacSwitch() async throws { + let pairedStore = DelayedTeamPairedMacStore( + recordsByTeam: ["": [ + Self.pairedMac(macDeviceID: "secondary-b"), + Self.pairedMac(macDeviceID: "secondary-c"), + ]], + blockedTeams: [""] + ) + let routerB = RoutingHostRouter() + let routerC = RoutingHostRouter() + let store = try await makeRoutingConnectedStore( + router: RoutingHostRouter(), + pairedMacStore: pairedStore + ) + try installSecondaryClient( + on: store, + macDeviceID: "secondary-b", + router: routerB, + supportedHostCapabilities: ["workspace.task_create.v1"] + ) + try installSecondaryClient( + on: store, + macDeviceID: "secondary-c", + router: routerC, + supportedHostCapabilities: ["workspace.task_create.v1"] + ) + var boundaryBCount = 0 + var boundaryCCount = 0 + + let submissionB = Task { @MainActor in + await store.submitTaskComposer( + macDeviceID: "secondary-b", + spec: MobileWorkspaceCreateSpec(title: "B", operationID: UUID()), + willStartCreate: { boundaryBCount += 1 } + ) + } + #expect(try await pollUntil { await pairedStore.currentLoadAllCount() == 1 }) + await pairedStore.release(teamID: nil) + #expect(try await pollUntil { await pairedStore.currentLoadAllCount() == 2 }) + + let submissionC = Task { @MainActor in + await store.submitTaskComposer( + macDeviceID: "secondary-c", + spec: MobileWorkspaceCreateSpec(title: "C", operationID: UUID()), + willStartCreate: { boundaryCCount += 1 } + ) + } + #expect(try await pollUntil { await pairedStore.currentLoadAllCount() == 3 }) + + submissionB.cancel() + #expect(store.isMacSwitchInFlight) + await pairedStore.release(teamID: nil) + _ = await submissionB.value + #expect(store.isMacSwitchInFlight) + + await pairedStore.release(teamID: nil) + #expect(try await pollUntil { await pairedStore.currentLoadAllCount() == 4 }) + await pairedStore.release(teamID: nil) + let resultC = await submissionC.value + + guard case .success = resultC else { + return #expect(Bool(false), "newer submission should retain its Mac-switch authority") + } + #expect(store.foregroundMacDeviceID == "secondary-c") + #expect(boundaryBCount == 0) + #expect(boundaryCCount == 1) + #expect(await routerB.recordedWorkspaceCreateCount() == 0) + #expect(await routerC.recordedWorkspaceCreateCount() == 1) + } + + private static func pairedMac(macDeviceID: String) -> MobilePairedMac { + MobilePairedMac( + macDeviceID: macDeviceID, + displayName: macDeviceID, + routes: [], + createdAt: Date(), + lastSeenAt: Date(), + isActive: false, + stackUserID: "routing-user" + ) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskComposerSubmitTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskComposerSubmitTests.swift new file mode 100644 index 000000000000..83fe987e413f --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskComposerSubmitTests.swift @@ -0,0 +1,567 @@ +import CmuxMobilePairedMac +import Foundation +import Testing +@testable import CmuxMobileShell + +@MainActor +@Suite struct MobileTaskComposerSubmitTests { + @Test func submitTaskComposerSendsWorkspaceCreateSpec() async throws { + let router = RoutingHostRouter() + let store = try await makeRoutingConnectedStore(router: router) + let operationID = UUID() + let spec = MobileWorkspaceCreateSpec( + title: "Fix login", + workingDirectory: "~/dev/cmux", + initialCommand: "codex 'Fix login'", + initialEnv: ["CMUX_TASK_PROMPT": "Fix login"], + operationID: operationID + ) + + let result = await store.submitTaskComposer(macDeviceID: "test-mac", spec: spec) + let records = await router.recordedWorkspaceCreates() + + guard case .success = result else { + return #expect(Bool(false), "task composer create should succeed, got \(String(describing: result)); records \(records)") + } + #expect(records == [ + RoutingHostRouter.WorkspaceCreateRecord( + groupID: nil, + title: "Fix login", + workingDirectory: "~/dev/cmux", + initialCommand: "codex 'Fix login'", + initialEnv: ["CMUX_TASK_PROMPT": "Fix login"], + operationID: operationID.uuidString + ) + ]) + let createdWorkspace = try #require( + store.workspaces.first(where: { $0.name == "Created Workspace" }) + ) + #expect(store.selectedWorkspaceID == createdWorkspace.id) + #expect(createdWorkspace.terminals.first?.isReady == true) + } + + @Test func taskComposerRejectsResponseWithoutCreatedWorkspaceID() async throws { + let router = RoutingHostRouter() + await router.setWorkspaceCreateResponse(createdWorkspaceID: nil) + let store = try await makeRoutingConnectedStore(router: router) + let originalWorkspaceIDs = store.workspaces.map(\.id) + let originalSelection = store.selectedWorkspaceID + + let result = await store.submitTaskComposer( + macDeviceID: "test-mac", + spec: MobileWorkspaceCreateSpec(title: "Task", operationID: UUID()) + ) + + guard case let .failure(.rejected(hostDisplayName)) = result else { + return #expect(Bool(false), "spec create must reject a response without created_workspace_id") + } + #expect(hostDisplayName == store.connectedHostName) + #expect(store.workspaces.map(\.id) == originalWorkspaceIDs) + #expect(store.selectedWorkspaceID == originalSelection) + } + + @Test func specCreateRejectsBlankCreatedWorkspaceID() async throws { + let router = RoutingHostRouter() + await router.setWorkspaceCreateResponse(createdWorkspaceID: " \n\t ") + let store = try await makeRoutingConnectedStore(router: router) + let originalWorkspaceIDs = store.workspaces.map(\.id) + let originalSelection = store.selectedWorkspaceID + + let result = await store.createWorkspaceRequest( + spec: MobileWorkspaceCreateSpec(title: "Task", operationID: UUID()) + ) + + guard case let .failure(.rejected(hostDisplayName)) = result else { + return #expect(Bool(false), "spec create must reject a blank created_workspace_id") + } + #expect(hostDisplayName == store.connectedHostName) + #expect(store.workspaces.map(\.id) == originalWorkspaceIDs) + #expect(store.selectedWorkspaceID == originalSelection) + } + + @Test func specCreateRejectsCreatedWorkspaceIDMissingFromReturnedList() async throws { + let router = RoutingHostRouter() + await router.setWorkspaceCreateResponse( + createdWorkspaceID: "workspace-created", + includesCreatedWorkspace: false + ) + let store = try await makeRoutingConnectedStore(router: router) + let originalWorkspaceIDs = store.workspaces.map(\.id) + let originalSelection = store.selectedWorkspaceID + + let result = await store.createWorkspaceRequest( + spec: MobileWorkspaceCreateSpec(title: "Task", operationID: UUID()) + ) + + guard case let .failure(.rejected(hostDisplayName)) = result else { + return #expect(Bool(false), "spec create must reject an ID absent from the returned workspace list") + } + #expect(hostDisplayName == store.connectedHostName) + #expect(store.workspaces.map(\.id) == originalWorkspaceIDs) + #expect(store.selectedWorkspaceID == originalSelection) + } + + @Test func specLessCreateStillAcceptsResponseWithoutCreatedWorkspaceID() async throws { + let router = RoutingHostRouter() + await router.setWorkspaceCreateResponse(createdWorkspaceID: nil) + let store = try await makeRoutingConnectedStore(router: router) + + let result = await store.createWorkspaceRequest() + + guard case .success = result else { + return #expect(Bool(false), "ordinary workspace create must preserve legacy response compatibility") + } + #expect(await router.recordedWorkspaceCreateCount() == 1) + } + + @Test func submitTaskComposerPreservesInitialCommandVerbatim() async throws { + let router = RoutingHostRouter() + let store = try await makeRoutingConnectedStore(router: router) + let initialCommand = " \nprintf ' preserved '\n " + + let result = await store.submitTaskComposer( + macDeviceID: "test-mac", + spec: MobileWorkspaceCreateSpec( + initialCommand: initialCommand, + operationID: UUID() + ) + ) + + guard case .success = result else { + return #expect(Bool(false), "task composer create should succeed") + } + #expect(await router.recordedWorkspaceCreates().map(\.initialCommand) == [initialCommand]) + } + + @Test func submitTaskComposerOmitsWhitespaceOnlyInitialCommand() async throws { + let router = RoutingHostRouter() + let store = try await makeRoutingConnectedStore(router: router) + + let result = await store.submitTaskComposer( + macDeviceID: "test-mac", + spec: MobileWorkspaceCreateSpec( + initialCommand: " \n\t ", + operationID: UUID() + ) + ) + + guard case .success = result else { + return #expect(Bool(false), "task composer create should succeed") + } + #expect(await router.recordedWorkspaceCreates().map(\.initialCommand) == [nil]) + } + + @Test func taskComposerFailsClosedBeforeCreateWhenForegroundMacLacksCapability() async throws { + let router = RoutingHostRouter() + let store = try await makeRoutingConnectedStore(router: router, hostCapabilities: []) + + let result = await store.submitTaskComposer( + macDeviceID: "test-mac", + spec: MobileWorkspaceCreateSpec(title: "Unsupported", operationID: UUID()) + ) + + guard case .failure(.unsupported) = result else { + return #expect(Bool(false), "old Mac should fail closed, got \(String(describing: result))") + } + #expect(await router.recordedWorkspaceCreateCount() == 0) + } + + @Test func promotedSecondaryMacUsesItsOwnTaskCreateCapability() async throws { + let pairedStore = DelayedTeamPairedMacStore( + recordsByTeam: ["": [ + Self.pairedMac(macDeviceID: "secondary-old"), + Self.pairedMac(macDeviceID: "secondary-current"), + ]], + blockedTeams: [] + ) + let unsupportedRouter = RoutingHostRouter() + let unsupportedStore = try await makeRoutingConnectedStore( + router: RoutingHostRouter(), + pairedMacStore: pairedStore + ) + try installSecondaryClient( + on: unsupportedStore, + macDeviceID: "secondary-old", + router: unsupportedRouter, + supportedHostCapabilities: [] + ) + + let unsupported = await unsupportedStore.submitTaskComposer( + macDeviceID: "secondary-old", + spec: MobileWorkspaceCreateSpec(title: "Old Mac", operationID: UUID()) + ) + + guard case .failure(.unsupported) = unsupported else { + return #expect(Bool(false), "promoted old Mac should fail closed") + } + #expect(await unsupportedRouter.recordedWorkspaceCreateCount() == 0) + + let currentRouter = RoutingHostRouter() + let currentStore = try await makeRoutingConnectedStore( + router: RoutingHostRouter(), + pairedMacStore: pairedStore + ) + try installSecondaryClient( + on: currentStore, + macDeviceID: "secondary-current", + router: currentRouter, + supportedHostCapabilities: ["workspace.task_create.v1"] + ) + + let current = await currentStore.submitTaskComposer( + macDeviceID: "secondary-current", + spec: MobileWorkspaceCreateSpec(title: "Current Mac", operationID: UUID()) + ) + + guard case .success = current else { + return #expect(Bool(false), "promoted current Mac should create, got \(String(describing: current))") + } + #expect(await currentRouter.recordedWorkspaceCreateCount() == 1) + } + + @Test func taskComposerDoesNotArmCreateBoundaryBeforeTargetCapabilityCheck() async throws { + let pairedStore = DelayedTeamPairedMacStore( + recordsByTeam: ["": [Self.pairedMac(macDeviceID: "secondary-old")]], + blockedTeams: [] + ) + let store = try await makeRoutingConnectedStore( + router: RoutingHostRouter(), + pairedMacStore: pairedStore + ) + let targetRouter = RoutingHostRouter() + try installSecondaryClient( + on: store, + macDeviceID: "secondary-old", + router: targetRouter, + supportedHostCapabilities: [] + ) + var boundaryCallCount = 0 + + let result = await store.submitTaskComposer( + macDeviceID: "secondary-old", + spec: MobileWorkspaceCreateSpec(title: "Unsupported", operationID: UUID()), + willStartCreate: { boundaryCallCount += 1 } + ) + + guard case .failure(.unsupported) = result else { + return #expect(Bool(false), "old Mac should fail before the create boundary") + } + #expect(boundaryCallCount == 0) + #expect(await targetRouter.recordedWorkspaceCreateCount() == 0) + } + + @Test func taskComposerArmsCreateBoundaryAfterTargetMacPromotion() async throws { + let pairedStore = DelayedTeamPairedMacStore( + recordsByTeam: ["": [Self.pairedMac(macDeviceID: "secondary-current")]], + blockedTeams: [] + ) + let store = try await makeRoutingConnectedStore( + router: RoutingHostRouter(), + pairedMacStore: pairedStore + ) + let targetRouter = RoutingHostRouter() + try installSecondaryClient( + on: store, + macDeviceID: "secondary-current", + router: targetRouter, + supportedHostCapabilities: ["workspace.task_create.v1"] + ) + var boundarySnapshots: [(macDeviceID: String?, workspaceIDs: Set)] = [] + + let result = await store.submitTaskComposer( + macDeviceID: "secondary-current", + spec: MobileWorkspaceCreateSpec(title: "Current", operationID: UUID()), + willStartCreate: { + boundarySnapshots.append(( + macDeviceID: store.foregroundMacDeviceID, + workspaceIDs: Set(store.workspaces.map { $0.rpcWorkspaceID.rawValue }) + )) + } + ) + + guard case .success = result else { + return #expect(Bool(false), "current Mac should create after promotion") + } + #expect(boundarySnapshots.count == 1) + #expect(boundarySnapshots.first?.macDeviceID == "secondary-current") + #expect(boundarySnapshots.first?.workspaceIDs.contains(RoutingHostRouter.workspaceID) == true) + #expect(await targetRouter.recordedWorkspaceCreateCount() == 1) + } + + @Test func cancelledSubmissionDoesNotCrossCreateBoundary() async throws { + let router = RoutingHostRouter() + let store = try await makeRoutingConnectedStore(router: router) + var boundaryCallCount = 0 + + let submission = Task { @MainActor in + withUnsafeCurrentTask { task in + task?.cancel() + } + return await store.submitTaskComposer( + macDeviceID: "test-mac", + spec: MobileWorkspaceCreateSpec(title: "Cancelled", operationID: UUID()), + willStartCreate: { boundaryCallCount += 1 } + ) + } + _ = await submission.value + + #expect(boundaryCallCount == 0) + #expect(await router.recordedWorkspaceCreateCount() == 0) + } + + @Test func pinnedContextRejectsAmbientReplacementClientAndGeneration() async throws { + let targetRouter = RoutingHostRouter() + let ambientRouter = RoutingHostRouter() + let store = try await makeRoutingConnectedStore(router: targetRouter) + let pinnedContext = try #require(store.captureWorkspaceCreateContext()) + + #expect(pinnedContext.isCurrent( + macDeviceID: store.foregroundMacDeviceID, + client: store.remoteClient, + generation: store.connectionGeneration + )) + + store.bumpConnectionGenerationForTesting() + try installFreshRemoteClient(on: store, router: ambientRouter) + + #expect(!pinnedContext.isCurrent( + macDeviceID: store.foregroundMacDeviceID, + client: store.remoteClient, + generation: store.connectionGeneration + )) + #expect(await targetRouter.recordedWorkspaceCreateCount() == 0) + #expect(await ambientRouter.recordedWorkspaceCreateCount() == 0) + } + + @Test func pinnedContextKeepsTargetCapabilitySnapshot() async throws { + let store = try await makeRoutingConnectedStore( + router: RoutingHostRouter(), + hostCapabilities: [] + ) + let pinnedContext = try #require(store.captureWorkspaceCreateContext()) + + store.supportedHostCapabilities = ["workspace.task_create.v1"] + + #expect(!pinnedContext.supportedHostCapabilities.contains("workspace.task_create.v1")) + } + + @Test func specLessCreateStillSendsOnlyGroupID() async throws { + let router = RoutingHostRouter() + let store = try await makeRoutingConnectedStore(router: router, macScopedWorkspaceMutations: true) + + let result = await store.createWorkspaceRequest(inGroup: "group-a") + let records = await router.recordedWorkspaceCreates() + + guard case .success = result else { + return #expect(Bool(false), "workspace create should succeed, got \(String(describing: result)); records \(records)") + } + #expect(records == [ + RoutingHostRouter.WorkspaceCreateRecord( + groupID: "group-a", + title: nil, + workingDirectory: nil, + initialCommand: nil, + initialEnv: nil + ) + ]) + } + + @Test func staleGenerationCreateFailureStillSurfacesFailure() async throws { + let router = RoutingHostRouter() + await router.setHoldFirstWorkspaceCreate(true) + await router.setRejectWorkspaceCreate(true) + let store = try await makeRoutingConnectedStore(router: router) + let spec = MobileWorkspaceCreateSpec(title: "Task") + + let create = Task { @MainActor in + await store.createWorkspaceRequest(spec: spec) + } + await router.awaitFirstWorkspaceCreateReached() + // The connection was replaced mid-flight (reconnect / Mac switch). The + // rejected create must still report failure: mapping it to success lets + // the composer dismiss and persist last-used defaults for a task that + // was never created. + store.connectionGeneration = UUID() + await router.releaseFirstWorkspaceCreate() + let result = await create.value + + guard case .failure = result else { + return #expect(Bool(false), "stale rejected create should surface failure, got \(String(describing: result))") + } + } + + @Test func idempotentCreateRejectsStaleSuccessfulResponseSoCallerCanRetrySafely() async throws { + let router = RoutingHostRouter() + await router.setHoldFirstWorkspaceCreate(true) + let store = try await makeRoutingConnectedStore(router: router) + let spec = MobileWorkspaceCreateSpec(title: "Task", operationID: UUID()) + + let create = Task { @MainActor in + await store.createWorkspaceRequest(spec: spec) + } + await router.awaitFirstWorkspaceCreateReached() + store.connectionGeneration = UUID() + await router.releaseFirstWorkspaceCreate() + let result = await create.value + + guard case .failure(.notConnected) = result else { + return #expect(Bool(false), "idempotent create should retry after a stale success") + } + #expect(await router.recordedWorkspaceCreateCount() == 1) + } + + @Test func idempotentDecodedSuccessFailsClosedAfterCancellation() { + let spec = MobileWorkspaceCreateSpec(title: "Task", operationID: UUID()) + + let disposition = MobileShellComposite.WorkspaceCreatePinnedContext.postResponseDisposition( + operationID: spec.operationID, + isCancelled: true, + isCurrent: true + ) + + #expect(disposition == .failClosed) + } + + @Test func specCreateDoesNotCoalesceWithInFlightCreate() async throws { + let router = RoutingHostRouter() + await router.setHoldFirstWorkspaceCreate(true) + let store = try await makeRoutingConnectedStore(router: router) + let spec = MobileWorkspaceCreateSpec(title: "Task") + + let firstCreate = Task { @MainActor in + await store.createWorkspaceRequest(spec: spec) + } + await router.awaitFirstWorkspaceCreateReached() + let secondResult = await store.createWorkspaceRequest(spec: spec) + + guard case .failure(.busy) = secondResult else { + await router.releaseFirstWorkspaceCreate() + _ = await firstCreate.value + return #expect(Bool(false), "spec create should not coalesce with an in-flight create") + } + + await router.releaseFirstWorkspaceCreate() + _ = await firstCreate.value + #expect(await router.recordedWorkspaceCreateCount() == 1) + } + + @Test func invalidWorkingDirectoryMapsToSpecificComposerFailure() async throws { + let router = RoutingHostRouter() + await router.setWorkspaceCreateError( + code: "invalid_working_directory", + message: "The requested folder cannot be used for this task." + ) + let store = try await makeRoutingConnectedStore(router: router) + + let result = await store.createWorkspaceRequest( + spec: MobileWorkspaceCreateSpec(workingDirectory: "/missing") + ) + + guard case let .failure(.invalidWorkingDirectory(hostDisplayName)) = result else { + return #expect(Bool(false), "working-directory rejection should map specifically") + } + #expect(hostDisplayName == store.connectedHostName) + } + + @Test func tombstonePersistenceFailureMapsToSpecificComposerFailure() async throws { + let router = RoutingHostRouter() + await router.setWorkspaceCreateError( + code: "persistence_failed", + message: "Workspace task could not be reserved safely" + ) + let store = try await makeRoutingConnectedStore(router: router) + + let result = await store.createWorkspaceRequest( + spec: MobileWorkspaceCreateSpec(title: "Task", operationID: UUID()) + ) + + guard case let .failure(.persistenceUnavailable(hostDisplayName)) = result else { + return #expect(Bool(false), "persistence failure should map specifically") + } + #expect(hostDisplayName == store.connectedHostName) + } + + @Test func completedOperationWithoutWorkspaceMapsToRecoveryFailure() async throws { + let router = RoutingHostRouter() + await router.setWorkspaceCreateError( + code: "already_completed", + message: "workspace.create operation already completed" + ) + let store = try await makeRoutingConnectedStore(router: router) + + let result = await store.createWorkspaceRequest( + spec: MobileWorkspaceCreateSpec(title: "Task", operationID: UUID()) + ) + + guard case let .failure(.alreadyCompleted(hostDisplayName)) = result else { + return #expect(Bool(false), "completed operation without a workspace needs recovery guidance") + } + #expect(hostDisplayName == store.connectedHostName) + } + + @Test func unrelatedInvalidParamsRemainsGenericRejection() async throws { + let router = RoutingHostRouter() + await router.setWorkspaceCreateError( + code: "invalid_params", + message: "group_id is required for group placement" + ) + let store = try await makeRoutingConnectedStore(router: router) + + let result = await store.createWorkspaceRequest( + spec: MobileWorkspaceCreateSpec(title: "Rejected") + ) + + guard case let .failure(.rejected(hostDisplayName)) = result else { + return #expect(Bool(false), "unrelated invalid_params should remain generic") + } + #expect(hostDisplayName == store.connectedHostName) + } + + @Test func hostDirectoryValidationTimeoutMapsToRetryableTimeoutFailure() async throws { + let router = RoutingHostRouter() + await router.setWorkspaceCreateError( + code: "request_timeout", + message: "working_directory validation timed out" + ) + let store = try await makeRoutingConnectedStore(router: router) + + let result = await store.createWorkspaceRequest( + spec: MobileWorkspaceCreateSpec(workingDirectory: "/slow") + ) + + guard case let .failure(.requestTimedOut(hostDisplayName)) = result else { + return #expect(Bool(false), "directory validation timeout should remain retryable") + } + #expect(hostDisplayName == store.connectedHostName) + } + + @Test func hostDirectoryValidationOverloadMapsToBusyFailure() async throws { + let router = RoutingHostRouter() + await router.setWorkspaceCreateError( + code: "busy", + message: "working_directory validation is busy" + ) + let store = try await makeRoutingConnectedStore(router: router) + + let result = await store.createWorkspaceRequest( + spec: MobileWorkspaceCreateSpec(workingDirectory: "/queued") + ) + + guard case let .failure(.busy(hostDisplayName)) = result else { + return #expect(Bool(false), "directory validation overload should remain retryable") + } + #expect(hostDisplayName == store.connectedHostName) + } + + private static func pairedMac(macDeviceID: String) -> MobilePairedMac { + MobilePairedMac( + macDeviceID: macDeviceID, + displayName: macDeviceID, + routes: [], + createdAt: Date(), + lastSeenAt: Date(), + isActive: false, + stackUserID: "routing-user" + ) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskDirectoryListTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskDirectoryListTests.swift new file mode 100644 index 000000000000..79b27cf04991 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskDirectoryListTests.swift @@ -0,0 +1,77 @@ +import Testing +@testable import CmuxMobileShell + +@MainActor +@Suite struct MobileTaskDirectoryListTests { + @Test func browseSendsTypedPaginationAndDecodesEntries() async throws { + let router = RoutingHostRouter() + let store = try await makeRoutingConnectedStore(router: router) + + let result = await store.listTaskDirectories( + macDeviceID: "test-mac", + path: "~", + offset: 0, + limit: 1 + ) + + let page = try #require(result.successValue) + #expect(page.currentPath == "/Users/test") + #expect(page.entries.map(\.name) == [".hidden"]) + #expect(page.entries.first?.isHidden == true) + #expect(page.totalCount == 2) + #expect(page.nextOffset == 1) + let requests = await router.recordedDirectoryListRequests() + #expect(requests.count == 1) + #expect(requests.first?.path == "~") + #expect(requests.first?.offset == 0) + #expect(requests.first?.limit == 1) + } + + @Test func olderHostMapsMissingMethodToUnsupported() async throws { + let router = RoutingHostRouter() + await router.setDirectoryListError(code: "method_not_found", message: "Unknown method") + let store = try await makeRoutingConnectedStore(router: router) + + let result = await store.listTaskDirectories(macDeviceID: "test-mac", path: "~") + + #expect(result == .failure(.unsupported)) + } + + @Test( + arguments: [ + ("directory_not_found", MobileTaskDirectoryListFailure.notFound), + ("not_a_directory", MobileTaskDirectoryListFailure.notDirectory), + ("permission_denied", MobileTaskDirectoryListFailure.permissionDenied), + ("directory_unreadable", MobileTaskDirectoryListFailure.unreadable), + ] + ) + func mapsFilesystemErrors( + code: String, + expected: MobileTaskDirectoryListFailure + ) async throws { + let router = RoutingHostRouter() + await router.setDirectoryListError(code: code, message: code) + let store = try await makeRoutingConnectedStore(router: router) + + let result = await store.listTaskDirectories(macDeviceID: "test-mac", path: "~") + + #expect(result == .failure(expected)) + } + + @Test func rejectsRelativePathBeforeSendingRPC() async throws { + let router = RoutingHostRouter() + let store = try await makeRoutingConnectedStore(router: router) + + let result = await store.listTaskDirectories(macDeviceID: "test-mac", path: "relative") + + #expect(result == .failure(.invalidPath)) + #expect(await router.recordedDirectoryListRequests().isEmpty) + } +} + +private extension Result { + var successValue: Success? { + guard case let .success(value) = self else { return nil } + return value + } +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskDirectorySearchTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskDirectorySearchTests.swift new file mode 100644 index 000000000000..a50651b199e0 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskDirectorySearchTests.swift @@ -0,0 +1,52 @@ +import Testing +import CmuxMobileRPC +@testable import CmuxMobileShell + +@MainActor +@Suite struct MobileTaskDirectorySearchTests { + @Test func liveSearchDoesNotTrustStaleMissingCapabilityMetadata() async throws { + let router = RoutingHostRouter() + let store = try await makeRoutingConnectedStore( + router: router, + hostCapabilities: [] + ) + + let result = await store.searchTaskDirectories( + macDeviceID: "test-mac", + query: " cmux " + ) + + #expect(await router.recordedDirectorySearchQueries() == ["cmux"]) + #expect( + result == .success(MobileTaskDirectorySearchResponse( + directories: [ + "/Users/test/Dev/Manaflow/cmux", + "/Users/test/Dev/Manaflow/cmuxterm-hq", + ] + )) + ) + } + + @Test func liveSearchSurfacesTimeoutForRetry() async throws { + let router = RoutingHostRouter() + await router.setDirectorySearchError( + code: "request_timeout", + message: "Directory search timed out" + ) + let store = try await makeRoutingConnectedStore(router: router) + + let result = await store.searchTaskDirectories(macDeviceID: "test-mac", query: "cmux") + + #expect(result == .failure(.timedOut)) + } + + @Test func olderHostWithoutSearchMethodSurfacesUnsupportedSearch() async throws { + let router = RoutingHostRouter() + await router.setDirectorySearchError(code: "method_not_found", message: "Unknown method") + let store = try await makeRoutingConnectedStore(router: router) + + let result = await store.searchTaskDirectories(macDeviceID: "test-mac", query: "cmux") + + #expect(result == .failure(.unsupported)) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskTemplateStoreTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskTemplateStoreTests.swift new file mode 100644 index 000000000000..2ba690a0ff52 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskTemplateStoreTests.swift @@ -0,0 +1,441 @@ +import Foundation +import Testing +@testable import CmuxMobileShell +import CmuxMobileShellModel + +@MainActor +@Suite(.serialized) struct MobileTaskTemplateStoreTests { + @Test func firstListSeedsDefaultTemplatesOnce() { + let defaults = Self.defaults() + let store = UserDefaultsMobileTaskTemplateStore(defaults: defaults) + + #expect(store.listTemplates().map(\.name) == ["Claude", "Codex", "OpenCode", "Shell"]) + + store.deleteTemplate(id: store.listTemplates()[0].id) + let reloaded = UserDefaultsMobileTaskTemplateStore(defaults: defaults) + + #expect(reloaded.listTemplates().map(\.name) == ["Codex", "OpenCode", "Shell"]) + } + + @Test func seedingV4ClearsAbandonedV1V2AndV3Keys() { + let defaults = Self.defaults() + defaults.set(Data("stale".utf8), forKey: "cmux.mobile.taskTemplates.v1") + defaults.set(true, forKey: "cmux.mobile.taskTemplates.seeded.v1") + defaults.set(Data("stale".utf8), forKey: "cmux.mobile.taskTemplates.v2") + defaults.set(true, forKey: "cmux.mobile.taskTemplates.seeded.v2") + defaults.set(Data("stale".utf8), forKey: "cmux.mobile.taskTemplates.v3") + defaults.set(true, forKey: "cmux.mobile.taskTemplates.seeded.v3") + + let store = UserDefaultsMobileTaskTemplateStore(defaults: defaults) + #expect(store.listTemplates().count == 4) + #expect(defaults.object(forKey: "cmux.mobile.taskTemplates.v1") == nil) + #expect(defaults.object(forKey: "cmux.mobile.taskTemplates.seeded.v1") == nil) + #expect(defaults.object(forKey: "cmux.mobile.taskTemplates.v2") == nil) + #expect(defaults.object(forKey: "cmux.mobile.taskTemplates.seeded.v2") == nil) + #expect(defaults.object(forKey: "cmux.mobile.taskTemplates.v3") == nil) + #expect(defaults.object(forKey: "cmux.mobile.taskTemplates.seeded.v3") == nil) + } + + @Test func crudPersistsAcrossStoreInstances() { + let defaults = Self.defaults() + let store = UserDefaultsMobileTaskTemplateStore(defaults: defaults) + let custom = MobileTaskTemplate(name: "Build", icon: "hammer", command: "swift test", defaultDirectory: "~/dev") + + store.addTemplate(custom) + var updated = custom + updated.name = "Test" + updated.command = "swift test --parallel" + store.updateTemplate(updated) + + let reloaded = UserDefaultsMobileTaskTemplateStore(defaults: defaults) + #expect(reloaded.listTemplates().contains(updated)) + + reloaded.deleteTemplate(id: updated.id) + #expect(!UserDefaultsMobileTaskTemplateStore(defaults: defaults).listTemplates().contains(updated)) + } + + @Test func deletingAllTemplatesStaysEmptyAfterRelaunch() { + let defaults = Self.defaults() + let store = UserDefaultsMobileTaskTemplateStore(defaults: defaults) + + for template in store.listTemplates() { + store.deleteTemplate(id: template.id) + } + + #expect(store.listTemplates().isEmpty) + #expect(UserDefaultsMobileTaskTemplateStore(defaults: defaults).listTemplates().isEmpty) + } + + @Test func batchDeletionPersistsAndClearsTheLastSelection() throws { + let defaults = Self.defaults() + let store = UserDefaultsMobileTaskTemplateStore(defaults: defaults) + let templates = store.listTemplates() + let deletedIDs = Set(templates.prefix(2).map(\.id)) + let selectedID = try #require(deletedIDs.first) + store.setLastTemplateID(selectedID) + + store.deleteTemplates(ids: deletedIDs) + + #expect(Set(store.listTemplates().map(\.id)).isDisjoint(with: deletedIDs)) + #expect(store.listTemplates().count == templates.count - deletedIDs.count) + #expect(store.lastTemplateID() == nil) + #expect(UserDefaultsMobileTaskTemplateStore(defaults: defaults).listTemplates() == store.listTemplates()) + } + + @Test func lastUsedValuesRoundTrip() { + let defaults = Self.defaults() + let store = UserDefaultsMobileTaskTemplateStore(defaults: defaults) + let templateID = UUID() + + store.setLastTemplateID(templateID) + store.setLastMacDeviceID("mac-a") + store.setLastDirectory("~/work", macDeviceID: "mac-a") + store.setLastDirectory("/tmp/other", macDeviceID: "mac-b") + + let reloaded = UserDefaultsMobileTaskTemplateStore(defaults: defaults) + #expect(reloaded.lastTemplateID() == templateID) + #expect(reloaded.lastMacDeviceID() == "mac-a") + #expect(reloaded.lastDirectory(macDeviceID: "mac-a") == "~/work") + #expect(reloaded.lastDirectory(macDeviceID: "mac-b") == "/tmp/other") + + reloaded.setLastTemplateID(nil) + reloaded.setLastMacDeviceID(nil) + reloaded.setLastDirectory(nil, macDeviceID: "mac-a") + #expect(reloaded.lastTemplateID() == nil) + #expect(reloaded.lastMacDeviceID() == nil) + #expect(reloaded.lastDirectory(macDeviceID: "mac-a") == nil) + } + + @Test func recentDirectoriesAreByteExactPromotedBoundedAndMacScoped() { + let defaults = Self.defaults() + let store = UserDefaultsMobileTaskTemplateStore(defaults: defaults) + let base = Date(timeIntervalSince1970: 1_000) + let composed = "~/caf\u{00E9}" + let decomposed = "~/cafe\u{301}" + + store.recordRecentDirectory(composed, macDeviceID: "mac-a", at: base) + store.recordRecentDirectory(decomposed, macDeviceID: "mac-a", at: base.addingTimeInterval(1)) + store.recordRecentDirectory(composed, macDeviceID: "mac-a", at: base.addingTimeInterval(2)) + store.recordRecentDirectory("~/other", macDeviceID: "mac-b", at: base) + + let byteExact = store.recentDirectories(macDeviceID: "mac-a") + #expect(byteExact.count == 2) + #expect(byteExact[0].path == composed) + #expect(byteExact[0].useCount == 2) + #expect(Array(byteExact[1].path.utf8) == Array(decomposed.utf8)) + + for index in 0..<24 { + store.recordRecentDirectory("~/project-\(index)", macDeviceID: "mac-a", at: base.addingTimeInterval(Double(index + 3))) + } + + let reloaded = UserDefaultsMobileTaskTemplateStore(defaults: defaults) + let macA = reloaded.recentDirectories(macDeviceID: "mac-a") + #expect(macA.count == 20) + #expect(macA.first?.path == "~/project-23") + #expect(reloaded.recentDirectories(macDeviceID: "mac-b").map(\.path) == ["~/other"]) + } + + @Test func composerDraftRoundTripsAcrossStoreInstancesAndClears() { + let defaults = Self.defaults() + let store = UserDefaultsMobileTaskTemplateStore(defaults: defaults) + let operationID = UUID() + let draft = MobileTaskComposerDraft( + prompt: "Fix the reconnect flow\nthen test it", + templateID: UUID(), + macDeviceID: "mac-a", + directory: "~/Dev/cmux", + didEditDirectory: true, + operationID: operationID + ) + + store.setComposerDraft(draft) + + let reloaded = UserDefaultsMobileTaskTemplateStore(defaults: defaults) + #expect(reloaded.composerDraft() == draft) + #expect(reloaded.composerDraft()?.operationID == operationID) + + reloaded.setComposerDraft(nil) + #expect(UserDefaultsMobileTaskTemplateStore(defaults: defaults).composerDraft() == nil) + } + + @Test func signOutClearsPersistedComposerDraftBeforeAnotherAccountCanRestoreIt() { + let defaults = Self.defaults() + let templateStore = UserDefaultsMobileTaskTemplateStore(defaults: defaults) + templateStore.setComposerDraft(MobileTaskComposerDraft( + prompt: "Account A secret", + templateID: nil, + macDeviceID: "mac-a", + directory: "~/Account-A", + didEditDirectory: true + )) + let shell = MobileShellComposite(isSignedIn: true, taskTemplateStore: templateStore) + + shell.signOut() + + #expect(UserDefaultsMobileTaskTemplateStore(defaults: defaults).composerDraft() == nil) + } + + @Test func signOutClearsAllTemplateDataAndNextListReseedsSafeDefaults() throws { + let defaults = Self.defaults() + let templateStore = UserDefaultsMobileTaskTemplateStore(defaults: defaults) + let custom = MobileTaskTemplate( + name: "Account A executable", + icon: "terminal", + command: "/Users/account-a/bin/private-agent", + defaultDirectory: "/Users/account-a/secret" + ) + templateStore.addTemplate(custom) + templateStore.setLastTemplateID(custom.id) + templateStore.setLastMacDeviceID("account-a-mac") + templateStore.setLastDirectory("/Users/account-a/project", macDeviceID: "account-a-mac") + templateStore.setLastDirectory("/tmp/account-a", macDeviceID: "other-mac") + templateStore.setComposerDraft(MobileTaskComposerDraft( + prompt: "Account A secret", + templateID: custom.id, + macDeviceID: "account-a-mac", + directory: "/Users/account-a/project", + didEditDirectory: true + )) + let shell = MobileShellComposite(isSignedIn: true, taskTemplateStore: templateStore) + + shell.signOut() + + let reloaded = UserDefaultsMobileTaskTemplateStore(defaults: defaults) + #expect(reloaded.lastTemplateID() == nil) + #expect(reloaded.lastMacDeviceID() == nil) + #expect(reloaded.lastDirectory(macDeviceID: "account-a-mac") == nil) + #expect(reloaded.lastDirectory(macDeviceID: "other-mac") == nil) + #expect(reloaded.composerDraft() == nil) + let seeds = reloaded.listTemplates() + #expect(seeds.map(\.command) == [ + "claude -- \"$CMUX_TASK_PROMPT\"", + "codex -- \"$CMUX_TASK_PROMPT\"", + "opencode --prompt \"$CMUX_TASK_PROMPT\"", + "", + ]) + #expect(!seeds.contains(where: { $0.id == custom.id })) + #expect(!seeds.contains(where: { $0.command.contains("account-a") })) + #expect(defaults.bool(forKey: "cmux.mobile.taskTemplates.seeded.v4")) + } + + @Test func staleComposerSheetCannotRepersistDraftAfterSignOut() { + let defaults = Self.defaults() + let templateStore = UserDefaultsMobileTaskTemplateStore(defaults: defaults) + let shell = MobileShellComposite(isSignedIn: true, taskTemplateStore: templateStore) + let capturedGeneration = shell.currentSessionGeneration + let staleDraft = MobileTaskComposerDraft( + prompt: "Account A secret", + templateID: nil, + macDeviceID: "mac-a", + directory: "~/Account-A", + didEditDirectory: true, + operationID: UUID() + ) + + shell.signOut() + let didPersist = shell.persistTaskComposerDraft( + staleDraft, + ifSessionGeneration: capturedGeneration + ) + + #expect(!didPersist) + #expect(UserDefaultsMobileTaskTemplateStore(defaults: defaults).composerDraft() == nil) + } + + @Test func staleComposerSheetCannotClearNewSessionDraft() { + let defaults = Self.defaults() + let templateStore = UserDefaultsMobileTaskTemplateStore(defaults: defaults) + let shell = MobileShellComposite(isSignedIn: true, taskTemplateStore: templateStore) + let staleGeneration = shell.currentSessionGeneration + let staleDraft = MobileTaskComposerDraft( + prompt: "Account A secret", + templateID: nil, + macDeviceID: "mac-a", + directory: "~/Account-A", + didEditDirectory: true, + operationID: UUID() + ) + let currentDraft = MobileTaskComposerDraft( + prompt: "Account B task", + templateID: nil, + macDeviceID: "mac-b", + directory: "~/Account-B", + didEditDirectory: true, + operationID: UUID() + ) + + shell.signOut() + shell.signIn() + templateStore.setComposerDraft(currentDraft) + let didPersist = shell.persistTaskComposerDraft( + staleDraft, + ifSessionGeneration: staleGeneration + ) + + #expect(!didPersist) + #expect(UserDefaultsMobileTaskTemplateStore(defaults: defaults).composerDraft() == currentDraft) + } + + @Test func staleCancelClearCannotEraseNewSessionDraft() { + let defaults = Self.defaults() + let templateStore = UserDefaultsMobileTaskTemplateStore(defaults: defaults) + let shell = MobileShellComposite(isSignedIn: true, taskTemplateStore: templateStore) + let staleGeneration = shell.currentSessionGeneration + let currentDraft = MobileTaskComposerDraft( + prompt: "Account B task", + templateID: nil, + macDeviceID: "mac-b", + directory: "~/Account-B", + didEditDirectory: true, + operationID: UUID() + ) + + shell.signOut() + shell.signIn() + templateStore.setComposerDraft(currentDraft) + let didClear = shell.clearTaskComposerDraft(ifSessionGeneration: staleGeneration) + + #expect(!didClear) + #expect(UserDefaultsMobileTaskTemplateStore(defaults: defaults).composerDraft() == currentDraft) + } + + @Test func staleAsyncSuccessClearCannotEraseNewSessionDraft() async { + let defaults = Self.defaults() + let templateStore = UserDefaultsMobileTaskTemplateStore(defaults: defaults) + let shell = MobileShellComposite(isSignedIn: true, taskTemplateStore: templateStore) + let staleGeneration = shell.currentSessionGeneration + let currentDraft = MobileTaskComposerDraft( + prompt: "Account B task", + templateID: nil, + macDeviceID: "mac-b", + directory: "~/Account-B", + didEditDirectory: true, + operationID: UUID() + ) + let completion = AsyncStream.makeStream() + let clearAfterSuccess = Task { @MainActor in + for await _ in completion.stream { break } + return shell.clearTaskComposerDraft(ifSessionGeneration: staleGeneration) + } + + shell.signOut() + shell.signIn() + templateStore.setComposerDraft(currentDraft) + completion.continuation.yield() + completion.continuation.finish() + let didClear = await clearAfterSuccess.value + + #expect(!didClear) + #expect(UserDefaultsMobileTaskTemplateStore(defaults: defaults).composerDraft() == currentDraft) + } + + @Test func staleComposerSuccessCannotOverwriteNextSessionDefaults() { + let defaults = Self.defaults() + let templateStore = UserDefaultsMobileTaskTemplateStore(defaults: defaults) + let shell = MobileShellComposite(isSignedIn: true, taskTemplateStore: templateStore) + let staleGeneration = shell.currentSessionGeneration + let accountATemplate = MobileTaskTemplate( + name: "Account A", + icon: "terminal", + command: "agent-a" + ) + let staleSnapshot = MobileTaskSubmissionSnapshot( + template: accountATemplate, + prompt: "Account A task", + macDeviceID: "mac-a", + directory: "/Users/account-a/private", + didEditDirectory: true, + operationID: UUID() + ) + + shell.signOut() + shell.signIn() + let accountBTemplate = MobileTaskTemplate( + name: "Account B", + icon: "terminal", + command: "agent-b" + ) + let accountBDraft = MobileTaskComposerDraft( + prompt: "Account B task", + templateID: accountBTemplate.id, + macDeviceID: "mac-b", + directory: "/Users/account-b/current", + didEditDirectory: true, + operationID: UUID() + ) + templateStore.addTemplate(accountBTemplate) + templateStore.setLastTemplateID(accountBTemplate.id) + templateStore.setLastMacDeviceID("mac-b") + templateStore.setLastDirectory("/Users/account-b/current", macDeviceID: "mac-b") + templateStore.setComposerDraft(accountBDraft) + + let didComplete = shell.completeTaskComposerSubmission( + staleSnapshot, + ifSessionGeneration: staleGeneration + ) + + #expect(!didComplete) + let reloaded = UserDefaultsMobileTaskTemplateStore(defaults: defaults) + #expect(reloaded.lastTemplateID() == accountBTemplate.id) + #expect(reloaded.lastMacDeviceID() == "mac-b") + #expect(reloaded.lastDirectory(macDeviceID: "mac-a") == nil) + #expect(reloaded.lastDirectory(macDeviceID: "mac-b") == "/Users/account-b/current") + #expect(reloaded.composerDraft() == accountBDraft) + } + + @Test func currentComposerSuccessPersistsDefaultsAndClearsDraftTogether() { + let defaults = Self.defaults() + let templateStore = UserDefaultsMobileTaskTemplateStore(defaults: defaults) + let shell = MobileShellComposite(isSignedIn: true, taskTemplateStore: templateStore) + let template = MobileTaskTemplate(name: "Agent", icon: "terminal", command: "agent") + let snapshot = MobileTaskSubmissionSnapshot( + template: template, + prompt: "Current task", + macDeviceID: "mac-current", + directory: " ~/current ", + didEditDirectory: true, + operationID: UUID() + ) + templateStore.setComposerDraft(snapshot.draft) + + let didComplete = shell.completeTaskComposerSubmission( + snapshot, + ifSessionGeneration: shell.currentSessionGeneration + ) + + #expect(didComplete) + let reloaded = UserDefaultsMobileTaskTemplateStore(defaults: defaults) + #expect(reloaded.lastTemplateID() == template.id) + #expect(reloaded.lastMacDeviceID() == "mac-current") + #expect(reloaded.lastDirectory(macDeviceID: "mac-current") == "~/current") + #expect(reloaded.composerDraft() == nil) + } + + @Test func currentSessionClearRemovesComposerDraft() { + let defaults = Self.defaults() + let templateStore = UserDefaultsMobileTaskTemplateStore(defaults: defaults) + let shell = MobileShellComposite(isSignedIn: true, taskTemplateStore: templateStore) + templateStore.setComposerDraft(MobileTaskComposerDraft( + prompt: "Current task", + templateID: nil, + macDeviceID: "mac-a", + directory: "~/Current", + didEditDirectory: true, + operationID: UUID() + )) + + let didClear = shell.clearTaskComposerDraft( + ifSessionGeneration: shell.currentSessionGeneration + ) + + #expect(didClear) + #expect(UserDefaultsMobileTaskTemplateStore(defaults: defaults).composerDraft() == nil) + } + + private static func defaults() -> UserDefaults { + let suiteName = "MobileTaskTemplateStoreTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskCommandComposer.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskCommandComposer.swift new file mode 100644 index 000000000000..c527b4f09523 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskCommandComposer.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Composes task startup parameters without interpreting user-authored shell source. +public struct MobileTaskCommandComposer: Sendable { + /// Creates a command composer. + public init() {} + + /// Preserves a nonblank template command byte-for-byte and supplies the + /// trimmed task prompt through `CMUX_TASK_PROMPT`. Blank commands open a + /// plain shell and intentionally receive no startup environment. + /// - Parameters: + /// - template: The selected task template. + /// - prompt: User-entered task prompt. + /// - Returns: The command, environment, and prompt-derived title. + public func compose(template: MobileTaskTemplate, prompt rawPrompt: String) -> MobileTaskComposition { + let prompt = rawPrompt.trimmingCharacters(in: .whitespacesAndNewlines) + let title = Self.taskTitle(from: prompt) + guard !template.isPlainShell else { + return MobileTaskComposition(initialCommand: nil, initialEnv: [:], title: title) + } + return MobileTaskComposition( + initialCommand: template.command, + initialEnv: ["CMUX_TASK_PROMPT": prompt], + title: title + ) + } + + /// The suggested workspace title for a task prompt: its first line, capped + /// at 60 characters. Static (not file-scope): the package conventions lint + /// forbids free functions in iOS package sources. + private static func taskTitle(from prompt: String) -> String? { + guard let firstLine = prompt.split(separator: "\n", omittingEmptySubsequences: false).first, + !firstLine.isEmpty else { + return nil + } + return String(firstLine.prefix(60)) + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposerDraft.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposerDraft.swift new file mode 100644 index 000000000000..194d3832a35c --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposerDraft.swift @@ -0,0 +1,53 @@ +public import Foundation + +/// The restorable, unsent state of the mobile task composer. +public struct MobileTaskComposerDraft: Codable, Equatable, Sendable { + /// Prompt text exactly as entered by the user. + public var prompt: String + /// Selected template, validated against current templates when restored. + public var templateID: MobileTaskTemplate.ID? + /// Selected Mac, validated against current paired Macs when restored. + public var macDeviceID: String? + /// Working directory exactly as entered by the user. + public var directory: String + /// Whether the user replaced the suggested directory. + public var didEditDirectory: Bool + /// Stable identity for retrying this logical task creation without duplication. + public var operationID: UUID? + /// Accepted identity awaiting an explicit refresh before this draft may be + /// started with a fresh operation ID. + public var completedOperationID: UUID? + + /// Creates a restorable composer draft. + public init( + prompt: String, + templateID: MobileTaskTemplate.ID?, + macDeviceID: String?, + directory: String, + didEditDirectory: Bool, + operationID: UUID? = nil, + completedOperationID: UUID? = nil + ) { + self.prompt = prompt + self.templateID = templateID + self.macDeviceID = macDeviceID + self.directory = directory + self.didEditDirectory = didEditDirectory + self.operationID = operationID + self.completedOperationID = completedOperationID + } + + /// Selects a template and adopts its suggested directory until the user + /// has explicitly edited the directory field. + /// - Parameters: + /// - id: Identifier of the newly selected template. + /// - suggestedDirectory: Directory suggested by that template and Mac. + public mutating func selectTemplate( + id: MobileTaskTemplate.ID, + suggestedDirectory: String + ) { + templateID = id + guard !didEditDirectory else { return } + directory = suggestedDirectory + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposition.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposition.swift new file mode 100644 index 000000000000..c306e9f6fce6 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposition.swift @@ -0,0 +1,22 @@ +import Foundation + +/// The workspace-create parameters derived from a task template and prompt. +public struct MobileTaskComposition: Equatable, Sendable { + /// Shell-interpreted command for the initial terminal, or `nil` for a plain shell. + public var initialCommand: String? + /// Environment variables for the initial terminal. + public var initialEnv: [String: String] + /// Suggested workspace title derived from the prompt. + public var title: String? + + /// Creates a task composition. + /// - Parameters: + /// - initialCommand: Shell-interpreted command for the initial terminal. + /// - initialEnv: Environment variables for the initial terminal. + /// - title: Suggested workspace title. + public init(initialCommand: String?, initialEnv: [String: String], title: String?) { + self.initialCommand = initialCommand + self.initialEnv = initialEnv + self.title = title + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskDirectorySuggestion.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskDirectorySuggestion.swift new file mode 100644 index 000000000000..39f505d1cecd --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskDirectorySuggestion.swift @@ -0,0 +1,291 @@ +public import Foundation + +/// The context that made a directory useful to the task composer. +public enum MobileTaskDirectorySource: Int, CaseIterable, Hashable, Sendable { + case home + case filesystemSearch + case recentSuccessful + case openTerminal + case openWorkspace + case lastSuccessful + case templateDefault + case activeWorkspace + case activeTerminal +} + +/// Exact UTF-8 identity for a Mac path. Swift `String` equality intentionally +/// treats canonically equivalent Unicode as equal, while remote filesystem +/// paths must preserve the exact bytes reported by the Mac. +public struct MobileTaskDirectoryPathID: Hashable, Sendable { + public let bytes: [UInt8] + + public init(path: String) { + bytes = Array(path.utf8) + } +} + +/// One directory fact available to the composer before matching and ranking. +public struct MobileTaskDirectoryCandidate: Identifiable, Equatable, Sendable { + public let path: String + public private(set) var sources: Set + public private(set) var context: String? + public private(set) var lastUsedAt: Date? + public private(set) var useCount: Int + + public var id: MobileTaskDirectoryPathID { + MobileTaskDirectoryPathID(path: path) + } + + public var bestSource: MobileTaskDirectorySource { + sources.max(by: { $0.rawValue < $1.rawValue }) ?? .home + } + + public init( + path: String, + source: MobileTaskDirectorySource, + context: String?, + lastUsedAt: Date? = nil, + useCount: Int = 0 + ) { + self.path = path + self.sources = [source] + self.context = context + self.lastUsedAt = lastUsedAt + self.useCount = max(0, useCount) + } + + mutating func merge(_ other: Self) { + let previousPriority = bestSource.rawValue + sources.formUnion(other.sources) + if other.bestSource.rawValue > previousPriority, let context = other.context { + self.context = context + } else if context == nil { + context = other.context + } + if let otherDate = other.lastUsedAt { + if let currentDate = lastUsedAt { + lastUsedAt = max(currentDate, otherDate) + } else { + lastUsedAt = otherDate + } + } + useCount = max(useCount, other.useCount) + } +} + +/// One successful directory selection retained for per-Mac ordering. +public struct MobileTaskRecentDirectory: Codable, Equatable, Sendable { + public let path: String + public var lastUsedAt: Date + public var useCount: Int + + public init(path: String, lastUsedAt: Date, useCount: Int) { + self.path = path + self.lastUsedAt = lastUsedAt + self.useCount = useCount + } +} + +/// A prepared, immutable directory index. Candidate folding and component +/// parsing happen once; each keystroke performs one bounded scan and keeps only +/// the requested top results instead of sorting every match. +public struct MobileTaskDirectorySuggestionIndex: Sendable { + private struct PreparedCandidate: Sendable { + let candidate: MobileTaskDirectoryCandidate + let foldedPath: String + let foldedBasename: String + let foldedComponents: [String] + } + + private struct RankedCandidate { + let prepared: PreparedCandidate + let matchTier: Int + let unmatchedComponents: Int + let recency: Int + } + + private let candidates: [PreparedCandidate] + private let now: Date + + public init(candidates rawCandidates: [MobileTaskDirectoryCandidate], now: Date = Date()) { + var merged: [MobileTaskDirectoryPathID: MobileTaskDirectoryCandidate] = [:] + var insertionOrder: [MobileTaskDirectoryPathID] = [] + for candidate in rawCandidates where !candidate.path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if var existing = merged[candidate.id] { + existing.merge(candidate) + merged[candidate.id] = existing + } else { + merged[candidate.id] = candidate + insertionOrder.append(candidate.id) + } + } + candidates = insertionOrder.compactMap { id in + guard let candidate = merged[id] else { return nil } + let foldedPath = Self.fold(candidate.path) + let components = Self.components(foldedPath) + return PreparedCandidate( + candidate: candidate, + foldedPath: foldedPath, + foldedBasename: components.last ?? foldedPath, + foldedComponents: components + ) + } + self.now = now + } + + public func suggestions( + matching query: String, + limit: Int = 8 + ) -> [MobileTaskDirectoryCandidate] { + guard limit > 0 else { return [] } + let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + let foldedQuery = Self.fold(trimmedQuery) + let queryComponents = Self.components(foldedQuery) + let queryBasename = queryComponents.last ?? foldedQuery + var top: [RankedCandidate] = [] + top.reserveCapacity(min(limit, candidates.count)) + + for candidate in candidates { + guard let match = match( + candidate, + rawQuery: trimmedQuery, + foldedQuery: foldedQuery, + queryBasename: queryBasename, + queryComponents: queryComponents + ) else { continue } + let ranked = RankedCandidate( + prepared: candidate, + matchTier: match.tier, + unmatchedComponents: match.unmatchedComponents, + recency: recencyBucket(candidate.candidate.lastUsedAt) + ) + let insertionIndex = top.firstIndex { isBetter(ranked, than: $0) } ?? top.endIndex + top.insert(ranked, at: insertionIndex) + if top.count > limit { + top.removeLast() + } + } + return top.map(\.prepared.candidate) + } + + private func match( + _ candidate: PreparedCandidate, + rawQuery: String, + foldedQuery: String, + queryBasename: String, + queryComponents: [String] + ) -> (tier: Int, unmatchedComponents: Int)? { + guard !foldedQuery.isEmpty else { + return (tier: 0, unmatchedComponents: candidate.foldedComponents.count) + } + if candidate.candidate.id == MobileTaskDirectoryPathID(path: rawQuery) { + return (tier: 5, unmatchedComponents: 0) + } + if candidate.candidate.path.hasPrefix(rawQuery) { + return (tier: 4, unmatchedComponents: max(0, candidate.foldedComponents.count - queryComponents.count)) + } + if candidate.foldedBasename == queryBasename { + return (tier: 4, unmatchedComponents: max(0, candidate.foldedComponents.count - queryComponents.count)) + } + if candidate.foldedPath.hasPrefix(foldedQuery) { + return (tier: 3, unmatchedComponents: max(0, candidate.foldedComponents.count - queryComponents.count)) + } + if Self.matchesOrderedComponentPrefixes(queryComponents, in: candidate.foldedComponents) { + return (tier: 2, unmatchedComponents: max(0, candidate.foldedComponents.count - queryComponents.count)) + } + if candidate.foldedBasename.contains(queryBasename) + || candidate.foldedPath.contains(foldedQuery) + || Self.hasFuzzyComponent(queryBasename, in: candidate.foldedComponents) { + return (tier: 1, unmatchedComponents: max(0, candidate.foldedComponents.count - queryComponents.count)) + } + return nil + } + + private func isBetter(_ lhs: RankedCandidate, than rhs: RankedCandidate) -> Bool { + if lhs.matchTier != rhs.matchTier { return lhs.matchTier > rhs.matchTier } + let lhsSource = lhs.prepared.candidate.bestSource.rawValue + let rhsSource = rhs.prepared.candidate.bestSource.rawValue + if lhsSource != rhsSource { return lhsSource > rhsSource } + if lhs.recency != rhs.recency { return lhs.recency > rhs.recency } + let lhsUsage = min(lhs.prepared.candidate.useCount, 99) + let rhsUsage = min(rhs.prepared.candidate.useCount, 99) + if lhsUsage != rhsUsage { return lhsUsage > rhsUsage } + if lhs.unmatchedComponents != rhs.unmatchedComponents { + return lhs.unmatchedComponents < rhs.unmatchedComponents + } + let lhsBytes = lhs.prepared.candidate.id.bytes + let rhsBytes = rhs.prepared.candidate.id.bytes + if lhsBytes.count != rhsBytes.count { return lhsBytes.count < rhsBytes.count } + return lhsBytes.lexicographicallyPrecedes(rhsBytes) + } + + private func recencyBucket(_ date: Date?) -> Int { + guard let date else { return 0 } + let age = max(0, now.timeIntervalSince(date)) + switch age { + case ..<3_600: return 99 + case ..<86_400: return 80 + case ..<(7 * 86_400): return 60 + case ..<(30 * 86_400): return 40 + default: return 20 + } + } + + private static func fold(_ value: String) -> String { + value.folding( + options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], + locale: Locale(identifier: "en_US_POSIX") + ) + } + + private static func components(_ value: String) -> [String] { + value.split { $0 == "/" || $0.isWhitespace }.map(String.init) + } + + private static func matchesOrderedComponentPrefixes( + _ query: [String], + in candidate: [String] + ) -> Bool { + guard !query.isEmpty else { return false } + var candidateIndex = candidate.startIndex + for queryComponent in query { + guard let match = candidate[candidateIndex...].firstIndex(where: { $0.hasPrefix(queryComponent) }) else { + return false + } + candidateIndex = candidate.index(after: match) + } + return true + } + + private static func hasFuzzyComponent(_ query: String, in components: [String]) -> Bool { + guard query.count >= 3 else { return false } + let maximum = query.count >= 7 ? 2 : 1 + return components.contains { component in + abs(component.count - query.count) <= maximum + && editDistance(component, query, maximum: maximum) <= maximum + } + } + + private static func editDistance(_ lhs: String, _ rhs: String, maximum: Int) -> Int { + let left = Array(lhs) + let right = Array(rhs) + guard abs(left.count - right.count) <= maximum else { return maximum + 1 } + var previous = Array(0...right.count) + for (leftIndex, leftCharacter) in left.enumerated() { + var current = [leftIndex + 1] + current.reserveCapacity(right.count + 1) + var rowMinimum = current[0] + for (rightIndex, rightCharacter) in right.enumerated() { + let insertion = current[rightIndex] + 1 + let deletion = previous[rightIndex + 1] + 1 + let substitution = previous[rightIndex] + (leftCharacter == rightCharacter ? 0 : 1) + let value = min(insertion, deletion, substitution) + current.append(value) + rowMinimum = min(rowMinimum, value) + } + if rowMinimum > maximum { return maximum + 1 } + previous = current + } + return previous.last ?? maximum + 1 + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionIdentity.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionIdentity.swift new file mode 100644 index 000000000000..b338493e26d1 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionIdentity.swift @@ -0,0 +1,109 @@ +public import Foundation + +/// Stable identity for one logical task submission. +/// +/// A retry reuses the same identity. Any edit that changes the requested task +/// rotates it so the Mac cannot mistake a new request for the previous one. +public struct MobileTaskSubmissionIdentity: Equatable, Sendable { + private struct ResolvedRequest: Equatable, Sendable { + let snapshot: MobileTaskSubmissionSnapshot? + let id: UUID + } + + /// Identity sent with `workspace.create`. + public private(set) var id: UUID + private var baseline: ResolvedRequest + private var divergent: ResolvedRequest? + private var active: ResolvedRequest + public private(set) var needsRequestResolution = false + + /// Creates an identity, restoring `id` for a retry when one exists. + public init( + id: UUID = UUID(), + initialRequest: MobileTaskSubmissionSnapshot? = nil + ) { + self.id = id + let resolved = ResolvedRequest( + snapshot: initialRequest?.withOperationID(id), + id: id + ) + self.baseline = resolved + self.active = resolved + } + + /// Starts a distinct logical submission after composer input changes. + public mutating func rotate() { + let newID = UUID() + id = newID + active = ResolvedRequest( + snapshot: active.snapshot?.withOperationID(newID), + id: newID + ) + baseline = active + divergent = nil + needsRequestResolution = false + } + + /// Defers all request composition and equivalence work until a persistence + /// or submission boundary. Calling this for each keystroke is O(1). + public mutating func markRequestDirty() { + guard !needsRequestResolution else { return } + needsRequestResolution = true + } + + /// Builds and compares the request only at persistence or send boundaries. + /// A clean retry returns the cached request without evaluating `builder`. + public mutating func resolveCurrentRequest( + _ builder: () -> MobileTaskSubmissionSnapshot? + ) -> MobileTaskSubmissionSnapshot? { + guard needsRequestResolution else { return active.snapshot } + + let candidate = builder() + let resolvedID: UUID + if Self.requestsAreEquivalent(candidate, baseline.snapshot) { + resolvedID = baseline.id + } else if let divergent, + Self.requestsAreEquivalent(candidate, divergent.snapshot) { + resolvedID = divergent.id + } else { + resolvedID = UUID() + divergent = ResolvedRequest( + snapshot: candidate?.withOperationID(resolvedID), + id: resolvedID + ) + } + + let resolved = ResolvedRequest( + snapshot: candidate?.withOperationID(resolvedID), + id: resolvedID + ) + id = resolvedID + active = resolved + needsRequestResolution = false + return resolved.snapshot + } + + /// Treats a submitted snapshot as the new retry baseline after failure. + public mutating func adoptResolvedRequest(_ snapshot: MobileTaskSubmissionSnapshot) { + id = snapshot.operationID + let resolved = ResolvedRequest(snapshot: snapshot, id: snapshot.operationID) + baseline = resolved + active = resolved + divergent = nil + needsRequestResolution = false + } + + private static func requestsAreEquivalent( + _ lhs: MobileTaskSubmissionSnapshot?, + _ rhs: MobileTaskSubmissionSnapshot? + ) -> Bool { + switch (lhs, rhs) { + case let (.some(lhs), .some(rhs)): + lhs.isRequestEquivalent(to: rhs) + case (nil, nil): + true + default: + false + } + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionSnapshot.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionSnapshot.swift new file mode 100644 index 000000000000..56edc4348cc0 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionSnapshot.swift @@ -0,0 +1,142 @@ +public import Foundation + +/// Immutable inputs and derived command for one task-composer submission. +/// +/// The composer captures this value before its first suspension so a late RPC +/// result cannot settle against template, Mac, prompt, or directory edits that +/// were not part of the sent request. +public struct MobileTaskSubmissionSnapshot: Equatable, Sendable { + /// Identifier of the task template selected when submission began. + public let templateID: MobileTaskTemplate.ID + /// Identifier of the Mac targeted by the captured submission. + public let macDeviceID: String + /// Unmodified prompt text captured from the composer. + public let prompt: String + /// Unmodified working-directory text captured from the composer. + public let directory: String + /// Working directory with surrounding whitespace removed for validation. + public let trimmedDirectory: String + /// Whether the user edited the template's suggested working directory. + public let didEditDirectory: Bool + /// Stable idempotency key used for every attempt to submit this snapshot. + public let operationID: UUID + /// Command and environment derived from the captured template and prompt. + public let composition: MobileTaskComposition + + /// Captures immutable inputs and derives the command for one submission. + /// + /// - Parameters: + /// - template: Task template selected when submission begins. + /// - prompt: Prompt text to compose into the template command. + /// - macDeviceID: Identifier of the Mac that should create the task. + /// - directory: Working-directory text shown in the composer. + /// - didEditDirectory: Whether the user changed the suggested directory. + /// - operationID: Stable idempotency key for submission retries. + public init( + template: MobileTaskTemplate, + prompt: String, + macDeviceID: String, + directory: String, + didEditDirectory: Bool, + operationID: UUID + ) { + self.templateID = template.id + self.macDeviceID = macDeviceID + self.prompt = prompt + self.directory = directory + self.trimmedDirectory = directory.trimmingCharacters(in: .whitespacesAndNewlines) + self.didEditDirectory = didEditDirectory + self.operationID = operationID + self.composition = MobileTaskCommandComposer().compose(template: template, prompt: prompt) + } + + /// Whether both snapshots produce the same `workspace.create` request. + /// + /// Template identity, presentation metadata, directory edit provenance, + /// and operation identity are excluded because the Mac + /// receives only the selected Mac, composed title/command/environment, and + /// trimmed effective working directory. + public func isRequestEquivalent(to other: MobileTaskSubmissionSnapshot) -> Bool { + Self.hasEqualUTF8(macDeviceID, other.macDeviceID) + && Self.hasEqualUTF8(composition.initialCommand, other.composition.initialCommand) + && Self.hasEqualUTF8(composition.initialEnv, other.composition.initialEnv) + && Self.hasEqualUTF8(composition.title, other.composition.title) + && Self.hasEqualUTF8(trimmedDirectory, other.trimmedDirectory) + } + + /// Rebinds an already-composed request to its resolved idempotency key. + /// Swift value storage keeps this copy O(1); it does not trim, compose, or + /// scan the request strings again. + public func withOperationID(_ operationID: UUID) -> MobileTaskSubmissionSnapshot { + MobileTaskSubmissionSnapshot( + templateID: templateID, + macDeviceID: macDeviceID, + prompt: prompt, + directory: directory, + didEditDirectory: didEditDirectory, + operationID: operationID, + composition: composition, + trimmedDirectory: trimmedDirectory + ) + } + + private static func hasEqualUTF8(_ lhs: String, _ rhs: String) -> Bool { + lhs.utf8.elementsEqual(rhs.utf8) + } + + private static func hasEqualUTF8(_ lhs: String?, _ rhs: String?) -> Bool { + switch (lhs, rhs) { + case let (.some(lhs), .some(rhs)): + hasEqualUTF8(lhs, rhs) + case (nil, nil): + true + default: + false + } + } + + private static func hasEqualUTF8( + _ lhs: [String: String], + _ rhs: [String: String] + ) -> Bool { + guard lhs.count == rhs.count else { return false } + return lhs.allSatisfy { lhsEntry in + rhs.contains { rhsEntry in + hasEqualUTF8(lhsEntry.key, rhsEntry.key) + && hasEqualUTF8(lhsEntry.value, rhsEntry.value) + } + } + } + + /// Draft restored after interruption or a failed submission. + public var draft: MobileTaskComposerDraft { + MobileTaskComposerDraft( + prompt: prompt, + templateID: templateID, + macDeviceID: macDeviceID.isEmpty ? nil : macDeviceID, + directory: directory, + didEditDirectory: didEditDirectory, + operationID: operationID + ) + } + + private init( + templateID: MobileTaskTemplate.ID, + macDeviceID: String, + prompt: String, + directory: String, + didEditDirectory: Bool, + operationID: UUID, + composition: MobileTaskComposition, + trimmedDirectory: String + ) { + self.templateID = templateID + self.macDeviceID = macDeviceID + self.prompt = prompt + self.directory = directory + self.trimmedDirectory = trimmedDirectory + self.didEditDirectory = didEditDirectory + self.operationID = operationID + self.composition = composition + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskTemplate.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskTemplate.swift new file mode 100644 index 000000000000..de31433197cb --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskTemplate.swift @@ -0,0 +1,97 @@ +public import Foundation + +/// A user-editable template for creating a new mobile task workspace. +public struct MobileTaskTemplate: Codable, Equatable, Sendable, Identifiable { + /// Stable template identifier. + public var id: UUID + /// User-visible template name. + public var name: String + /// SF Symbol name or single emoji used to represent the template. + public var icon: String + /// Shell script run in the new workspace's first terminal. Empty or + /// whitespace-only values create a plain shell. + public var command: String + /// Optional default working directory for workspaces created from this template. + public var defaultDirectory: String? + + /// Whether the command is blank and should open a plain shell. + public var isPlainShell: Bool { + command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + /// Creates a mobile task template. + /// - Parameters: + /// - id: Stable template identifier. + /// - name: User-visible template name. + /// - icon: SF Symbol name or single emoji. + /// - command: Shell script run in the first terminal; blank values create a plain shell. + /// - defaultDirectory: Optional default working directory. + public init( + id: UUID = UUID(), + name: String, + icon: String, + command: String, + defaultDirectory: String? = nil + ) { + self.id = id + self.name = name + self.icon = icon + self.command = command + self.defaultDirectory = defaultDirectory + } + + /// Prefix marking an icon value as a bundled agent brand image rather + /// than an SF Symbol or emoji (e.g. `agent:claude`). + public static let agentIconPrefix = "agent:" + + /// Returns the bundled brand-image base name for an `agent:` icon value, + /// or nil when the value is a symbol/emoji icon. These resolve to loose + /// PNGs in the UI package (AgentIcons/@3x.png), NOT an asset + /// catalog: dev reloads override PRODUCT_BUNDLE_IDENTIFIER globally, + /// which stamps SwiftPM resource bundles with the app's identifier and + /// breaks CoreUI catalog registration (Image(named:) then fails even + /// though the compiled Assets.car contains the entries). + public static func agentIconAssetName(for icon: String) -> String? { + guard icon.hasPrefix(agentIconPrefix) else { return nil } + switch icon.dropFirst(agentIconPrefix.count) { + case "claude": return "Claude" + case "codex": return "Codex" + case "opencode": return "OpenCode" + default: return nil + } + } + + /// Default templates written once into the device-local template store. + /// Display names are passed in: they are user-facing, and localization + /// lives at the store/UI boundary, not in this model package. + /// - Parameters: + /// - claudeName: Localized name for the Claude template. + /// - codexName: Localized name for the Codex template. + /// - openCodeName: Localized name for the OpenCode template. + /// - shellName: Localized name for the plain-shell template. + public static func seedDefaults( + claudeName: String, + codexName: String, + openCodeName: String, + shellName: String + ) -> [MobileTaskTemplate] { + [ + MobileTaskTemplate( + name: claudeName, + icon: "agent:claude", + command: "claude -- \"$CMUX_TASK_PROMPT\"" + ), + MobileTaskTemplate( + name: codexName, + icon: "agent:codex", + command: "codex -- \"$CMUX_TASK_PROMPT\"" + ), + MobileTaskTemplate( + name: openCodeName, + icon: "agent:opencode", + command: "opencode --prompt \"$CMUX_TASK_PROMPT\"" + ), + MobileTaskTemplate(name: shellName, icon: "terminal", command: ""), + ] + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalPreview.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalPreview.swift index aac558b8b974..bb578d6032e6 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalPreview.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalPreview.swift @@ -27,6 +27,8 @@ public struct MobileTerminalPreview: Identifiable, Equatable, Sendable { public var id: ID /// The terminal's user-facing display name. public var name: String + /// The terminal's last reported current directory on its owning Mac. + public var currentDirectory: String? /// Whether the terminal surface is ready to receive input and render output. public var isReady: Bool /// Whether the terminal currently holds focus in the shell. @@ -44,12 +46,14 @@ public struct MobileTerminalPreview: Identifiable, Equatable, Sendable { public init( id: ID, name: String, + currentDirectory: String? = nil, isReady: Bool = true, isFocused: Bool = false, viewportFit: MobileTerminalViewportFit? = nil ) { self.id = id self.name = name + self.currentDirectory = currentDirectory self.isReady = isReady self.isFocused = isFocused self.viewportFit = viewportFit diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview.swift index 1a0fbfac6c0b..d07fa868a473 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview.swift @@ -50,6 +50,8 @@ public struct MobileWorkspacePreview: Identifiable, Equatable, Sendable { public var windowID: String? /// The workspace's user-facing display name. public var name: String + /// The workspace's last reported current directory on its owning Mac. + public var currentDirectory: String? /// Whether the workspace is pinned on the Mac. Pinned workspaces sort to the /// top of the mobile list. public var isPinned: Bool @@ -119,6 +121,7 @@ public struct MobileWorkspacePreview: Identifiable, Equatable, Sendable { macDisplayName: String? = nil, windowID: String? = nil, name: String, + currentDirectory: String? = nil, isPinned: Bool = false, groupID: MobileWorkspaceGroupPreview.ID? = nil, previewText: String? = nil, @@ -133,6 +136,7 @@ public struct MobileWorkspacePreview: Identifiable, Equatable, Sendable { self.macDisplayName = macDisplayName self.windowID = windowID self.name = name + self.currentDirectory = currentDirectory self.isPinned = isPinned self.groupID = groupID self.previewText = previewText diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskCommandComposerTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskCommandComposerTests.swift new file mode 100644 index 000000000000..2396c21f2198 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskCommandComposerTests.swift @@ -0,0 +1,118 @@ +import Foundation +import Testing +@testable import CmuxMobileShellModel + +@Suite struct MobileTaskCommandComposerTests { + private let composer = MobileTaskCommandComposer() + + @Test func whitespaceOnlyCommandsCreatePlainShells() { + for command in ["", " ", " \n\t "] { + let template = MobileTaskTemplate(name: "Shell", icon: "terminal", command: command) + let result = composer.compose(template: template, prompt: "Investigate logs") + + #expect(template.isPlainShell) + #expect(result.initialCommand == nil) + #expect(result.initialEnv.isEmpty) + #expect(result.title == "Investigate logs") + } + } + + @Test func nonblankCommandAndBlankPromptRemainExplicit() { + let command = "codex -- \"$CMUX_TASK_PROMPT\"" + let template = MobileTaskTemplate(name: "Codex", icon: "agent:codex", command: command) + + let result = composer.compose(template: template, prompt: " \n ") + + #expect(!template.isPlainShell) + #expect(result.initialCommand == command) + #expect(result.initialEnv == ["CMUX_TASK_PROMPT": ""]) + #expect(result.title == nil) + } + + @Test func promptIsTrimmedOnlyInEnvironmentAndTitle() { + let command = "agent --flag" + let template = MobileTaskTemplate(name: "Agent", icon: "terminal", command: command) + + let result = composer.compose(template: template, prompt: " Fix the race \n ") + + #expect(result.initialCommand == command) + #expect(result.initialEnv == ["CMUX_TASK_PROMPT": "Fix the race"]) + #expect(result.title == "Fix the race") + } + + @Test func seededCommandsConsumeThePromptEnvironmentExplicitly() { + let seeds = MobileTaskTemplate.seedDefaults( + claudeName: "Claude", + codexName: "Codex", + openCodeName: "OpenCode", + shellName: "Shell" + ) + + #expect(seeds.map(\.command) == [ + "claude -- \"$CMUX_TASK_PROMPT\"", + "codex -- \"$CMUX_TASK_PROMPT\"", + "opencode --prompt \"$CMUX_TASK_PROMPT\"", + "", + ]) + for template in seeds.dropLast() { + let result = composer.compose(template: template, prompt: "--resume") + #expect(result.initialCommand == template.command) + #expect(result.initialEnv == ["CMUX_TASK_PROMPT": "--resume"]) + } + } + + @Test func submissionIdentityStaysStableUntilRotated() { + let restoredID = UUID() + var identity = MobileTaskSubmissionIdentity(id: restoredID) + + #expect(identity.id == restoredID) + #expect(identity.id == restoredID) + + identity.rotate() + + #expect(identity.id != restoredID) + } + + @Test func submissionSnapshotKeepsTheVerbatimCommand() { + let operationID = UUID() + let templateID = UUID() + let template = MobileTaskTemplate( + id: templateID, + name: "Codex", + icon: "sparkles", + command: "codex {prompt}" + ) + let snapshot = MobileTaskSubmissionSnapshot( + template: template, + prompt: "Fix the race", + macDeviceID: "mac-a", + directory: " ~/cmux ", + didEditDirectory: true, + operationID: operationID + ) + + #expect(snapshot.templateID == template.id) + #expect(snapshot.macDeviceID == "mac-a") + #expect(snapshot.trimmedDirectory == "~/cmux") + #expect(snapshot.operationID == operationID) + #expect(snapshot.composition.initialCommand == "codex {prompt}") + #expect(snapshot.composition.initialEnv == ["CMUX_TASK_PROMPT": "Fix the race"]) + #expect(snapshot.draft == MobileTaskComposerDraft( + prompt: "Fix the race", + templateID: template.id, + macDeviceID: "mac-a", + directory: " ~/cmux ", + didEditDirectory: true, + operationID: operationID + )) + } + + @Test func titleUsesFirstTrimmedLineAndTruncatesToSixtyCharacters() { + let template = MobileTaskTemplate(name: "Codex", icon: "sparkles", command: "codex") + let prompt = " 123456789012345678901234567890123456789012345678901234567890abcdef\nsecond line " + + let result = composer.compose(template: template, prompt: prompt) + + #expect(result.title == "123456789012345678901234567890123456789012345678901234567890") + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskCommandComposerVerbatimTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskCommandComposerVerbatimTests.swift new file mode 100644 index 000000000000..ae33a541d580 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskCommandComposerVerbatimTests.swift @@ -0,0 +1,57 @@ +import Testing +@testable import CmuxMobileShellModel + +@Suite struct MobileTaskCommandComposerVerbatimTests { + private let composer = MobileTaskCommandComposer() + + @Test func arbitraryShellSourceIsNeverParsedOrRewritten() { + let commands = [ + "echo {prompt}; printf %s {prompt}", + "# don't parse {prompt}\nagent '$CMUX_TASK_PROMPT'", + "cat <<'EOF'\n{prompt}\nEOF", + "cat <&1", + "agent $$CMUX_TASK_PROMPT", + "cat <<''\n{prompt}\n\n", + "cat <<\"\"\n{prompt}\n\n", + "if true; then\nagent {prompt}\nfi", + "prepare && agent || recover", + "agent # $CMUX_TASK_PROMPT and {prompt} are documentation", + "agent \\$CMUX_TASK_PROMPT", + "agent \"${#CMUX_TASK_PROMPT}\"", + "agent \"${CMUX_TASK_PROMPT_EXTRA:-fallback}\"", + ] + + for command in commands { + let template = MobileTaskTemplate(name: "Custom", icon: "terminal", command: command) + let result = composer.compose(template: template, prompt: " space * ' quote ") + + #expect(!template.isPlainShell) + #expect(result.initialCommand == command) + #expect(result.initialEnv == ["CMUX_TASK_PROMPT": "space * ' quote"]) + } + } + + @Test func nonblankCommentsAssignmentsAndRedirectionsRemainCommands() { + let commands = [ + "# setup note", + "FOO=bar # setup note", + "> /tmp/cmux-task-output", + "2>>/tmp/cmux-task-log", + ] + + for command in commands { + let template = MobileTaskTemplate(name: "Custom", icon: "terminal", command: command) + let result = composer.compose(template: template, prompt: "ship it") + + #expect(!template.isPlainShell) + #expect(result.initialCommand == command) + #expect(result.initialEnv == ["CMUX_TASK_PROMPT": "ship it"]) + } + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskComposerDraftTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskComposerDraftTests.swift new file mode 100644 index 000000000000..08ab4f57151b --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskComposerDraftTests.swift @@ -0,0 +1,69 @@ +import Foundation +import Testing +@testable import CmuxMobileShellModel + +@Suite struct MobileTaskComposerDraftTests { + @Test func templateSelectionPreservesManuallyEditedDirectory() { + let originalTemplateID = UUID() + let selectedTemplateID = UUID() + var draft = MobileTaskComposerDraft( + prompt: "Keep the path", + templateID: originalTemplateID, + macDeviceID: "mac-a", + directory: "/Users/test/Manual", + didEditDirectory: true + ) + + draft.selectTemplate( + id: selectedTemplateID, + suggestedDirectory: "/Users/test/Suggested" + ) + + #expect(draft.templateID == selectedTemplateID) + #expect(draft.directory == "/Users/test/Manual") + #expect(draft.didEditDirectory) + } + + @Test func templateSelectionAppliesSuggestionUntilDirectoryIsEdited() { + let selectedTemplateID = UUID() + var draft = MobileTaskComposerDraft( + prompt: "", + templateID: UUID(), + macDeviceID: "mac-a", + directory: "~", + didEditDirectory: false + ) + + draft.selectTemplate( + id: selectedTemplateID, + suggestedDirectory: "/Users/test/Suggested" + ) + + #expect(draft.templateID == selectedTemplateID) + #expect(draft.directory == "/Users/test/Suggested") + #expect(!draft.didEditDirectory) + } + + @Test func completedOperationRecoverySurvivesDraftRoundTrip() throws { + let freshOperationID = UUID() + let completedOperationID = UUID() + let draft = MobileTaskComposerDraft( + prompt: "Keep this draft", + templateID: UUID(), + macDeviceID: "mac-a", + directory: "~/Dev/cmux", + didEditDirectory: true, + operationID: freshOperationID, + completedOperationID: completedOperationID + ) + + let restored = try JSONDecoder().decode( + MobileTaskComposerDraft.self, + from: JSONEncoder().encode(draft) + ) + + #expect(restored == draft) + #expect(restored.operationID == freshOperationID) + #expect(restored.completedOperationID == completedOperationID) + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskDirectorySuggestionTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskDirectorySuggestionTests.swift new file mode 100644 index 000000000000..3b945758cd05 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskDirectorySuggestionTests.swift @@ -0,0 +1,99 @@ +import Foundation +import Testing +@testable import CmuxMobileShellModel + +@Suite struct MobileTaskDirectorySuggestionTests { + @Test func strictMatchesOutrankHigherContextLenientMatches() { + let index = MobileTaskDirectorySuggestionIndex(candidates: [ + candidate("/Users/me/alpha-cmux", source: .activeTerminal), + candidate("/Users/me/cmux", source: .openWorkspace), + ]) + + #expect(index.suggestions(matching: "/Users/me/cmux").map(\.path) == [ + "/Users/me/cmux", + "/Users/me/alpha-cmux", + ]) + } + + @Test func exactBasenameOutranksHigherContextDescendantMatch() { + let project = "/Users/me/Dev/Manaflow/cmuxterm-hq/worktrees/feat-ios-task-composer" + let index = MobileTaskDirectorySuggestionIndex(candidates: [ + candidate("\(project)/web", source: .activeTerminal), + candidate(project, source: .filesystemSearch), + ]) + + #expect(index.suggestions(matching: "feat-ios-task-composer").map(\.path) == [ + project, + "\(project)/web", + ]) + } + + @Test func activeAndRecentContextOrdersEqualMatches() { + let now = Date(timeIntervalSince1970: 10_000) + let index = MobileTaskDirectorySuggestionIndex(candidates: [ + candidate("/Users/me/cmux-old", source: .openWorkspace, date: now.addingTimeInterval(-8 * 86_400)), + candidate("/Users/me/cmux-active", source: .activeWorkspace, date: now.addingTimeInterval(-30)), + candidate("/Users/me/cmux-recent", source: .recentSuccessful, date: now.addingTimeInterval(-60), useCount: 8), + ], now: now) + + #expect(index.suggestions(matching: "cmux").map(\.path) == [ + "/Users/me/cmux-active", + "/Users/me/cmux-old", + "/Users/me/cmux-recent", + ]) + } + + @Test func componentPrefixesProvideLenientMatching() { + let index = MobileTaskDirectorySuggestionIndex(candidates: [ + candidate("/Users/me/Dev/Manaflow/cmuxterm-hq", source: .openWorkspace), + candidate("/Users/me/Dev/another-project", source: .activeWorkspace), + ]) + + #expect(index.suggestions(matching: "mana cmux").map(\.path) == [ + "/Users/me/Dev/Manaflow/cmuxterm-hq", + ]) + } + + @Test func exactUTF8IdentityMergesSourcesWithoutMergingCanonicalUnicode() throws { + let composed = "/Users/me/caf\u{00E9}" + let decomposed = "/Users/me/cafe\u{301}" + let index = MobileTaskDirectorySuggestionIndex(candidates: [ + candidate(composed, source: .templateDefault), + candidate(composed, source: .activeWorkspace), + candidate(decomposed, source: .recentSuccessful), + ]) + + let suggestions = index.suggestions(matching: "caf") + #expect(suggestions.count == 2) + #expect(suggestions[0].path == composed) + #expect(suggestions[0].sources == [.templateDefault, .activeWorkspace]) + #expect(Array(suggestions[0].path.utf8) != Array(suggestions[1].path.utf8)) + } + + @Test func resultCountIsBoundedForLargeCandidateSets() { + let candidates = (0..<10_000).map { + candidate("/Users/me/project-\($0)", source: .openWorkspace) + } + let index = MobileTaskDirectorySuggestionIndex(candidates: candidates) + + let suggestions = index.suggestions(matching: "project", limit: 8) + + #expect(suggestions.count == 8) + #expect(Set(suggestions.map(\.path)).count == 8) + } + + private func candidate( + _ path: String, + source: MobileTaskDirectorySource, + date: Date? = nil, + useCount: Int = 0 + ) -> MobileTaskDirectoryCandidate { + MobileTaskDirectoryCandidate( + path: path, + source: source, + context: nil, + lastUsedAt: date, + useCount: useCount + ) + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskSubmissionSnapshotTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskSubmissionSnapshotTests.swift new file mode 100644 index 000000000000..94399842a526 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskSubmissionSnapshotTests.swift @@ -0,0 +1,356 @@ +import Foundation +import Testing +@testable import CmuxMobileShellModel + +@Suite struct MobileTaskSubmissionSnapshotTests { + @Test func selectedTemplateNameAndIconEditKeepsEquivalentRequest() { + let templateID = UUID() + let before = snapshot(template: MobileTaskTemplate( + id: templateID, + name: "Codex", + icon: "agent:codex", + command: "codex" + )) + let after = snapshot(template: MobileTaskTemplate( + id: templateID, + name: "Renamed Codex", + icon: "sparkles", + command: "codex" + )) + + #expect(before.isRequestEquivalent(to: after)) + expectIdentityPreserved(from: before, to: after) + } + + @Test func unselectedTemplateEditKeepsSelectedRequestEquivalent() { + let selected = MobileTaskTemplate(name: "Codex", icon: "agent:codex", command: "codex") + let before = snapshot(template: selected) + let after = snapshot(template: selected) + + #expect(before.isRequestEquivalent(to: after)) + expectIdentityPreserved(from: before, to: after) + } + + @Test func selectedTemplateCommandEditChangesRequest() { + let templateID = UUID() + let before = snapshot(template: MobileTaskTemplate( + id: templateID, + name: "Codex", + icon: "agent:codex", + command: "codex" + )) + let after = snapshot(template: MobileTaskTemplate( + id: templateID, + name: "Codex", + icon: "agent:codex", + command: "codex --dangerously-bypass-approvals-and-sandbox" + )) + + #expect(!before.isRequestEquivalent(to: after)) + expectIdentityRotated(from: before, to: after) + } + + @Test func commandSurroundingWhitespaceChangesRequestIdentity() { + let before = snapshot(template: MobileTaskTemplate( + name: "Codex", + icon: "agent:codex", + command: "codex" + )) + let after = snapshot(template: MobileTaskTemplate( + name: "Codex", + icon: "agent:codex", + command: " codex " + )) + + #expect(!before.isRequestEquivalent(to: after)) + expectIdentityRotated(from: before, to: after) + } + + @Test func canonicallyEquivalentCommandBytesChangeRequestIdentity() { + let before = snapshot(template: MobileTaskTemplate( + name: "Codex", + icon: "agent:codex", + command: "codex caf\u{00E9}" + )) + let after = snapshot(template: MobileTaskTemplate( + name: "Codex", + icon: "agent:codex", + command: "codex cafe\u{301}" + )) + + #expect(!before.isRequestEquivalent(to: after)) + expectIdentityRotated(from: before, to: after) + } + + @Test func canonicallyEquivalentPromptEnvironmentAndTitleBytesChangeRequestIdentity() { + let template = MobileTaskTemplate(name: "Codex", icon: "agent:codex", command: "codex") + let before = snapshot(template: template, prompt: "caf\u{00E9}") + let after = snapshot(template: template, prompt: "cafe\u{301}") + + #expect(!before.isRequestEquivalent(to: after)) + expectIdentityRotated(from: before, to: after) + } + + @Test func canonicallyEquivalentDirectoryBytesChangeRequestIdentity() { + let template = MobileTaskTemplate(name: "Codex", icon: "agent:codex", command: "codex") + let before = snapshot(template: template, directory: "~/caf\u{00E9}") + let after = snapshot(template: template, directory: "~/cafe\u{301}") + + #expect(!before.isRequestEquivalent(to: after)) + expectIdentityRotated(from: before, to: after) + } + + @Test func canonicallyEquivalentMacIdentifierBytesChangeRequestIdentity() { + let template = MobileTaskTemplate(name: "Codex", icon: "agent:codex", command: "codex") + let before = snapshot(template: template, macDeviceID: "mac-caf\u{00E9}") + let after = snapshot(template: template, macDeviceID: "mac-cafe\u{301}") + + #expect(!before.isRequestEquivalent(to: after)) + expectIdentityRotated(from: before, to: after) + } + + @Test func selectedTemplateDefaultDirectoryEditChangesEffectiveRequest() { + let template = MobileTaskTemplate(name: "Codex", icon: "agent:codex", command: "codex") + let before = snapshot(template: template, directory: "~/cmux") + let after = snapshot(template: template, directory: "~/other") + + #expect(!before.isRequestEquivalent(to: after)) + expectIdentityRotated(from: before, to: after) + } + + @Test func selectedTemplateChangeWithDifferentCompositionChangesRequest() { + let before = snapshot(template: MobileTaskTemplate( + name: "Codex", + icon: "agent:codex", + command: "codex" + )) + let after = snapshot(template: MobileTaskTemplate( + name: "Claude", + icon: "agent:claude", + command: "claude" + )) + + #expect(!before.isRequestEquivalent(to: after)) + expectIdentityRotated(from: before, to: after) + } + + @Test func selectedTemplateDeletionRotatesIdentity() { + let before = snapshot(template: MobileTaskTemplate( + name: "Codex", + icon: "agent:codex", + command: "codex" + )) + + expectIdentityRotated(from: before, to: nil) + } + + @Test func requestEquivalenceMatchesSentWorkspaceSpec() { + let before = MobileTaskSubmissionSnapshot( + template: MobileTaskTemplate(name: "Codex", icon: "agent:codex", command: "codex"), + prompt: " ship it ", + macDeviceID: "mac-a", + directory: " ~/cmux ", + didEditDirectory: false, + operationID: UUID() + ) + let after = MobileTaskSubmissionSnapshot( + template: MobileTaskTemplate(name: "Renamed", icon: "sparkles", command: "codex"), + prompt: "ship it", + macDeviceID: "mac-a", + directory: "~/cmux", + didEditDirectory: true, + operationID: UUID() + ) + + #expect(before.isRequestEquivalent(to: after)) + #expect(!before.isRequestEquivalent(to: snapshot( + template: MobileTaskTemplate(name: "Codex", icon: "agent:codex", command: "codex"), + macDeviceID: "mac-b" + ))) + } + + @Test func dirtyMarksDeferOneHundredKilobytePromptCompositionUntilResolution() throws { + let template = MobileTaskTemplate(name: "Codex", icon: "agent:codex", command: "codex") + let baseline = snapshot(template: template, prompt: "baseline") + var identity = MobileTaskSubmissionIdentity( + id: baseline.operationID, + initialRequest: baseline + ) + let largePrompt = String(repeating: "x", count: 100_000) + var buildCount = 0 + + for _ in 0..<10_000 { + identity.markRequestDirty() + } + #expect(buildCount == 0) + let resolved = try #require(identity.resolveCurrentRequest { + buildCount += 1 + return self.snapshot(template: template, prompt: largePrompt) + }) + #expect(buildCount == 1) + let resolvedID = resolved.operationID + + let cleanRetry = identity.resolveCurrentRequest { + buildCount += 1 + return self.snapshot(template: template, prompt: largePrompt) + } + #expect(buildCount == 1) + #expect(cleanRetry?.operationID == resolvedID) + } + + @Test func editThenRevertBeforeResolutionRestoresBaselineID() throws { + let template = MobileTaskTemplate(name: "Codex", icon: "agent:codex", command: "codex") + let baseline = snapshot(template: template, prompt: "A") + var identity = MobileTaskSubmissionIdentity( + id: baseline.operationID, + initialRequest: baseline + ) + + identity.markRequestDirty() + identity.markRequestDirty() + let resolved = try #require(identity.resolveCurrentRequest { + self.snapshot(template: template, prompt: "A") + }) + + #expect(resolved.operationID == baseline.operationID) + } + + @Test func resolvedBaselineAndDivergentRequestsRestoreBothIDs() throws { + let template = MobileTaskTemplate(name: "Codex", icon: "agent:codex", command: "codex") + let baseline = snapshot(template: template, prompt: "A") + var identity = MobileTaskSubmissionIdentity( + id: baseline.operationID, + initialRequest: baseline + ) + identity.markRequestDirty() + let divergent = try #require(identity.resolveCurrentRequest { + self.snapshot(template: template, prompt: "B") + }) + #expect(divergent.operationID != baseline.operationID) + + identity.markRequestDirty() + let restoredBaseline = try #require(identity.resolveCurrentRequest { + self.snapshot(template: template, prompt: "A") + }) + identity.markRequestDirty() + let restoredDivergent = try #require(identity.resolveCurrentRequest { + self.snapshot(template: template, prompt: "B") + }) + + #expect(restoredBaseline.operationID == baseline.operationID) + #expect(restoredDivergent.operationID == divergent.operationID) + } + + @Test func missingTemplateTransitionUsesStableDivergentID() { + let template = MobileTaskTemplate(name: "Codex", icon: "agent:codex", command: "codex") + let baseline = snapshot(template: template) + var identity = MobileTaskSubmissionIdentity( + id: baseline.operationID, + initialRequest: baseline + ) + identity.markRequestDirty() + #expect(identity.resolveCurrentRequest { nil } == nil) + let missingID = identity.id + identity.markRequestDirty() + _ = identity.resolveCurrentRequest { baseline } + #expect(identity.id == baseline.operationID) + identity.markRequestDirty() + #expect(identity.resolveCurrentRequest { nil } == nil) + #expect(identity.id == missingID) + } + + @Test func adoptingFailedSubmissionCreatesNewRetryBaseline() throws { + let template = MobileTaskTemplate(name: "Codex", icon: "agent:codex", command: "codex") + let original = snapshot(template: template, prompt: "A") + let failed = snapshot(template: template, prompt: "B") + var identity = MobileTaskSubmissionIdentity( + id: original.operationID, + initialRequest: original + ) + + identity.adoptResolvedRequest(failed) + let retry = try #require(identity.resolveCurrentRequest { + self.snapshot(template: template, prompt: "unused") + }) + #expect(retry.operationID == failed.operationID) + #expect(retry.prompt == failed.prompt) + } + + @Test func retryKeepsFailedOperationIdentityUntilTheRequestChanges() throws { + let template = MobileTaskTemplate(name: "Codex", icon: "agent:codex", command: "codex") + let failed = snapshot(template: template, prompt: "A") + var identity = MobileTaskSubmissionIdentity( + id: failed.operationID, + initialRequest: failed + ) + + identity.adoptResolvedRequest(failed) + let unchangedRetry = try #require(identity.resolveCurrentRequest { nil }) + #expect(unchangedRetry.operationID == failed.operationID) + + identity.markRequestDirty() + let editedRequest = try #require(identity.resolveCurrentRequest { + self.snapshot(template: template, prompt: "B") + }) + #expect(editedRequest.operationID != failed.operationID) + + let editedRetry = try #require(identity.resolveCurrentRequest { nil }) + #expect(editedRetry.operationID == editedRequest.operationID) + } + + @Test func rebindingOperationIDPreservesExactWireRequest() { + let original = snapshot( + template: MobileTaskTemplate(name: "Codex", icon: "agent:codex", command: "codex caf\u{00E9}"), + prompt: "cafe\u{301}", + macDeviceID: "mac-caf\u{00E9}", + directory: " ~/cafe\u{301} " + ) + let rebound = original.withOperationID(UUID()) + + #expect(rebound.operationID != original.operationID) + #expect(rebound.composition == original.composition) + #expect(rebound.trimmedDirectory == original.trimmedDirectory) + } + + private func expectIdentityPreserved( + from before: MobileTaskSubmissionSnapshot?, + to after: MobileTaskSubmissionSnapshot? + ) { + var identity = MobileTaskSubmissionIdentity(initialRequest: before) + let originalID = identity.id + + identity.markRequestDirty() + _ = identity.resolveCurrentRequest { after } + + #expect(identity.id == originalID) + } + + private func expectIdentityRotated( + from before: MobileTaskSubmissionSnapshot?, + to after: MobileTaskSubmissionSnapshot? + ) { + var identity = MobileTaskSubmissionIdentity(initialRequest: before) + let originalID = identity.id + + identity.markRequestDirty() + _ = identity.resolveCurrentRequest { after } + + #expect(identity.id != originalID) + } + + private func snapshot( + template: MobileTaskTemplate, + prompt: String = "ship it", + macDeviceID: String = "mac-a", + directory: String = "~/cmux" + ) -> MobileTaskSubmissionSnapshot { + MobileTaskSubmissionSnapshot( + template: template, + prompt: prompt, + macDeviceID: macDeviceID, + directory: directory, + didEditDirectory: false, + operationID: UUID() + ) + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskTemplateTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskTemplateTests.swift new file mode 100644 index 000000000000..d71e09654fd8 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskTemplateTests.swift @@ -0,0 +1,57 @@ +import Foundation +import Testing +@testable import CmuxMobileShellModel + +@Suite struct MobileTaskTemplateTests { + @Test func seedDefaultsUseExpectedNamesIconsAndCommands() { + let seeds = MobileTaskTemplate.seedDefaults( + claudeName: "Claude", + codexName: "Codex", + openCodeName: "OpenCode", + shellName: "Shell" + ) + + #expect(seeds.map(\.name) == ["Claude", "Codex", "OpenCode", "Shell"]) + #expect(seeds.map(\.icon) == ["agent:claude", "agent:codex", "agent:opencode", "terminal"]) + #expect(seeds.map(\.command) == [ + "claude -- \"$CMUX_TASK_PROMPT\"", + "codex -- \"$CMUX_TASK_PROMPT\"", + "opencode --prompt \"$CMUX_TASK_PROMPT\"", + "", + ]) + #expect(seeds.allSatisfy { $0.defaultDirectory == nil }) + } + + @Test func onlyWhitespaceCommandsArePlainShells() { + #expect(MobileTaskTemplate(name: "Shell", icon: "terminal", command: "").isPlainShell) + #expect(MobileTaskTemplate(name: "Shell", icon: "terminal", command: " \n\t ").isPlainShell) + #expect(!MobileTaskTemplate(name: "Comment", icon: "terminal", command: "# note").isPlainShell) + #expect(!MobileTaskTemplate(name: "Assignment", icon: "terminal", command: "FOO=bar").isPlainShell) + #expect(!MobileTaskTemplate(name: "Redirect", icon: "terminal", command: "2>&1").isPlainShell) + } + + @Test func agentIconAssetNamesResolveOnlyKnownAgents() { + #expect(MobileTaskTemplate.agentIconAssetName(for: "agent:claude") == "Claude") + #expect(MobileTaskTemplate.agentIconAssetName(for: "agent:codex") == "Codex") + #expect(MobileTaskTemplate.agentIconAssetName(for: "agent:opencode") == "OpenCode") + #expect(MobileTaskTemplate.agentIconAssetName(for: "agent:unknown") == nil) + #expect(MobileTaskTemplate.agentIconAssetName(for: "terminal") == nil) + #expect(MobileTaskTemplate.agentIconAssetName(for: "🚀") == nil) + } + + @Test func templateCodableRoundTripsEditableFields() throws { + let id = UUID() + let template = MobileTaskTemplate( + id: id, + name: "Build", + icon: "hammer", + command: "swift test", + defaultDirectory: "~/code/cmux" + ) + + let data = try JSONEncoder().encode(template) + let decoded = try JSONDecoder().decode(MobileTaskTemplate.self, from: data) + + #expect(decoded == template) + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift index b8b65535a10c..1fad4a9a9065 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift @@ -9,20 +9,9 @@ import CmuxMobileTerminal import SwiftUI import UIKit -/// SwiftUI wrapper that mounts a `GhosttySurfaceView` and routes terminal output -/// chunks into `ghostty_surface_process_output`. Primary-screen output can stay -/// at the phone's natural height, while alternate-screen render-grid replay can -/// pin the surface to the Mac's authoritative grid. -/// -/// The bottom dock (terminal grid / composer band / accessory toolbar / keyboard) -/// is owned entirely by the `GhosttySurfaceView` in one coordinate system. The -/// iMessage-style composer is a SwiftUI view, so it is hosted in a -/// `UIHostingController` and installed into the surface's composer band; this -/// representable is the only layer that can see both the terminal package and the -/// shell-UI composer, so it owns that bridge. The surface owns the band's position -/// and the grid reservation; the host reports the field's measured height back so a -/// field-grow pushes only the terminal up. There is no toolbar handoff and no second -/// layout system reaching into the surface's bottom math. +/// Mounts a `GhosttySurfaceView`, routes terminal output, and bridges the SwiftUI +/// composer into the surface-owned bottom dock. Primary-screen output uses the +/// phone's natural height; alternate-screen replay can pin to the Mac's grid. struct GhosttySurfaceRepresentable: UIViewRepresentable { let workspaceID: String let surfaceID: String diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift index ff63145fd2c6..63819aa102b6 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift @@ -24,10 +24,14 @@ public final class MobileDisplaySettings { private static let showAltScreenNoticeKey = "cmux.mobile.showAltScreenNotice" private static let showMissingFilesKey = "cmux.mobile.showMissingFiles" private static let terminalFilesChipEnabledKey = "cmux.mobile.terminalFilesChipEnabled" + private static let taskComposerEnabledKey = "cmux.mobile.taskComposerEnabled" private static let workspacePreviewLineCountKey = "cmux.mobile.workspacePreviewLineCount" private static let unreadIndicatorLeftShiftKey = "cmux.mobile.debug.unreadIndicatorLeftShift.v2" private static let profilePictureLeftShiftKey = "cmux.mobile.debug.profilePictureLeftShift" private static let profilePictureSizeKey = "cmux.mobile.debug.profilePictureSize" + #if DEBUG + private static let taskComposerShellIconVariantKey = "cmux.mobile.debug.taskComposerShellIconVariant.v1" + #endif /// The preview line counts the "Preview Lines" setting offers. public static let workspacePreviewLineCountRange = 1...2 @@ -76,6 +80,15 @@ public final class MobileDisplaySettings { } } + /// Whether the beta New Task composer is available from the workspace list. + /// Defaults to `false`. Mutating this writes through to the injected + /// ``UserDefaults``. + public var taskComposerEnabled: Bool { + didSet { + defaults.set(taskComposerEnabled, forKey: Self.taskComposerEnabledKey) + } + } + /// How many lines a workspace row's activity preview shows (1 or 2). /// Defaults to 2. Mutating this clamps to the supported range and writes /// through to the injected ``UserDefaults``. @@ -117,6 +130,21 @@ public final class MobileDisplaySettings { } } + #if DEBUG + /// Persisted selection for the debug-only Shell icon lab. + var taskComposerShellIconVariant: TaskComposerShellIconVariant { + didSet { + defaults.set( + taskComposerShellIconVariant.rawValue, + forKey: Self.taskComposerShellIconVariantKey + ) + } + } + #else + /// Production builds expose only the shipping Shell icon treatment. + var taskComposerShellIconVariant: TaskComposerShellIconVariant { .current } + #endif + /// Creates the display settings, seeding stored values from `defaults`. /// - Parameter defaults: The store backing the persisted preferences. /// Defaults to `.standard`; tests pass a scoped suite. Stored properties @@ -129,6 +157,7 @@ public final class MobileDisplaySettings { self.showAltScreenNotice = defaults.object(forKey: Self.showAltScreenNoticeKey) as? Bool ?? true self.showMissingFiles = defaults.bool(forKey: Self.showMissingFilesKey) self.terminalFilesChipEnabled = defaults.bool(forKey: Self.terminalFilesChipEnabledKey) + self.taskComposerEnabled = defaults.bool(forKey: Self.taskComposerEnabledKey) let storedPreviewLines = defaults.object(forKey: Self.workspacePreviewLineCountKey) as? Int self.workspacePreviewLineCount = Self.clampedWorkspacePreviewLineCount( storedPreviewLines ?? Self.defaultWorkspacePreviewLineCount @@ -148,6 +177,11 @@ public final class MobileDisplaySettings { storedProfilePictureSize ?? Self.defaultProfilePictureSize, to: Self.profilePictureSizeRange ) + #if DEBUG + self.taskComposerShellIconVariant = defaults.string( + forKey: Self.taskComposerShellIconVariantKey + ).flatMap(TaskComposerShellIconVariant.init(rawValue:)) ?? .current + #endif } /// Clamps a stored or assigned preview line count to the supported range. diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index 75233db0ef96..7d9258bbcfb1 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -168,6 +168,14 @@ struct MobileSettingsView: View { } Section(L10n.string("mobile.settings.betaFeatures", defaultValue: "Beta Features")) { + Toggle(isOn: $displaySettings.taskComposerEnabled) { + Text(L10n.string( + "mobile.settings.taskComposer", + defaultValue: "New Task Composer" + )) + } + .accessibilityIdentifier("MobileSettingsTaskComposer") + Toggle(isOn: $displaySettings.terminalFilesChipEnabled) { Text(L10n.string( "mobile.settings.terminalFilesChip", @@ -226,6 +234,24 @@ struct MobileSettingsView: View { identifier: "MobileSettingsProfilePictureSize" ) } + + Section(L10n.string( + "mobile.settings.cmuxLabs", + defaultValue: "CMUX Labs" + )) { + NavigationLink { + TaskComposerShellIconLabView() + } label: { + Label( + L10n.string( + "mobile.settings.shellIconLab", + defaultValue: "Shell Icon Lab" + ), + systemImage: "terminal" + ) + } + .accessibilityIdentifier("MobileSettingsShellIconLab") + } #endif Section(L10n.string("mobile.settings.display", defaultValue: "Display")) { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/Claude.png b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/Claude.png new file mode 100644 index 000000000000..8f68dd19f36c Binary files /dev/null and b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/Claude.png differ diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/Claude@2x.png b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/Claude@2x.png new file mode 100644 index 000000000000..a771853bb039 Binary files /dev/null and b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/Claude@2x.png differ diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/Claude@3x.png b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/Claude@3x.png new file mode 100644 index 000000000000..d5ae20d5f136 Binary files /dev/null and b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/Claude@3x.png differ diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/Codex-dark.png b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/Codex-dark.png new file mode 100644 index 000000000000..49b13dca3bf0 Binary files /dev/null and b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/Codex-dark.png differ diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/Codex-dark@2x.png b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/Codex-dark@2x.png new file mode 100644 index 000000000000..206c5cfbb3bd Binary files /dev/null and b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/Codex-dark@2x.png differ diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/Codex-dark@3x.png b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/Codex-dark@3x.png new file mode 100644 index 000000000000..06a7d337f55b Binary files /dev/null and b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/Codex-dark@3x.png differ diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/Codex.png b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/Codex.png new file mode 100644 index 000000000000..41648e60c9ce Binary files /dev/null and b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/Codex.png differ diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/Codex@2x.png b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/Codex@2x.png new file mode 100644 index 000000000000..42ab2f7429f7 Binary files /dev/null and b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/Codex@2x.png differ diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/Codex@3x.png b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/Codex@3x.png new file mode 100644 index 000000000000..e3f91996fb35 Binary files /dev/null and b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/Codex@3x.png differ diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/OpenCode.png b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/OpenCode.png new file mode 100644 index 000000000000..9c1d94ad2ca3 Binary files /dev/null and b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/OpenCode.png differ diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/OpenCode@2x.png b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/OpenCode@2x.png new file mode 100644 index 000000000000..61b2e23aef95 Binary files /dev/null and b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/OpenCode@2x.png differ diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/OpenCode@3x.png b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/OpenCode@3x.png new file mode 100644 index 000000000000..f95f02a5be04 Binary files /dev/null and b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/AgentIcons/OpenCode@3x.png differ diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAccessibilityPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAccessibilityPreviewView.swift new file mode 100644 index 000000000000..ec1400f0cc6c --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAccessibilityPreviewView.swift @@ -0,0 +1,440 @@ +#if os(iOS) && DEBUG +import CmuxMobilePairedMac +import CmuxMobileRPC +import CmuxMobileShell +import CmuxMobileSupport +import Foundation +import SwiftUI + +/// Deterministic host for accessibility UI tests. It presents the production +/// composer as a real sheet, including its iPad presentation behavior. +public struct TaskComposerAccessibilityPreviewView: View { + @State private var isPresented = false + @State private var draftWasPersistedAtSubmit: Bool? + @State private var submittedMacDeviceID: String? + @State private var submittedSpec: MobileWorkspaceCreateSpec? + @State private var submissionAttempts: [TaskComposerSubmissionAttempt] = [] + @State private var selectedDirectory: String? + private let store: CMUXMobileShellStore + private let returnsSubmissionFailure: Bool + private let failsFirstSubmission: Bool + private let presentsTemplateForm: Bool + private let presentsDirectoryPicker: Bool + private let presentsDirectoryScrollStress: Bool + private let holdsSubmissionInPreparation: Bool + @State private var directoryPaginationRecoveryPreview: TaskComposerDirectoryPaginationRecoveryPreview? + + /// Creates the preview with isolated, in-memory task state so repeated UI + /// tests cannot inherit production templates, selections, or drafts. Set + /// `CMUX_UITEST_TASK_COMPOSER_FAILURE=1` to exercise a persistent failure, + /// `CMUX_UITEST_TASK_COMPOSER_FAIL_ONCE=1` to exercise a successful retry, + /// or `CMUX_UITEST_TASK_TEMPLATE_FORM_PREVIEW=1` to present the production + /// add-template form directly. Set + /// `CMUX_UITEST_TASK_DIRECTORY_PICKER_PREVIEW=1` to present the production + /// directory picker with deterministic filesystem results. Set + /// `CMUX_UITEST_TASK_DIRECTORY_PAGINATION_RECOVERY_PREVIEW=1` to make the + /// first page-2 request fail and its exact retry succeed. + public init() { + let environment = ProcessInfo.processInfo.environment + let presentsDirectoryPaginationRecovery = environment[ + "CMUX_UITEST_TASK_DIRECTORY_PAGINATION_RECOVERY_PREVIEW" + ] == "1" + let presentsDirectoryScrollStress = environment[ + "CMUX_UITEST_TASK_DIRECTORY_SCROLL_STRESS" + ] == "1" + self.store = CMUXMobileShellStore( + isSignedIn: true, + taskTemplateStore: TaskComposerAccessibilityTemplateStore() + ) + self.returnsSubmissionFailure = environment[ + "CMUX_UITEST_TASK_COMPOSER_FAILURE" + ] == "1" + self.failsFirstSubmission = environment[ + "CMUX_UITEST_TASK_COMPOSER_FAIL_ONCE" + ] == "1" + self.presentsTemplateForm = environment[ + "CMUX_UITEST_TASK_TEMPLATE_FORM_PREVIEW" + ] == "1" + self.presentsDirectoryPicker = environment[ + "CMUX_UITEST_TASK_DIRECTORY_PICKER_PREVIEW" + ] == "1" || presentsDirectoryPaginationRecovery + self.presentsDirectoryScrollStress = presentsDirectoryScrollStress + self.holdsSubmissionInPreparation = environment[ + "CMUX_UITEST_TASK_COMPOSER_HOLD_PREPARATION" + ] == "1" + _directoryPaginationRecoveryPreview = State( + initialValue: presentsDirectoryPaginationRecovery + ? TaskComposerDirectoryPaginationRecoveryPreview() + : nil + ) + } + + /// Presents the requested production task-composer surface over an otherwise empty host. + public var body: some View { + Color.clear + .onAppear { isPresented = true } + .overlay { + if let submittedMacDeviceID, let submittedSpec { + TaskComposerSubmissionProbe( + macDeviceID: submittedMacDeviceID, + spec: submittedSpec + ) + } + if let selectedDirectory { + Text(verbatim: selectedDirectory) + .accessibilityIdentifier("MobileTaskComposerSelectedDirectory") + } + if !isPresented { + TaskComposerSubmissionHistoryProbe(attempts: submissionAttempts) + } + } + .sheet(isPresented: $isPresented) { + if presentsTemplateForm { + TaskTemplateFormView(template: nil, onSave: { _ in }) + } else if presentsDirectoryPicker { + TaskComposerDirectoryPickerView( + candidates: [], + selectedPath: selectedDirectory ?? "~", + select: { selectedDirectory = $0 }, + searchMac: Self.searchPreviewDirectories, + listMac: listDirectoriesForPreview + ) + } else { + TaskComposerSheet( + store: store, + availableMachines: [Self.previewMac, Self.backupPreviewMac], + submitTaskComposer: { macDeviceID, spec, willStartCreate in + let attemptNumber = submissionAttempts.count + 1 + submittedMacDeviceID = macDeviceID + submittedSpec = spec + submissionAttempts.append(TaskComposerSubmissionAttempt( + id: attemptNumber, + operationID: spec.operationID?.uuidString ?? "", + prompt: spec.initialEnv?["CMUX_TASK_PROMPT"] ?? "" + )) + draftWasPersistedAtSubmit = store.taskTemplateStore?.composerDraft() != nil + if holdsSubmissionInPreparation { + do { + try await Task.sleep(for: .seconds(30)) + } catch { + return .failure(.notConnected(hostDisplayName: "Preview Mac")) + } + } + willStartCreate() + if returnsSubmissionFailure { + return .failure(.invalidWorkingDirectory(hostDisplayName: "Preview Mac")) + } + if failsFirstSubmission { + if attemptNumber == 1 { + return .failure(.invalidWorkingDirectory(hostDisplayName: "Preview Mac")) + } + do { + try await Task.sleep(for: .seconds(2)) + } catch { + return .failure(.notConnected(hostDisplayName: "Preview Mac")) + } + } + return .success(()) + }, + searchTaskDirectories: { _, query in + await Self.searchPreviewDirectories(query) + }, + listTaskDirectories: { _, path, offset in + await listDirectoriesForPreview(path, offset) + } + ) + .overlay(alignment: .top) { + VStack(spacing: 0) { + if let draftWasPersistedAtSubmit { + Text( + draftWasPersistedAtSubmit + ? L10n.string( + "mobile.taskComposer.debug.draftPersisted", + defaultValue: "persisted" + ) + : L10n.string( + "mobile.taskComposer.debug.draftMissing", + defaultValue: "missing" + ) + ) + .accessibilityIdentifier("MobileTaskComposerSubmissionDraftState") + } + TaskComposerSubmissionHistoryProbe(attempts: submissionAttempts) + } + } + } + } + } + + private static let previewMac = MobilePairedMac( + macDeviceID: "task-composer-preview-mac", + displayName: "Preview Mac", + routes: [], + createdAt: Date(timeIntervalSince1970: 0), + lastSeenAt: Date(timeIntervalSince1970: 0), + isActive: true, + stackUserID: nil + ) + + private static let backupPreviewMac = MobilePairedMac( + macDeviceID: "task-composer-backup-preview-mac", + displayName: "Backup Preview Mac", + routes: [], + createdAt: Date(timeIntervalSince1970: 1), + lastSeenAt: Date(timeIntervalSince1970: 1), + isActive: true, + stackUserID: nil + ) + + private static func searchPreviewDirectories( + _ query: String + ) async -> Result { + let paths = [ + "/Users/ui/mobile-root", + "/Users/ui/mobile-root/Sources", + "/Users/ui/mobile-root-archive", + ] + let matches = paths.filter { $0.localizedCaseInsensitiveContains(query) } + return .success(MobileTaskDirectorySearchResponse( + directories: matches, + searchScope: .allIndexedVolumes, + gatheringComplete: true, + filesystemComplete: false, + truncated: false, + indexedMatchCount: matches.count + )) + } + + private static func listPreviewDirectories( + _ requestedPath: String, + _ offset: Int + ) async -> Result { + let currentPath: String + let parentPath: String? + let specs: [(String, String, Bool, Bool, Bool, Bool)] + switch requestedPath { + case "~", "/Users/ui": + currentPath = "/Users/ui" + parentPath = "/Users" + specs = [ + (".hidden", "/Users/ui/.hidden", true, false, false, true), + ("Projects.app", "/Users/ui/Projects.app", false, true, false, true), + ("mobile-link", "/Users/ui/mobile-link", false, false, true, true), + ("mobile-root", "/Users/ui/mobile-root", false, false, false, true), + ] + case "/": + currentPath = "/" + parentPath = nil + specs = [ + ("Users", "/Users", false, false, false, true), + ("Volumes", "/Volumes", false, false, false, true), + ] + case "/Users/ui/mobile-root": + currentPath = requestedPath + parentPath = "/Users/ui" + specs = [ + ("Sources", "/Users/ui/mobile-root/Sources", false, false, false, true), + ] + default: + currentPath = requestedPath + parentPath = URL(fileURLWithPath: requestedPath).deletingLastPathComponent().path + specs = [] + } + + let entries = specs.compactMap { spec in + MobileTaskDirectoryListEntry( + name: spec.0, + path: spec.1, + isHidden: spec.2, + isPackage: spec.3, + isSymbolicLink: spec.4, + isReadable: spec.5 + ) + } + guard let response = MobileTaskDirectoryListResponse( + currentPath: currentPath, + parentPath: parentPath, + entries: Array(entries.dropFirst(offset)), + offset: offset, + limit: 50, + totalCount: entries.count, + nextOffset: nil + ) else { + return .failure(.rejected) + } + return .success(response) + } + + private func listDirectoriesForPreview( + _ requestedPath: String, + _ offset: Int + ) async -> Result { + if presentsDirectoryScrollStress { + return Self.listScrollStressDirectories(requestedPath, offset) + } + if let directoryPaginationRecoveryPreview { + return await directoryPaginationRecoveryPreview.listDirectories( + requestedPath, + offset + ) + } + return await Self.listPreviewDirectories(requestedPath, offset) + } + + private static func listScrollStressDirectories( + _ requestedPath: String, + _ offset: Int + ) -> Result { + guard requestedPath == "~" || requestedPath == "/Users/ui", + offset == 0 else { + return .failure(.rejected) + } + let entries = (0..<50).compactMap { index in + let name = String(format: "folder-%02d", index) + return MobileTaskDirectoryListEntry( + name: name, + path: "/Users/ui/\(name)", + isHidden: false, + isPackage: false, + isSymbolicLink: false, + isReadable: true + ) + } + guard entries.count == 50, + let response = MobileTaskDirectoryListResponse( + currentPath: "/Users/ui", + parentPath: "/Users", + entries: entries, + offset: 0, + limit: 50, + totalCount: entries.count, + nextOffset: nil + ) else { + return .failure(.rejected) + } + return .success(response) + } +} + +private actor TaskComposerDirectoryPaginationRecoveryPreview { + private struct AppendRequest: Equatable { + let path: String + let offset: Int + } + + private var failedAppendRequest: AppendRequest? + + func listDirectories( + _ requestedPath: String, + _ offset: Int + ) -> Result { + if offset == 0 { + guard requestedPath == "~" || requestedPath == "/Users/ui" else { + return .failure(.rejected) + } + return Self.page( + entries: [ + ("first-page-folder", "/Users/ui/first-page-folder", true), + ("unreadable-page-one", "/Users/ui/unreadable-page-one", false), + ], + offset: 0, + nextOffset: 2 + ) + } + + let request = AppendRequest(path: requestedPath, offset: offset) + let expectedRequest = AppendRequest(path: "/Users/ui", offset: 2) + guard request == expectedRequest else { + return .failure(.rejected) + } + guard let failedAppendRequest else { + self.failedAppendRequest = request + return .failure(.timedOut) + } + guard request == failedAppendRequest else { + return .failure(.rejected) + } + + return Self.page( + entries: [ + ("z-second-page-folder", "/Users/ui/z-second-page-folder", true), + ], + offset: 2, + nextOffset: nil + ) + } + + private static func page( + entries: [(name: String, path: String, isReadable: Bool)], + offset: Int, + nextOffset: Int? + ) -> Result { + let directoryEntries = entries.compactMap { entry in + MobileTaskDirectoryListEntry( + name: entry.name, + path: entry.path, + isHidden: false, + isPackage: false, + isSymbolicLink: false, + isReadable: entry.isReadable + ) + } + guard directoryEntries.count == entries.count, + let response = MobileTaskDirectoryListResponse( + currentPath: "/Users/ui", + parentPath: "/Users", + entries: directoryEntries, + offset: offset, + limit: 2, + totalCount: 3, + nextOffset: nextOffset + ) else { + return .failure(.rejected) + } + return .success(response) + } +} + +private struct TaskComposerSubmissionAttempt: Identifiable { + let id: Int + let operationID: String + let prompt: String +} + +private struct TaskComposerSubmissionHistoryProbe: View { + let attempts: [TaskComposerSubmissionAttempt] + + var body: some View { + VStack(spacing: 0) { + ForEach(attempts) { attempt in + Text(verbatim: attempt.operationID) + .accessibilityIdentifier("MobileTaskComposerSubmittedOperationID-\(attempt.id)") + Text(verbatim: attempt.prompt) + .accessibilityIdentifier("MobileTaskComposerSubmittedPrompt-\(attempt.id)") + } + } + .allowsHitTesting(false) + } +} + +private struct TaskComposerSubmissionProbe: View { + let macDeviceID: String + let spec: MobileWorkspaceCreateSpec + + var body: some View { + VStack { + Text(verbatim: macDeviceID) + .accessibilityIdentifier("MobileTaskComposerSubmittedMacDeviceID") + Text(verbatim: spec.workingDirectory ?? "") + .accessibilityIdentifier("MobileTaskComposerSubmittedWorkingDirectory") + Text(verbatim: spec.initialCommand ?? "") + .accessibilityIdentifier("MobileTaskComposerSubmittedInitialCommand") + Text(verbatim: spec.initialEnv?["CMUX_TASK_PROMPT"] ?? "") + .accessibilityIdentifier("MobileTaskComposerSubmittedPrompt") + Text(verbatim: spec.operationID?.uuidString ?? "") + .accessibilityIdentifier("MobileTaskComposerSubmittedOperationID") + } + } +} + +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAccessibilityTemplateStore.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAccessibilityTemplateStore.swift new file mode 100644 index 000000000000..fb795f5ac423 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAccessibilityTemplateStore.swift @@ -0,0 +1,96 @@ +#if os(iOS) && DEBUG +import CmuxMobileShell +import CmuxMobileShellModel +import CmuxMobileSupport +import Foundation + +@MainActor +final class TaskComposerAccessibilityTemplateStore: MobileTaskTemplateStoring { + private var templates = MobileTaskTemplate.seedDefaults( + claudeName: L10n.string("mobile.taskComposer.template.seed.claude", defaultValue: "Claude"), + codexName: L10n.string("mobile.taskComposer.template.seed.codex", defaultValue: "Codex"), + openCodeName: L10n.string("mobile.taskComposer.template.seed.opencode", defaultValue: "OpenCode"), + shellName: L10n.string("mobile.taskComposer.template.seed.shell", defaultValue: "Shell") + ) + private var selectedTemplateID: MobileTaskTemplate.ID? + private var selectedMacDeviceID: String? + private var directoriesByMacDeviceID: [String: String] = [:] + private var recentsByMacDeviceID: [String: [MobileTaskRecentDirectory]] = [:] + private var draft: MobileTaskComposerDraft? + + func listTemplates() -> [MobileTaskTemplate] { + templates + } + + func addTemplate(_ template: MobileTaskTemplate) { + templates.append(template) + } + + func updateTemplate(_ template: MobileTaskTemplate) { + guard let index = templates.firstIndex(where: { $0.id == template.id }) else { return } + templates[index] = template + } + + func deleteTemplates(ids: Set) { + templates.removeAll { ids.contains($0.id) } + if let selectedID = selectedTemplateID, ids.contains(selectedID) { + selectedTemplateID = nil + } + } + + func lastTemplateID() -> MobileTaskTemplate.ID? { + selectedTemplateID + } + + func setLastTemplateID(_ id: MobileTaskTemplate.ID?) { + selectedTemplateID = id + } + + func lastMacDeviceID() -> String? { + selectedMacDeviceID + } + + func setLastMacDeviceID(_ id: String?) { + selectedMacDeviceID = id + } + + func lastDirectory(macDeviceID: String) -> String? { + directoriesByMacDeviceID[macDeviceID] + } + + func setLastDirectory(_ directory: String?, macDeviceID: String) { + directoriesByMacDeviceID[macDeviceID] = directory + } + + func recentDirectories(macDeviceID: String) -> [MobileTaskRecentDirectory] { + recentsByMacDeviceID[macDeviceID] ?? [] + } + + func recordRecentDirectory(_ directory: String, macDeviceID: String, at date: Date) { + var recents = recentDirectories(macDeviceID: macDeviceID) + let id = MobileTaskDirectoryPathID(path: directory) + let count = recents.first { MobileTaskDirectoryPathID(path: $0.path) == id }?.useCount ?? 0 + recents.removeAll { MobileTaskDirectoryPathID(path: $0.path) == id } + let nextCount = count == Int.max ? Int.max : count + 1 + recents.insert(.init(path: directory, lastUsedAt: date, useCount: nextCount), at: 0) + recentsByMacDeviceID[macDeviceID] = Array(recents.prefix(20)) + } + + func composerDraft() -> MobileTaskComposerDraft? { + draft + } + + func setComposerDraft(_ draft: MobileTaskComposerDraft?) { + self.draft = draft + } + + func clearAllUserData() { + templates.removeAll() + selectedTemplateID = nil + selectedMacDeviceID = nil + directoriesByMacDeviceID.removeAll() + recentsByMacDeviceID.removeAll() + draft = nil + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenu.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenu.swift new file mode 100644 index 000000000000..ff3a2a565746 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenu.swift @@ -0,0 +1,129 @@ +#if os(iOS) +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI + +/// Keeps the selected agent attached to the prompt while leaving every +/// template and the editor one tap away. +struct TaskComposerAgentMenu: View { + let templates: [MobileTaskTemplate] + let selectedTemplateID: MobileTaskTemplate.ID? + let isDisabled: Bool + let selectTemplate: (MobileTaskTemplate) -> Void + let editTemplates: () -> Void + + private var selectedTemplate: MobileTaskTemplate? { + selectedTemplateID.flatMap { id in + templates.first { $0.id == id } + } + } + + private var templateSelection: Binding { + Binding( + get: { selectedTemplateID }, + set: { id in + guard let id, + let template = templates.first(where: { $0.id == id }) else { return } + selectTemplate(template) + } + ) + } + + var body: some View { + Menu { + if !templates.isEmpty { + Picker( + L10n.string("mobile.taskComposer.agent", defaultValue: "Agent"), + selection: templateSelection + ) { + ForEach(templates) { template in + Text(template.name) + .tag(Optional(template.id)) + } + } + .pickerStyle(.inline) + .labelsHidden() + } + + Divider() + + Button(action: editTemplates) { + Label( + L10n.string( + "mobile.taskComposer.agent.edit", + defaultValue: "Edit Agents" + ), + systemImage: "slider.horizontal.3" + ) + } + .accessibilityIdentifier("MobileTaskComposerEditTemplatesButton") + } label: { + HStack(spacing: 10) { + if let selectedTemplate { + TaskTemplateIcon(value: selectedTemplate.icon, size: 18) + .frame(width: 32, height: 32) + .background(Color.accentColor.opacity(0.11), in: Circle()) + .accessibilityHidden(true) + + Text(title(for: selectedTemplate)) + .font(.headline) + .foregroundStyle(.primary) + .lineLimit(1) + .minimumScaleFactor(0.82) + } else { + Image(systemName: "person.crop.circle.badge.exclamationmark") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 32, height: 32) + .background(Color.primary.opacity(0.055), in: Circle()) + .accessibilityHidden(true) + + Text( + L10n.string( + "mobile.taskComposer.validation.template", + defaultValue: "Add an agent before starting a task." + ) + ) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Image(systemName: "chevron.up.chevron.down") + .font(.caption2.weight(.bold)) + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + } + .frame(minHeight: 44) + .contentShape(Rectangle()) + // Keep the chrome around the prompt compact while allowing the + // menu's choices to retain the caller's full Dynamic Type size. + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) + } + .buttonStyle(.plain) + // Keep the menu reachable when every template has been deleted so the + // editor remains the recovery path for adding an agent. + .disabled(isDisabled) + .accessibilityLabel(L10n.string("mobile.taskComposer.agent", defaultValue: "Agent")) + .accessibilityValue(selectedTemplate?.name ?? "") + .accessibilityHint(TaskComposerSheet.templateAccessibilityHint) + .accessibilityIdentifier("MobileTaskComposerAgentMenu") + } + + private func title(for template: MobileTaskTemplate) -> String { + if template.isPlainShell { + return L10n.string( + "mobile.taskComposer.promptTitle.shell", + defaultValue: "Shell command" + ) + } + return String( + format: L10n.string( + "mobile.taskComposer.promptTitle.agentFormat", + defaultValue: "Ask %@" + ), + template.name + ) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerButton.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerButton.swift new file mode 100644 index 000000000000..5cc6472102d4 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerButton.swift @@ -0,0 +1,36 @@ +#if os(iOS) +import CmuxMobileSupport +import SwiftUI + +struct TaskComposerButton: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: "sparkles") + .font(.system(size: 16, weight: .semibold)) + .accessibilityHidden(true) + Text( + L10n.string( + "mobile.taskComposer.button.title", + defaultValue: "New task" + ) + ) + .font(.subheadline.weight(.semibold)) + } + .padding(.horizontal, 17) + .frame(minHeight: 52) + .contentShape(.capsule) + } + .buttonStyle(.plain) + .foregroundStyle(.primary) + .mobileGlassPill() + .accessibilityLabel(L10n.string("mobile.taskComposer.button.accessibilityLabel", defaultValue: "New Task")) + .accessibilityHint( + L10n.string("mobile.taskComposer.button.accessibilityHint", defaultValue: "Opens the task composer.") + ) + .accessibilityIdentifier("MobileTaskComposerButton") + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerContextSection.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerContextSection.swift new file mode 100644 index 000000000000..53481ea27564 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerContextSection.swift @@ -0,0 +1,170 @@ +#if os(iOS) +import CmuxMobilePairedMac +import CmuxMobileSupport +import SwiftUI + +/// Keeps the Mac and folder on one compact route so both remain visible while typing. +struct TaskComposerContextSection: View { + let machines: [MobilePairedMac] + let selectedMacDeviceID: String + let directory: String + let isDisabled: Bool + let selectMachine: (MobilePairedMac) -> Void + let selectDirectory: () -> Void + + private var selectedMachine: MobilePairedMac? { + machines.first { $0.macDeviceID == selectedMacDeviceID } + } + + var body: some View { + HStack(spacing: 8) { + machinePicker + + Image(systemName: "arrow.right") + .font(.caption2.weight(.bold)) + .foregroundStyle(Color.accentColor) + .frame(width: 24, height: 24) + .background(Color.accentColor.opacity(0.11), in: Circle()) + .accessibilityHidden(true) + + Button(action: selectDirectory) { + HStack(spacing: 8) { + contextSymbol("folder.fill", tint: .blue) + VStack(alignment: .leading, spacing: 1) { + Text(L10n.string("mobile.taskComposer.directory", defaultValue: "Directory")) + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.75) + Text(directory) + .font(.system(.caption, design: .monospaced, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + .minimumScaleFactor(0.72) + .truncationMode(.middle) + } + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.caption2.weight(.bold)) + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + } + .frame(maxWidth: .infinity, minHeight: 52, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isDisabled) + .accessibilityLabel(L10n.string("mobile.taskComposer.directory", defaultValue: "Directory")) + .accessibilityValue(directory) + .accessibilityHint( + L10n.string( + "mobile.taskComposer.directoryPicker.hint", + defaultValue: "Browses and searches folders on this Mac." + ) + ) + .accessibilityIdentifier("MobileTaskComposerDirectory") + } + .padding(10) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 22, style: .continuous) + .strokeBorder(Color.primary.opacity(0.07), lineWidth: 1) + .allowsHitTesting(false) + } + .shadow(color: Color.black.opacity(0.04), radius: 12, y: 5) + } + + @ViewBuilder + private var machinePicker: some View { + if machines.isEmpty { + HStack(spacing: 8) { + contextSymbol("desktopcomputer.trianglebadge.exclamationmark", tint: .orange) + VStack(alignment: .leading, spacing: 2) { + Text(L10n.string("mobile.taskComposer.machine.none", defaultValue: "No paired Macs")) + .font(.caption.weight(.semibold)) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, minHeight: 52, alignment: .leading) + } else { + Menu { + ForEach(machines) { mac in + Button { + selectMachine(mac) + } label: { + Label(mac.resolvedName, systemImage: "desktopcomputer") + } + .accessibilityAddTraits(mac.macDeviceID == selectedMacDeviceID ? .isSelected : []) + } + } label: { + HStack(spacing: 8) { + if let selectedMachine { + machineIcon(selectedMachine) + } else { + contextSymbol("desktopcomputer", tint: .accentColor) + } + VStack(alignment: .leading, spacing: 1) { + Text(L10n.string("mobile.taskComposer.machine", defaultValue: "Machine")) + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.75) + Text(selectedMachine?.resolvedName ?? selectedMacDeviceID) + .font(.caption.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + .minimumScaleFactor(0.72) + } + Spacer(minLength: 0) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2.weight(.bold)) + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + } + .frame(maxWidth: .infinity, minHeight: 52, alignment: .leading) + .contentShape(Rectangle()) + } + .disabled(isDisabled) + .accessibilityLabel(L10n.string("mobile.taskComposer.machine", defaultValue: "Machine")) + .accessibilityValue(selectedMachine?.resolvedName ?? selectedMacDeviceID) + .accessibilityHint(TaskComposerSheet.machineAccessibilityHint) + .accessibilityIdentifier("MobileTaskComposerMachineMenu") + } + } + + private func contextSymbol(_ name: String, tint: Color) -> some View { + Image(systemName: name) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 28, height: 28) + .background(tint.opacity(0.12), in: Circle()) + .accessibilityHidden(true) + } + + private func machineIcon(_ mac: MobilePairedMac) -> some View { + ZStack { + Circle() + .fill( + MachineAvatarColors.gradient( + customColor: mac.customColor, + fallbackIndex: nil, + machineID: mac.macDeviceID, + fallbackID: mac.id + ) + ) + switch MacAvatarIcon.resolve(custom: mac.customIcon, defaultSymbol: "desktopcomputer") { + case .symbol(let name): + Image(systemName: name) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + case .emoji(let emoji): + Text(emoji) + .font(.system(size: 17)) + } + } + .frame(width: 28, height: 28) + .accessibilityHidden(true) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerDirectoryBrowseState.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerDirectoryBrowseState.swift new file mode 100644 index 000000000000..4d1cb97610e5 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerDirectoryBrowseState.swift @@ -0,0 +1,255 @@ +#if os(iOS) +import CmuxMobileRPC +import CmuxMobileShell +import Foundation + +struct TaskComposerDirectoryBrowseState: Equatable, Sendable { + enum LoadKind: Equatable, Hashable, Sendable { + case replace + case append + } + + struct LoadRequest: Equatable, Hashable, Sendable { + let path: String + let offset: Int + let expectedCurrentPath: String? + let kind: LoadKind + let generation: Int + } + + struct LoadFailure: Equatable, Sendable { + let reason: MobileTaskDirectoryListFailure + let request: LoadRequest + } + + struct Snapshot: Equatable, Sendable { + let currentPath: String + let parentPath: String? + let entries: [MobileTaskDirectoryListEntry] + let nextOffset: Int? + let totalCount: Int + } + + private(set) var snapshot: Snapshot? + private(set) var pendingRequest: LoadRequest? + private(set) var failure: LoadFailure? + private(set) var generation = 0 + + init(initialPath: String) { + let path = Self.trimmedPath(initialPath) + beginLoad( + path: path.isEmpty ? "~" : path, + offset: 0, + expectedCurrentPath: Self.expectedCurrentPath(for: path), + kind: .replace + ) + } + + var isLoading: Bool { + pendingRequest != nil + } + + var displayPath: String { + snapshot?.currentPath + ?? pendingRequest?.path + ?? failure?.request.path + ?? "~" + } + + @discardableResult + mutating func navigate(to path: String) -> Bool { + let path = Self.trimmedPath(path) + guard !path.isEmpty else { return false } + + let expectedCurrentPath = Self.expectedCurrentPath(for: path) + if let pendingRequest, + pendingRequest.kind == .replace, + pendingRequest.path == path { + return false + } + if pendingRequest == nil, + failure == nil, + let expectedCurrentPath, + snapshot?.currentPath == expectedCurrentPath { + return false + } + + beginLoad( + path: path, + offset: 0, + expectedCurrentPath: expectedCurrentPath, + kind: .replace + ) + return true + } + + @discardableResult + mutating func requestNextPage() -> Bool { + guard pendingRequest == nil, + failure == nil, + let snapshot, + let nextOffset = snapshot.nextOffset else { + return false + } + + beginLoad( + path: snapshot.currentPath, + offset: nextOffset, + expectedCurrentPath: snapshot.currentPath, + kind: .append + ) + return true + } + + @discardableResult + mutating func retryFailedRequest() -> Bool { + guard let failedRequest = failure?.request else { return false } + + beginLoad( + path: failedRequest.path, + offset: failedRequest.offset, + expectedCurrentPath: failedRequest.expectedCurrentPath, + kind: failedRequest.kind + ) + return true + } + + mutating func resolve( + _ result: Result, + for request: LoadRequest + ) { + guard pendingRequest == request else { return } + + switch result { + case let .success(page): + receive(page, for: request) + case .failure(.cancelled): + cancel(request) + case let .failure(reason): + fail(reason, request: request) + } + } + + mutating func cancel(_ request: LoadRequest) { + guard pendingRequest == request else { return } + pendingRequest = nil + } + + func navigationDestination(for entry: MobileTaskDirectoryListEntry) -> String? { + entry.isReadable ? entry.path : nil + } + + private mutating func beginLoad( + path: String, + offset: Int, + expectedCurrentPath: String?, + kind: LoadKind + ) { + generation &+= 1 + pendingRequest = LoadRequest( + path: path, + offset: offset, + expectedCurrentPath: expectedCurrentPath, + kind: kind, + generation: generation + ) + failure = nil + } + + private mutating func receive( + _ page: MobileTaskDirectoryListResponse, + for request: LoadRequest + ) { + guard page.offset == request.offset else { + fail(.rejected, request: request) + return + } + + switch request.kind { + case .replace: + guard request.offset == 0, + request.expectedCurrentPath.map({ $0 == page.currentPath }) ?? true else { + fail(.rejected, request: request) + return + } + snapshot = Snapshot( + currentPath: page.currentPath, + parentPath: page.parentPath, + entries: page.entries, + nextOffset: page.nextOffset, + totalCount: page.totalCount + ) + case .append: + guard let previous = snapshot, + let expectedCurrentPath = request.expectedCurrentPath, + request.path == previous.currentPath, + expectedCurrentPath == previous.currentPath, + page.currentPath == expectedCurrentPath, + page.parentPath == previous.parentPath, + previous.nextOffset == request.offset, + previous.entries.count == request.offset, + page.totalCount == previous.totalCount, + !Self.hasCrossPageDuplicate( + existing: previous.entries, + incoming: page.entries + ), + Self.isOrderedAcrossPageBoundary( + existing: previous.entries, + incoming: page.entries + ) else { + fail(.rejected, request: request) + return + } + snapshot = Snapshot( + currentPath: previous.currentPath, + parentPath: previous.parentPath, + entries: previous.entries + page.entries, + nextOffset: page.nextOffset, + totalCount: page.totalCount + ) + } + + pendingRequest = nil + failure = nil + } + + private mutating func fail( + _ reason: MobileTaskDirectoryListFailure, + request: LoadRequest + ) { + guard pendingRequest == request else { return } + pendingRequest = nil + failure = LoadFailure(reason: reason, request: request) + } + + private static func trimmedPath(_ path: String) -> String { + path.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func expectedCurrentPath(for requestedPath: String) -> String? { + guard requestedPath.hasPrefix("/") else { return nil } + return URL(fileURLWithPath: requestedPath, isDirectory: true) + .standardizedFileURL.path + } + + private static func hasCrossPageDuplicate( + existing: [MobileTaskDirectoryListEntry], + incoming: [MobileTaskDirectoryListEntry] + ) -> Bool { + let existingPaths = Set(existing.map(\.path)) + return incoming.contains { existingPaths.contains($0.path) } + } + + private static func isOrderedAcrossPageBoundary( + existing: [MobileTaskDirectoryListEntry], + incoming: [MobileTaskDirectoryListEntry] + ) -> Bool { + guard let previous = existing.last, let next = incoming.first else { return true } + if previous.name.utf8.lexicographicallyPrecedes(next.name.utf8) { + return true + } + guard previous.name == next.name else { return false } + return previous.path.utf8.lexicographicallyPrecedes(next.path.utf8) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerDirectoryCandidates.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerDirectoryCandidates.swift new file mode 100644 index 000000000000..802e159a6dd8 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerDirectoryCandidates.swift @@ -0,0 +1,83 @@ +#if os(iOS) +import CmuxMobileShell +import CmuxMobileShellModel +import Foundation + +@MainActor +struct TaskComposerDirectoryCandidates { + let store: CMUXMobileShellStore + let selectedMacDeviceID: String + let selectedTemplate: MobileTaskTemplate? + + func make() -> [MobileTaskDirectoryCandidate] { + var candidates: [MobileTaskDirectoryCandidate] = [] + append( + selectedTemplate?.defaultDirectory, + source: .templateDefault, + context: selectedTemplate?.name, + to: &candidates + ) + append( + store.taskTemplateStore?.lastDirectory(macDeviceID: selectedMacDeviceID), + source: .lastSuccessful, + context: nil, + to: &candidates + ) + for recent in store.taskTemplateStore?.recentDirectories(macDeviceID: selectedMacDeviceID) ?? [] { + append( + recent.path, + source: .recentSuccessful, + context: nil, + lastUsedAt: recent.lastUsedAt, + useCount: recent.useCount, + to: &candidates + ) + } + + let includeUnscoped = selectedMacDeviceID == store.connectedMacDeviceID + for workspace in store.workspaces where workspace.macDeviceID == selectedMacDeviceID + || (workspace.macDeviceID == nil && includeUnscoped) { + let isActiveWorkspace = workspace.id == store.selectedWorkspaceID + append( + workspace.currentDirectory, + source: isActiveWorkspace ? .activeWorkspace : .openWorkspace, + context: workspace.name, + lastUsedAt: workspace.lastActivityAt, + to: &candidates + ) + for terminal in workspace.terminals { + append( + terminal.currentDirectory, + source: isActiveWorkspace && terminal.isFocused ? .activeTerminal : .openTerminal, + context: "\(workspace.name) · \(terminal.name)", + lastUsedAt: workspace.lastActivityAt, + to: &candidates + ) + } + } + + append("~", source: .home, context: nil, to: &candidates) + return candidates + } + + private func append( + _ rawPath: String?, + source: MobileTaskDirectorySource, + context: String?, + lastUsedAt: Date? = nil, + useCount: Int = 0, + to candidates: inout [MobileTaskDirectoryCandidate] + ) { + guard let path = rawPath?.trimmingCharacters(in: .whitespacesAndNewlines), !path.isEmpty else { + return + } + candidates.append(MobileTaskDirectoryCandidate( + path: path, + source: source, + context: context, + lastUsedAt: lastUsedAt, + useCount: useCount + )) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerDirectoryDisplayPath.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerDirectoryDisplayPath.swift new file mode 100644 index 000000000000..27ede623c9e0 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerDirectoryDisplayPath.swift @@ -0,0 +1,28 @@ +struct TaskComposerDirectoryDisplayPath: Equatable, Sendable { + let name: String + let parentPath: String? + + init(path: String) { + var normalizedPath = path + while normalizedPath.count > 1, normalizedPath.last == "/" { + normalizedPath.removeLast() + } + + guard normalizedPath != "/" else { + name = "/" + parentPath = nil + return + } + + guard let separator = normalizedPath.lastIndex(of: "/") else { + name = normalizedPath + parentPath = nil + return + } + + let nameStart = normalizedPath.index(after: separator) + name = String(normalizedPath[nameStart...]) + let parent = String(normalizedPath[.. Void + private let searchMac: ( + String + ) async -> Result + private let listMac: ( + _ path: String, + _ offset: Int + ) async -> Result + + init( + candidates: [MobileTaskDirectoryCandidate], + selectedPath: String, + select: @escaping (String) -> Void, + searchMac: @escaping ( + String + ) async -> Result, + listMac: @escaping ( + _ path: String, + _ offset: Int + ) async -> Result + ) { + self.candidates = candidates + selectedPathID = MobileTaskDirectoryPathID(path: selectedPath) + self.select = select + self.searchMac = searchMac + self.listMac = listMac + _browseState = State( + initialValue: TaskComposerDirectoryBrowseState(initialPath: selectedPath) + ) + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + if isSearchMode { + searchContent + } else { + browseContent + } + } + .padding(.horizontal, 16) + .padding(.top, 10) + .padding(.bottom, 24) + } + .scrollDismissesKeyboard(.interactively) + .background { + Color(uiColor: .systemGroupedBackground) + .ignoresSafeArea() + } + .searchable( + text: $query, + placement: .navigationBarDrawer(displayMode: .always), + prompt: Text( + L10n.string( + "mobile.taskComposer.directoryPicker.search", + defaultValue: "Search folders" + ) + ) + ) + .navigationTitle( + L10n.string( + "mobile.taskComposer.directoryPicker.title", + defaultValue: "Choose Folder" + ) + ) + .mobileInlineNavigationTitle() + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.string("mobile.common.cancel", defaultValue: "Cancel")) { + dismiss() + } + .accessibilityIdentifier("MobileTaskDirectoryPickerCancel") + } + ToolbarItem(placement: .primaryAction) { + if !isSearchMode { + Button { + if let parentPath { + navigate(to: parentPath) + } + } label: { + Image(systemName: "chevron.left") + } + .disabled(parentPath == nil) + .accessibilityLabel( + L10n.string( + "mobile.taskComposer.directoryPicker.browse.parent", + defaultValue: "Parent Folder" + ) + ) + .accessibilityIdentifier("MobileTaskDirectoryBrowseParent") + } + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + if !isSearchMode, let currentPath { + chooseCurrentFolderAction(path: currentPath) + } + } + .task(id: SearchRequest(query: query, retryGeneration: searchRetryGeneration)) { + await updateRemoteSuggestions() + } + .task(id: browseState.pendingRequest) { + await loadPendingDirectoryRequest() + } + } + } + + @ViewBuilder + private var browseContent: some View { + quickLocations + currentLocationCard + + if let browseFailure, !isLoadingDirectory { + browseFailureCard(browseFailure) + } + + if isLoadingDirectory, browseEntries.isEmpty { + HStack(spacing: 10) { + ProgressView() + Text( + L10n.string( + "mobile.taskComposer.directoryPicker.browse.loading", + defaultValue: "Loading folders…" + ) + ) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, minHeight: 120) + .accessibilityElement(children: .combine) + } else if browseEntries.isEmpty, browseFailure == nil { + ContentUnavailableView( + L10n.string( + "mobile.taskComposer.directoryPicker.browse.empty.title", + defaultValue: "No Subfolders" + ), + systemImage: "folder", + description: Text( + L10n.string( + "mobile.taskComposer.directoryPicker.browse.empty.message", + defaultValue: "You can still use this folder for the new workspace." + ) + ) + ) + .frame(maxWidth: .infinity, minHeight: 180) + } else if !browseEntries.isEmpty { + VStack(spacing: 0) { + ForEach(browseEntries, id: \.path) { entry in + Button { + if let destination = browseState.navigationDestination(for: entry) { + navigate(to: destination) + } + } label: { + HStack(spacing: 12) { + directoryIcon(for: entry) + + VStack(alignment: .leading, spacing: 3) { + Text(entry.name) + .font(.body.weight(.medium)) + .foregroundStyle(.primary) + .lineLimit(1) + Text(entry.path) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + + Spacer(minLength: 8) + + if !entry.isReadable { + Image(systemName: "lock.fill") + .font(.caption) + .foregroundStyle(.orange) + .accessibilityHidden(true) + } + if entry.isReadable { + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + } + } + .padding(.horizontal, 14) + .frame(minHeight: 58) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!entry.isReadable) + .accessibilityLabel(entry.name) + .accessibilityValue(entry.path) + .accessibilityHint( + entry.isReadable + ? L10n.string( + "mobile.taskComposer.directoryPicker.browse.open.hint", + defaultValue: "Shows the folders inside this folder." + ) + : browseFailureMessage(.unreadable) + ) + + if entry.path != browseEntries.last?.path { + Divider().padding(.leading, 54) + } + } + } + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder(Color.primary.opacity(0.07), lineWidth: 1) + } + + if browseFailure == nil, nextOffset != nil { + Button { + browseState.requestNextPage() + } label: { + HStack(spacing: 8) { + if isLoadingDirectory { + ProgressView() + } else { + Image(systemName: "ellipsis.circle") + } + Text( + L10n.string( + "mobile.taskComposer.directoryPicker.browse.more", + defaultValue: "Show More" + ) + ) + Spacer() + Text("\(browseEntries.count)/\(totalCount)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .frame(minHeight: 44) + .disabled(isLoadingDirectory) + .accessibilityIdentifier("MobileTaskDirectoryBrowseMore") + } + } + } + + private var quickLocations: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + locationChip( + title: L10n.string( + "mobile.taskComposer.directoryPicker.browse.home", + defaultValue: "Home" + ), + systemImage: "house.fill", + path: "~", + identifier: "MobileTaskDirectoryBrowseHome" + ) + locationChip( + title: L10n.string( + "mobile.taskComposer.directoryPicker.browse.computer", + defaultValue: "Computer" + ), + systemImage: "internaldrive.fill", + path: "/", + identifier: "MobileTaskDirectoryBrowseComputer" + ) + + ForEach(Array(candidates.prefix(5).enumerated()), id: \.element.id) { index, candidate in + let displayPath = TaskComposerDirectoryDisplayPath(path: candidate.path) + locationChip( + title: displayPath.name, + systemImage: "clock.arrow.circlepath", + path: candidate.path, + identifier: "MobileTaskDirectoryRecent\(index)" + ) + } + } + .padding(.vertical, 2) + } + .contentMargins(.horizontal, 1, for: .scrollContent) + } + + private var currentLocationCard: some View { + HStack(spacing: 10) { + Image(systemName: "folder.fill") + .foregroundStyle(Color.accentColor) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 2) { + Text(currentDirectoryName) + .font(.headline) + .lineLimit(1) + Text(browseState.displayPath) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + + Spacer(minLength: 4) + } + .padding(12) + .background(Color.accentColor.opacity(0.075), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(Color.accentColor.opacity(0.18), lineWidth: 1) + } + .accessibilityElement(children: .combine) + .accessibilityIdentifier("MobileTaskDirectoryBrowseCurrent") + } + + @ViewBuilder + private var searchContent: some View { + Label { + Text( + L10n.string( + "mobile.taskComposer.directoryPicker.search.coverage", + defaultValue: "Search checks indexed folders across mounted volumes. Browse to reach unindexed or restricted locations." + ) + ) + .font(.caption) + .foregroundStyle(.secondary) + } icon: { + Image(systemName: "magnifyingglass.circle.fill") + .foregroundStyle(Color.accentColor) + } + .padding(.horizontal, 2) + + if let searchFailure, !isSearchingMac { + searchFailureCard(searchFailure) + } + + if searchSuggestions.isEmpty, !isSearchingMac, searchFailure == nil { + ContentUnavailableView( + L10n.string( + "mobile.taskComposer.directoryPicker.search.empty.title", + defaultValue: "No Indexed Matches" + ), + systemImage: "folder.badge.questionmark", + description: Text( + L10n.string( + "mobile.taskComposer.directoryPicker.search.empty.message", + defaultValue: "Browse the Mac to reach folders outside its search index." + ) + ) + ) + .frame(maxWidth: .infinity, minHeight: 220) + } else if !searchSuggestions.isEmpty { + VStack(spacing: 0) { + ForEach(searchSuggestions) { suggestion in + let displayPath = TaskComposerDirectoryDisplayPath(path: suggestion.path) + Button { + choose(path: suggestion.path) + } label: { + TaskComposerDirectorySuggestionRow( + displayPath: displayPath, + sourceLabel: sourceLabel(for: suggestion.bestSource), + context: suggestion.context, + isSelected: suggestion.id == selectedPathID + ) + } + .buttonStyle(.plain) + .accessibilityLabel(displayPath.name) + .accessibilityValue(accessibilityValue(for: suggestion)) + .accessibilityHint( + L10n.string( + "mobile.taskComposer.directoryPicker.result.hint", + defaultValue: "Uses this folder for the new workspace." + ) + ) + .accessibilityAddTraits(suggestion.id == selectedPathID ? .isSelected : []) + + if suggestion.id != searchSuggestions.last?.id { + Divider().padding(.leading, 54) + } + } + } + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder(Color.primary.opacity(0.07), lineWidth: 1) + } + } + + if isSearchingMac { + HStack(spacing: 10) { + ProgressView() + Text( + L10n.string( + "mobile.taskComposer.directoryPicker.searching", + defaultValue: "Searching this Mac…" + ) + ) + .foregroundStyle(.secondary) + } + .frame(minHeight: 44) + .accessibilityElement(children: .combine) + } + + if let searchStatusMessage { + Label(searchStatusMessage, systemImage: "info.circle.fill") + .font(.caption) + .foregroundStyle(.secondary) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.accentColor.opacity(0.07), in: RoundedRectangle(cornerRadius: 14)) + .accessibilityIdentifier("MobileTaskDirectorySearchStatus") + } + } + + private func chooseCurrentFolderAction(path: String) -> some View { + Button { + choose(path: path) + } label: { + HStack(spacing: 9) { + Image(systemName: "folder.badge.checkmark") + Text( + String( + format: L10n.string( + "mobile.taskComposer.directoryPicker.browse.useFormat", + defaultValue: "Use “%@”" + ), + currentDirectoryName + ) + ) + .fontWeight(.semibold) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .frame(maxWidth: .infinity) + } + .mobileGlassProminentButton() + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 6) + .background(.ultraThinMaterial) + .accessibilityIdentifier("MobileTaskDirectoryBrowseUseCurrent") + } + + private func locationChip( + title: String, + systemImage: String, + path: String, + identifier: String + ) -> some View { + Button { + navigate(to: path) + } label: { + Label(title, systemImage: systemImage) + .font(.caption.weight(.semibold)) + .padding(.horizontal, 12) + .frame(minHeight: 38) + .background(Color.primary.opacity(0.055), in: Capsule()) + } + .buttonStyle(.plain) + .accessibilityIdentifier(identifier) + } + + private func directoryIcon(for entry: MobileTaskDirectoryListEntry) -> some View { + ZStack(alignment: .bottomTrailing) { + Image(systemName: entry.isPackage ? "shippingbox.fill" : "folder.fill") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(entry.isReadable ? Color.accentColor : Color.orange) + .frame(width: 34, height: 34) + .background( + (entry.isReadable ? Color.accentColor : Color.orange).opacity(0.1), + in: RoundedRectangle(cornerRadius: 10, style: .continuous) + ) + if entry.isSymbolicLink { + Image(systemName: "arrow.trianglehead.turn.up.right.diamond.fill") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.secondary) + .padding(2) + .background(.regularMaterial, in: Circle()) + .offset(x: 3, y: 2) + } else if entry.isHidden { + Image(systemName: "eye.slash.fill") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.secondary) + .padding(2) + .background(.regularMaterial, in: Circle()) + .offset(x: 3, y: 2) + } + } + .accessibilityHidden(true) + } + + private var isSearchMode: Bool { + !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var currentPath: String? { + browseState.snapshot?.currentPath + } + + private var parentPath: String? { + browseState.snapshot?.parentPath + } + + private var browseEntries: [MobileTaskDirectoryListEntry] { + browseState.snapshot?.entries ?? [] + } + + private var nextOffset: Int? { + browseState.snapshot?.nextOffset + } + + private var totalCount: Int { + browseState.snapshot?.totalCount ?? 0 + } + + private var isLoadingDirectory: Bool { + browseState.isLoading + } + + private var browseFailure: MobileTaskDirectoryListFailure? { + browseState.failure?.reason + } + + private var currentDirectoryName: String { + let path = browseState.displayPath + guard path != "/" else { + return L10n.string( + "mobile.taskComposer.directoryPicker.browse.computer", + defaultValue: "Computer" + ) + } + let name = URL(fileURLWithPath: path).lastPathComponent + return name.isEmpty || path == "~" ? path : name + } + + private var searchSuggestions: [MobileTaskDirectoryCandidate] { + let localSuggestions = MobileTaskDirectorySuggestionIndex(candidates: candidates) + .suggestions(matching: query) + let remoteCandidates = (searchResponse?.directories ?? []).map { + MobileTaskDirectoryCandidate(path: $0, source: .filesystemSearch, context: nil) + } + var seen = Set() + return (remoteCandidates + localSuggestions).filter { seen.insert($0.id).inserted } + } + + private var searchStatusMessage: String? { + guard let searchResponse else { return nil } + if searchResponse.truncated { + return L10n.string( + "mobile.taskComposer.directoryPicker.search.truncated", + defaultValue: "More indexed folders match. Refine your search to see them." + ) + } + if searchResponse.searchScope != .allIndexedVolumes { + return L10n.string( + "mobile.taskComposer.directoryPicker.search.limited", + defaultValue: "This Mac returned limited search results. Browse to reach every accessible folder." + ) + } + if !searchResponse.gatheringComplete { + return L10n.string( + "mobile.taskComposer.directoryPicker.search.partial", + defaultValue: "The Mac search index did not finish in time. Refine your search or retry." + ) + } + return nil + } + + @MainActor + private func loadPendingDirectoryRequest() async { + guard let request = browseState.pendingRequest else { return } + let result = await listMac(request.path, request.offset) + guard !Task.isCancelled else { + browseState.cancel(request) + return + } + browseState.resolve(result, for: request) + } + + @MainActor + private func updateRemoteSuggestions() async { + let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedQuery.isEmpty else { + searchResponse = nil + isSearchingMac = false + searchFailure = nil + return + } + searchResponse = nil + searchFailure = nil + isSearchingMac = true + do { + // The cancellable delay is the search debounce itself, not synchronization. + try await Task.sleep(for: .milliseconds(140)) + let result = await searchMac(trimmedQuery) + guard !Task.isCancelled else { return } + switch result { + case let .success(response): + searchResponse = response + searchFailure = nil + case .failure(.cancelled): + searchResponse = nil + searchFailure = nil + case let .failure(failure): + searchResponse = nil + searchFailure = failure + } + isSearchingMac = false + } catch is CancellationError { + } catch { + guard !Task.isCancelled else { return } + searchResponse = nil + searchFailure = .rejected + isSearchingMac = false + } + } + + private func navigate(to path: String) { + browseState.navigate(to: path) + } + + private func choose(path: String) { + select(path) + dismiss() + } + + private func searchFailureCard(_ failure: MobileTaskDirectorySearchFailure) -> some View { + failureCard( + title: L10n.string( + "mobile.taskComposer.directoryPicker.failure.title", + defaultValue: "Couldn’t Search Folders" + ), + message: searchFailureMessage(failure), + retry: { searchRetryGeneration &+= 1 }, + identifier: "TaskComposerDirectorySearchRetry" + ) + } + + private func browseFailureCard(_ failure: MobileTaskDirectoryListFailure) -> some View { + failureCard( + title: browseFailureTitle(failure), + message: browseFailureMessage(failure), + retry: { browseState.retryFailedRequest() }, + identifier: "TaskComposerDirectoryBrowseRetry" + ) + } + + private func failureCard( + title: String, + message: String, + retry: @escaping () -> Void, + identifier: String + ) -> some View { + VStack(alignment: .leading, spacing: 10) { + Label(title, systemImage: "exclamationmark.folder.fill") + .font(.headline) + .foregroundStyle(.orange) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Button(action: retry) { + Text(L10n.string("mobile.common.retry", defaultValue: "Retry")) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background( + Color.accentColor.opacity(0.1), + in: RoundedRectangle(cornerRadius: 8, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(Color.accentColor.opacity(0.16), lineWidth: 1) + } + .frame(minHeight: 44, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(Color.accentColor) + .accessibilityIdentifier(identifier) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(Color.orange.opacity(0.16), lineWidth: 1) + } + } + + private func searchFailureMessage(_ failure: MobileTaskDirectorySearchFailure) -> String { + switch failure { + case .unsupported: + L10n.string( + "mobile.taskComposer.directoryPicker.failure.unsupported", + defaultValue: "Update cmux on this Mac to search its folders. You can still choose a recent location." + ) + case .unavailable: + L10n.string( + "mobile.taskComposer.directoryPicker.failure.unavailable", + defaultValue: "Reconnect to this Mac, then try again." + ) + case .timedOut: + L10n.string( + "mobile.taskComposer.directoryPicker.failure.timeout", + defaultValue: "This Mac took too long to search. Try again." + ) + case .authorizationRequired: + L10n.string( + "mobile.taskComposer.directoryPicker.failure.authorization", + defaultValue: "Sign in again on this device and Mac, then retry." + ) + case .rejected, .cancelled: + L10n.string( + "mobile.taskComposer.directoryPicker.failure.generic", + defaultValue: "The folder search failed. Try again." + ) + } + } + + private func browseFailureTitle(_ failure: MobileTaskDirectoryListFailure) -> String { + if browseState.failure?.request.kind == .append { + return L10n.string( + "mobile.taskComposer.directoryPicker.browse.more.failure.title", + defaultValue: "Couldn’t Load More Folders" + ) + } + return switch failure { + case .permissionDenied, .unreadable: + L10n.string( + "mobile.taskComposer.directoryPicker.browse.failure.access.title", + defaultValue: "Folder Access Needed" + ) + case .unsupported: + L10n.string( + "mobile.taskComposer.directoryPicker.browse.failure.update.title", + defaultValue: "Update cmux on This Mac" + ) + default: + L10n.string( + "mobile.taskComposer.directoryPicker.browse.failure.title", + defaultValue: "Couldn’t Open Folder" + ) + } + } + + private func browseFailureMessage(_ failure: MobileTaskDirectoryListFailure) -> String { + switch failure { + case .invalidPath: + L10n.string( + "mobile.taskComposer.directoryPicker.browse.failure.invalid", + defaultValue: "Choose another location and try again." + ) + case .unavailable: + L10n.string( + "mobile.taskComposer.directoryPicker.failure.unavailable", + defaultValue: "Reconnect to this Mac, then try again." + ) + case .timedOut: + L10n.string( + "mobile.taskComposer.directoryPicker.browse.failure.timeout", + defaultValue: "This folder took too long to load. Check the Mac or network volume, then retry." + ) + case .authorizationRequired: + L10n.string( + "mobile.taskComposer.directoryPicker.failure.authorization", + defaultValue: "Sign in again on this device and Mac, then retry." + ) + case .unsupported: + L10n.string( + "mobile.taskComposer.directoryPicker.browse.failure.update.message", + defaultValue: "Install the latest cmux on the Mac to browse every accessible folder." + ) + case .notFound, .notDirectory: + L10n.string( + "mobile.taskComposer.directoryPicker.browse.failure.missing", + defaultValue: "This folder moved or no longer exists. Choose another location." + ) + case .permissionDenied: + L10n.string( + "mobile.taskComposer.directoryPicker.browse.failure.permission", + defaultValue: "Allow cmux to access this location on the Mac, then retry." + ) + case .unreadable: + L10n.string( + "mobile.taskComposer.directoryPicker.browse.failure.unreadable", + defaultValue: "The Mac can see this folder but cannot read its contents." + ) + case .rejected, .cancelled: + L10n.string( + "mobile.taskComposer.directoryPicker.browse.failure.generic", + defaultValue: "The Mac could not list this folder. Try again." + ) + } + } + + private func detail(for suggestion: MobileTaskDirectoryCandidate) -> String { + [sourceLabel(for: suggestion.bestSource), suggestion.context] + .compactMap { $0 } + .joined(separator: " · ") + } + + private func accessibilityValue(for suggestion: MobileTaskDirectoryCandidate) -> String { + [suggestion.path, detail(for: suggestion)].formatted() + } + + private func sourceLabel(for source: MobileTaskDirectorySource) -> String { + switch source { + case .filesystemSearch: + L10n.string("mobile.taskComposer.directoryPicker.source.filesystem", defaultValue: "On this Mac") + case .activeTerminal: + L10n.string("mobile.taskComposer.directoryPicker.source.activeTerminal", defaultValue: "Focused terminal") + case .activeWorkspace: + L10n.string("mobile.taskComposer.directoryPicker.source.activeWorkspace", defaultValue: "Current workspace") + case .templateDefault: + L10n.string("mobile.taskComposer.directoryPicker.source.template", defaultValue: "Template default") + case .lastSuccessful: + L10n.string("mobile.taskComposer.directoryPicker.source.last", defaultValue: "Last used") + case .openWorkspace, .openTerminal: + L10n.string("mobile.taskComposer.directoryPicker.source.open", defaultValue: "Open on this Mac") + case .recentSuccessful: + L10n.string("mobile.taskComposer.directoryPicker.source.recent", defaultValue: "Recent task") + case .home: + L10n.string("mobile.taskComposer.directoryPicker.source.home", defaultValue: "Home folder") + } + } + + private struct SearchRequest: Hashable { + let query: String + let retryGeneration: Int + } + +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerDirectorySuggestionRow.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerDirectorySuggestionRow.swift new file mode 100644 index 000000000000..408e99a850cc --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerDirectorySuggestionRow.swift @@ -0,0 +1,87 @@ +#if os(iOS) +import SwiftUI + +struct TaskComposerDirectorySuggestionRow: View { + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + let displayPath: TaskComposerDirectoryDisplayPath + let sourceLabel: String + let context: String? + let isSelected: Bool + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "folder.fill") + .font(.system(.subheadline, weight: .semibold)) + .foregroundStyle(.tint) + .frame(width: 36, height: 36) + .background( + Color.accentColor.opacity(0.12), + in: RoundedRectangle(cornerRadius: 11, style: .continuous) + ) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 4) { + Text(displayPath.name) + .font(.body.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(dynamicTypeSize.isAccessibilitySize ? 2 : 1) + .fixedSize(horizontal: false, vertical: true) + + if let parentPath = displayPath.parentPath { + Text(parentPath) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(dynamicTypeSize.isAccessibilitySize ? 2 : 1) + .truncationMode(.middle) + .fixedSize(horizontal: false, vertical: true) + } + + if dynamicTypeSize.isAccessibilitySize { + VStack(alignment: .leading, spacing: 2) { + Text(sourceLabel) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.tint) + if let context { + Text(context) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + } + } else { + HStack(spacing: 5) { + Text(sourceLabel) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.tint) + .lineLimit(1) + if let context { + Text(verbatim: "·") + .font(.caption2) + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + Text(context) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + } + + Spacer(minLength: 8) + + if isSelected { + Image(systemName: "checkmark.circle.fill") + .font(.body.weight(.semibold)) + .foregroundStyle(.tint) + .accessibilityHidden(true) + } + } + .padding(.vertical, 5) + .frame(minHeight: 58) + .contentShape(Rectangle()) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerHero.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerHero.swift new file mode 100644 index 000000000000..8bbe0cde6960 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerHero.swift @@ -0,0 +1,52 @@ +#if os(iOS) +import CmuxMobileSupport +import SwiftUI + +/// Introduces the task composer as an agent-launch surface rather than a settings form. +struct TaskComposerHero: View { + var body: some View { + HStack(alignment: .top, spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 15, style: .continuous) + .fill( + LinearGradient( + colors: [.accentColor, .accentColor.opacity(0.58)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + Image(systemName: "sparkles") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(.white) + .accessibilityHidden(true) + } + .frame(width: 48, height: 48) + .padding(.top, 6) + .shadow(color: Color.accentColor.opacity(0.22), radius: 12, y: 6) + + VStack(alignment: .leading, spacing: 5) { + Text( + L10n.string( + "mobile.taskComposer.hero.title", + defaultValue: "Start an agent" + ) + ) + .font(.title2.weight(.bold)) + .foregroundStyle(.primary) + + Text( + L10n.string( + "mobile.taskComposer.hero.subtitle", + defaultValue: "Describe the outcome. cmux opens a workspace and puts your agent to work." + ) + ) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .accessibilityElement(children: .combine) + .accessibilityIdentifier("MobileTaskComposerHero") + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerInitialFocusCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerInitialFocusCoordinator.swift new file mode 100644 index 000000000000..c4e80b5cea52 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerInitialFocusCoordinator.swift @@ -0,0 +1,129 @@ +#if os(iOS) +import SwiftUI +import UIKit + +/// Owns the task composer's one initial UIKit focus transfer. +/// +/// Architecture note: the prompt previously requested focus from its SwiftUI +/// `onAppear`, while the sheet presentation still owned responder readiness. +/// That split could make the keyboard start showing and then immediately hide. +/// This controller is now the single initial-focus owner. It waits for its +/// presentation lifecycle and an attached backing input, focuses once, then +/// permanently consumes the request for this sheet presentation. SwiftUI's +/// `FocusState` remains a visual mirror only. +struct TaskComposerInitialFocusCoordinator: UIViewControllerRepresentable { + let isEnabled: Bool + + func makeUIViewController(context: Context) -> Controller { + Controller(isEnabled: isEnabled) + } + + func updateUIViewController(_ uiViewController: Controller, context: Context) { + uiViewController.setEnabled(isEnabled) + } + + @MainActor + final class Controller: UIViewController { + private enum Phase { + case waitingForAppearance + case waitingForInput + case consumed + case cancelled + } + + private var phase: Phase = .waitingForAppearance + private var isEnabled: Bool + + init(isEnabled: Bool) { + self.isEnabled = isEnabled + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + let view = UIView(frame: .zero) + view.isUserInteractionEnabled = false + view.isAccessibilityElement = false + view.accessibilityElementsHidden = true + self.view = view + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + guard phase == .waitingForAppearance else { return } + phase = .waitingForInput + focusIfReady() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + focusIfReady() + } + + override func viewDidDisappear(_ animated: Bool) { + if phase != .consumed { + phase = .cancelled + } + super.viewDidDisappear(animated) + } + + func setEnabled(_ isEnabled: Bool) { + self.isEnabled = isEnabled + if !isEnabled, phase != .consumed { + phase = .cancelled + } + } + + private func focusIfReady() { + guard isEnabled, + phase == .waitingForInput, + viewIfLoaded?.window != nil, + let input = promptInput() else { return } + guard input.becomeFirstResponder() || input.isFirstResponder else { return } + phase = .consumed + } + + private func promptInput() -> UIView? { + var container = parent?.view + while let candidate = container { + let inputs = candidate.taskComposerFocusableTextInputs() + if let identified = inputs.first(where: { + $0.accessibilityIdentifier == "MobileTaskComposerPrompt" + }) { + return identified + } + if inputs.count == 1 { + return inputs[0] + } + if let multilineInput = inputs.first(where: { $0 is UITextView }) { + return multilineInput + } + container = candidate.superview + } + return nil + } + } +} + +private extension UIView { + @MainActor + func taskComposerFocusableTextInputs() -> [UIView] { + var result: [UIView] = [] + if (self is UITextField || self is UITextView), + canBecomeFirstResponder, + window != nil, + !isHidden, + alpha > 0.01 { + result.append(self) + } + for subview in subviews { + result.append(contentsOf: subview.taskComposerFocusableTextInputs()) + } + return result + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPrimaryAction.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPrimaryAction.swift new file mode 100644 index 000000000000..cb83b549ee77 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPrimaryAction.swift @@ -0,0 +1,148 @@ +#if os(iOS) +import CmuxMobileSupport +import SwiftUI + +/// The composer's persistent primary action. Keeping it outside the virtualized +/// scrolling content makes the visible action a stable accessibility element +/// on compact and regular-width layouts. +struct TaskComposerPrimaryAction: View { + @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion + + let isSubmitting: Bool + let isEnabled: Bool + let templateIcon: String? + let actionTitle: String + let progressTitle: String + let caption: String + let failureText: String? + let completedOperationRecovery: TaskComposerCompletedOperationRecovery? + let action: () -> Void + let refreshCompletedOperation: () -> Void + let requestStartAgain: () -> Void + + var body: some View { + VStack(spacing: 10) { + if let failureText { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: "exclamationmark.circle.fill") + .accessibilityHidden(true) + Text(failureText) + .font(.footnote.weight(.medium)) + .fixedSize(horizontal: false, vertical: true) + } + .foregroundStyle(.red) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(Color.red.opacity(0.1), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .accessibilityIdentifier("MobileTaskComposerFailure") + } + if let completedOperationRecovery { + HStack(spacing: 10) { + Button(action: refreshCompletedOperation) { + Group { + if isSubmitting { + ProgressView() + } else { + Text( + completedOperationRecovery.allowsStartAgain + ? L10n.string( + "mobile.taskComposer.recovery.refreshAgain", + defaultValue: "Refresh Again" + ) + : L10n.string( + "mobile.taskComposer.recovery.refresh", + defaultValue: "Refresh Workspaces" + ) + ) + } + } + .frame(maxWidth: .infinity) + } + .mobileGlassProminentButton() + .disabled(isSubmitting) + .accessibilityHint(TaskComposerSheet.recoveryRefreshAccessibilityHint) + .accessibilityIdentifier("MobileTaskComposerRefreshButton") + + if completedOperationRecovery.allowsStartAgain { + Button( + L10n.string( + "mobile.taskComposer.recovery.startAgain", + defaultValue: "Start Again" + ), + action: requestStartAgain + ) + .mobileGlassButton() + .disabled(isSubmitting) + .accessibilityHint(TaskComposerSheet.recoveryStartAgainAccessibilityHint) + .accessibilityIdentifier("MobileTaskComposerStartAgainButton") + } + } + } else { + Button(action: action) { + HStack(spacing: 10) { + if isSubmitting { + ProgressView() + } else if let templateIcon { + TaskTemplateIcon(value: templateIcon, size: 18) + .frame(width: 28, height: 28) + .background(Color.white.opacity(0.14), in: Circle()) + } + Text(isSubmitting ? progressTitle : actionTitle) + .fontWeight(.semibold) + .lineLimit(1) + .minimumScaleFactor(0.82) + if !isSubmitting { + Image(systemName: "paperplane.fill") + .font(.subheadline.weight(.bold)) + .accessibilityHidden(true) + } + } + .frame(maxWidth: .infinity) + .contentShape(.capsule) + } + .mobileGlassProminentButton() + .disabled(isSubmitting || !isEnabled) + .accessibilityLabel(isSubmitting ? progressTitle : actionTitle) + .accessibilityHint(TaskComposerSheet.createAccessibilityHint) + .accessibilityIdentifier("MobileTaskComposerCreateButton") + + Text(caption) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineLimit(2) + .minimumScaleFactor(0.82) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("MobileTaskComposerActionCaption") + } + } + // Persistent chrome must stay compact even when content text uses the + // largest accessibility categories. Accessibility 1 remains larger + // than standard text while preserving enough prompt canvas to work. + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .frame(maxWidth: 680) + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 6) + .frame(maxWidth: .infinity) + .background(.ultraThinMaterial) + .overlay(alignment: .top) { + LinearGradient( + colors: [.clear, Color.accentColor.opacity(0.24), .clear], + startPoint: .leading, + endPoint: .trailing + ) + .frame(height: 1) + .accessibilityHidden(true) + } + .animation( + accessibilityReduceMotion ? nil : .snappy(duration: 0.22), + value: isEnabled + ) + .sensoryFeedback(.impact(weight: .light), trigger: isSubmitting) { oldValue, newValue in + !oldValue && newValue + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPromptCard.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPromptCard.swift new file mode 100644 index 000000000000..ddc77d53f56c --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPromptCard.swift @@ -0,0 +1,75 @@ +#if os(iOS) +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI + +/// A large, automatically focused prompt canvas for the agent's first instruction. +struct TaskComposerPromptCard: View { + @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + @Binding var prompt: String + let placeholder: String + let isDisabled: Bool + let templates: [MobileTaskTemplate] + let selectedTemplateID: MobileTaskTemplate.ID? + let selectTemplate: (MobileTaskTemplate) -> Void + let editTemplates: () -> Void + + @FocusState private var isFocused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + TaskComposerAgentMenu( + templates: templates, + selectedTemplateID: selectedTemplateID, + isDisabled: isDisabled, + selectTemplate: selectTemplate, + editTemplates: editTemplates + ) + Spacer(minLength: 0) + } + + TextField(placeholder, text: $prompt, axis: .vertical) + .textFieldStyle(.plain) + .font(.body) + .lineSpacing(3) + .lineLimit(promptLineLimit) + .frame(minHeight: promptMinimumHeight, alignment: .topLeading) + .focused($isFocused) + .disabled(isDisabled) + .accessibilityLabel(L10n.string("mobile.taskComposer.prompt", defaultValue: "Prompt")) + .accessibilityHint(placeholder) + .accessibilityIdentifier("MobileTaskComposerPrompt") + } + .padding(14) + .mobileGlassField(cornerRadius: 26) + .overlay { + RoundedRectangle(cornerRadius: 26, style: .continuous) + .strokeBorder( + isFocused ? Color.accentColor.opacity(0.58) : Color.primary.opacity(0.06), + lineWidth: isFocused ? 1.25 : 1 + ) + .allowsHitTesting(false) + } + .shadow( + color: isFocused ? Color.accentColor.opacity(0.1) : Color.black.opacity(0.035), + radius: isFocused ? 16 : 10, + y: 6 + ) + .animation( + accessibilityReduceMotion ? nil : .easeOut(duration: 0.18), + value: isFocused + ) + } + + private var promptLineLimit: ClosedRange { + dynamicTypeSize.isAccessibilitySize ? 2...6 : 5...12 + } + + private var promptMinimumHeight: CGFloat { + dynamicTypeSize.isAccessibilitySize ? 96 : 132 + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+CompletedOperationRecovery.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+CompletedOperationRecovery.swift new file mode 100644 index 000000000000..8784583feb36 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+CompletedOperationRecovery.swift @@ -0,0 +1,121 @@ +#if os(iOS) +import CmuxMobileShell +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI +import UIKit + +extension TaskComposerSheet { + func announceFailure(_ message: String) { + guard UIAccessibility.isVoiceOverRunning else { return } + AccessibilityNotification.Announcement(message).post() + } + + func startCompletedOperationReconciliation() { + guard submitTask == nil, let recovery = completedOperationRecovery else { return } + submitTask = Task { @MainActor in + await reconcileCompletedOperation(recovery.submittedSnapshot) + submitTask = nil + } + } + + private func reconcileCompletedOperation(_ snapshot: MobileTaskSubmissionSnapshot) async { + submissionPhase = .preparing + failureText = nil + await store.refreshWorkspaces() + guard !Task.isCancelled else { + submissionPhase = .idle + return + } + let result = await submitTaskComposer( + snapshot.macDeviceID, + Self.workspaceCreateSpec(for: snapshot) + ) { + submissionPhase = .committed + } + submissionPhase = .idle + guard !Task.isCancelled else { return } + switch result { + case .success: + completeSubmission(snapshot) + case .failure(.alreadyCompleted): + completedOperationRecovery?.recordReconciliationStillMissing() + let message = L10n.string( + "mobile.taskComposer.recovery.stillMissing", + defaultValue: "The task is still missing. Refresh again or start it as a new task." + ) + failureText = message + announceFailure(message) + case .failure(let failure): + let message = Self.failureMessage(failure) + failureText = message + announceFailure(message) + } + } + + func confirmStartAgain() { + guard completedOperationRecovery?.allowsStartAgain == true else { return } + completedOperationRecovery = nil + failureText = nil + startSubmission() + } + + func completeSubmission(_ snapshot: MobileTaskSubmissionSnapshot) { + _ = store.completeTaskComposerSubmission( + snapshot, + ifSessionGeneration: sessionGeneration + ) + // Remote success is authoritative. A stale signed-in session may stop + // local defaults from being saved, but it must not leave a launchable + // sheet open and invite the user to submit the same task twice. + completedOperationRecovery = nil + shouldPersistDraftOnDisappear = false + dismiss() + } + + static func workspaceCreateSpec( + for snapshot: MobileTaskSubmissionSnapshot + ) -> MobileWorkspaceCreateSpec { + MobileWorkspaceCreateSpec( + title: snapshot.composition.title, + workingDirectory: snapshot.trimmedDirectory.isEmpty ? nil : snapshot.trimmedDirectory, + initialCommand: snapshot.composition.initialCommand, + initialEnv: snapshot.composition.initialEnv.isEmpty ? nil : snapshot.composition.initialEnv, + operationID: snapshot.operationID + ) + } +} + +struct TaskComposerStartAgainConfirmationModifier: ViewModifier { + @Binding var isPresented: Bool + let confirm: () -> Void + + func body(content: Content) -> some View { + content.confirmationDialog( + L10n.string( + "mobile.taskComposer.recovery.startAgain.title", + defaultValue: "Start this task again?" + ), + isPresented: $isPresented, + titleVisibility: .visible + ) { + Button( + L10n.string( + "mobile.taskComposer.recovery.startAgain", + defaultValue: "Start Again" + ), + role: .destructive, + action: confirm + ) + Button(L10n.string("mobile.common.cancel", defaultValue: "Cancel"), role: .cancel) {} + } message: { + Text( + L10n.string( + "mobile.taskComposer.recovery.startAgain.message", + defaultValue: "Only continue if the task is not present. Starting again may create a duplicate." + ) + ) + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+DirectorySelection.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+DirectorySelection.swift new file mode 100644 index 000000000000..031c70ffd20c --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+DirectorySelection.swift @@ -0,0 +1,22 @@ +#if os(iOS) +import CmuxMobileShellModel + +extension TaskComposerSheet { + var directoryCandidates: [MobileTaskDirectoryCandidate] { + TaskComposerDirectoryCandidates( + store: store, + selectedMacDeviceID: selectedMacDeviceID, + selectedTemplate: selectedTemplate + ).make() + } + + func selectDirectory(_ path: String) { + guard !submissionPhase.disablesRequestEditing else { return } + updateSubmissionRequest { + directory = path + didEditDirectory = true + } + failureText = nil + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+DraftState.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+DraftState.swift new file mode 100644 index 000000000000..77f59165ad56 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+DraftState.swift @@ -0,0 +1,91 @@ +#if os(iOS) +import CmuxMobileShellModel +import Foundation + +extension TaskComposerSheet { + func selectTemplate(_ template: MobileTaskTemplate) { + updateSubmissionRequest { + selectedTemplateID = template.id + guard !didEditDirectory else { return } + directory = Self.suggestedDirectory( + template: template, + macDeviceID: selectedMacDeviceID, + templateStore: store.taskTemplateStore + ) + } + } + + func restoreSubmittedDraft(_ snapshot: MobileTaskSubmissionSnapshot) { + prompt = snapshot.prompt + selectedTemplateID = snapshot.templateID + selectedMacDeviceID = snapshot.macDeviceID + directory = snapshot.directory + didEditDirectory = snapshot.didEditDirectory + submissionIdentity.adoptResolvedRequest(snapshot) + } + + /// Recompute the suggested directory unless the user hand-edited it. + func syncSuggestedDirectory() { + guard !didEditDirectory else { return } + directory = Self.suggestedDirectory( + template: selectedTemplate, + macDeviceID: selectedMacDeviceID, + templateStore: store.taskTemplateStore, + openDirectory: Self.preferredOpenDirectory( + workspaces: store.workspaces, + selectedWorkspaceID: store.selectedWorkspaceID, + macDeviceID: selectedMacDeviceID, + connectedMacDeviceID: store.connectedMacDeviceID + ) + ) + } + + /// Applies a composer mutation and defers request comparison to a low- + /// frequency persistence or submission boundary. + func updateSubmissionRequest(_ update: () -> Void) { + if submissionPhase.offersRetry { + submissionPhase = .idle + } + failureText = nil + update() + submissionIdentity.markRequestDirty() + completedOperationRecovery = nil + isStartAgainConfirmationPresented = false + } + + func submissionSnapshot() -> MobileTaskSubmissionSnapshot? { + let candidateID = submissionIdentity.id + return submissionIdentity.resolveCurrentRequest { + makeSubmissionSnapshot(operationID: candidateID) + } + } + + func draftSnapshot() -> MobileTaskComposerDraft { + let candidateID = submissionIdentity.id + let resolved = submissionIdentity.resolveCurrentRequest { + makeSubmissionSnapshot(operationID: candidateID) + } + return MobileTaskComposerDraft( + prompt: prompt, + templateID: selectedTemplateID, + macDeviceID: selectedMacDeviceID.isEmpty ? nil : selectedMacDeviceID, + directory: directory, + didEditDirectory: didEditDirectory, + operationID: resolved?.operationID ?? submissionIdentity.id, + completedOperationID: completedOperationRecovery?.submittedSnapshot.operationID + ) + } + + private func makeSubmissionSnapshot(operationID: UUID) -> MobileTaskSubmissionSnapshot? { + guard let selectedTemplate else { return nil } + return MobileTaskSubmissionSnapshot( + template: selectedTemplate, + prompt: prompt, + macDeviceID: selectedMacDeviceID, + directory: directory, + didEditDirectory: didEditDirectory, + operationID: operationID + ) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+Policies.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+Policies.swift new file mode 100644 index 000000000000..67cd00945002 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+Policies.swift @@ -0,0 +1,178 @@ +#if os(iOS) +import CmuxMobileShell +import CmuxMobileShellModel +import CmuxMobileSupport + +enum TaskComposerSubmissionPhase: Equatable { + case idle + case preparing + case committed + case retryReady + + var allowsSubmission: Bool { + self == .idle || self == .retryReady + } + + var offersRetry: Bool { + self == .retryReady + } + + var disablesRequestEditing: Bool { + self == .preparing || self == .committed + } + + var showsProgress: Bool { + self == .preparing || self == .committed + } + + var locksDismissal: Bool { + self == .committed + } +} + +struct TaskComposerCompletedOperationRecovery: Equatable { + enum Phase: Equatable { + case refreshRequired + case startAgainAvailable + } + + let submittedSnapshot: MobileTaskSubmissionSnapshot + private(set) var phase: Phase = .refreshRequired + + var allowsStartAgain: Bool { + phase == .startAgainAvailable + } + + mutating func recordReconciliationStillMissing() { + phase = .startAgainAvailable + } +} + +extension TaskComposerSheet { + static var createAccessibilityHint: String { + L10n.string( + "mobile.taskComposer.create.accessibilityHint", + defaultValue: "Starts this task in a new workspace on the selected Mac." + ) + } + + static var recoveryRefreshAccessibilityHint: String { + L10n.string( + "mobile.taskComposer.recovery.refresh.accessibilityHint", + defaultValue: "Checks the Mac for the task that was already accepted." + ) + } + + static var recoveryStartAgainAccessibilityHint: String { + L10n.string( + "mobile.taskComposer.recovery.startAgain.accessibilityHint", + defaultValue: "Starts the same draft as a new task after confirmation." + ) + } + + static var machineAccessibilityHint: String { + L10n.string( + "mobile.taskComposer.machine.accessibilityHint", + defaultValue: "Chooses the Mac that will run this task." + ) + } + + static var templateAccessibilityHint: String { + L10n.string( + "mobile.taskComposer.template.accessibilityHint", + defaultValue: "Selects this agent or command for the task." + ) + } + + static var draftPersistenceFailureMessage: String { + L10n.string( + "mobile.taskComposer.failure.draftPersistence", + defaultValue: "cmux couldn’t save this draft safely. Reopen the composer and try again." + ) + } + + static func failureMessage(_ failure: MobileWorkspaceMutationFailure) -> String { + switch failure { + case .notConnected: + return L10n.string("mobile.taskComposer.failure.notConnected", defaultValue: "That Mac is not connected.") + case .requestTimedOut: + return L10n.string("mobile.taskComposer.failure.timedOut", defaultValue: "The Mac did not respond in time.") + case .authorizationFailed: + return L10n.string("mobile.taskComposer.failure.authorization", defaultValue: "That Mac did not authorize the request.") + case .busy: + return L10n.string("mobile.taskComposer.failure.busy", defaultValue: "Another workspace action is still finishing.") + case .rejected: + return L10n.string("mobile.taskComposer.failure.rejected", defaultValue: "The Mac rejected the task.") + case .invalidWorkingDirectory: + return L10n.string("mobile.taskComposer.failure.invalidWorkingDirectory", defaultValue: "Choose an existing folder on that Mac.") + case .persistenceUnavailable: + return L10n.string("mobile.taskComposer.failure.persistence", defaultValue: "The Mac could not safely reserve this task.") + case .alreadyCompleted: + return L10n.string( + "mobile.taskComposer.failure.alreadyCompleted", + defaultValue: "The Mac already accepted this task. Refresh workspaces before trying again." + ) + case .unsupported: + return L10n.string("mobile.taskComposer.failure.unsupported", defaultValue: "That Mac does not support this action.") + } + } + + /// The directory the composer pre-fills: the template default, then the + /// last successful directory for the selected Mac, an open directory on + /// that Mac, then home. + static func suggestedDirectory( + template: MobileTaskTemplate?, + macDeviceID: String, + templateStore: (any MobileTaskTemplateStoring)?, + openDirectory: String? = nil + ) -> String { + if let defaultDirectory = template?.defaultDirectory?.trimmingCharacters(in: .whitespacesAndNewlines), + !defaultDirectory.isEmpty { + return defaultDirectory + } + if let lastDirectory = templateStore?.lastDirectory(macDeviceID: macDeviceID)?.trimmingCharacters(in: .whitespacesAndNewlines), + !lastDirectory.isEmpty { + return lastDirectory + } + if let openDirectory = openDirectory?.trimmingCharacters(in: .whitespacesAndNewlines), + !openDirectory.isEmpty { + return openDirectory + } + return "~" + } + + static func preferredOpenDirectory( + workspaces: [MobileWorkspacePreview], + selectedWorkspaceID: MobileWorkspacePreview.ID?, + macDeviceID: String, + connectedMacDeviceID: String? + ) -> String? { + let includeUnscoped = macDeviceID == connectedMacDeviceID + let matching = workspaces.filter { + $0.macDeviceID == macDeviceID || ($0.macDeviceID == nil && includeUnscoped) + } + let ordered = matching.sorted { lhs, rhs in + if (lhs.id == selectedWorkspaceID) != (rhs.id == selectedWorkspaceID) { + return lhs.id == selectedWorkspaceID + } + return (lhs.lastActivityAt ?? .distantPast) > (rhs.lastActivityAt ?? .distantPast) + } + for workspace in ordered { + if let focused = workspace.terminals.first(where: \.isFocused)?.currentDirectory, + !focused.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return focused + } + if let current = workspace.currentDirectory, + !current.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return current + } + if let terminal = workspace.terminals.compactMap(\.currentDirectory).first(where: { + !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + }) { + return terminal + } + } + return nil + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet.swift new file mode 100644 index 000000000000..daf73041ac05 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet.swift @@ -0,0 +1,590 @@ +#if os(iOS) +import CmuxMobilePairedMac +import CmuxMobileRPC +import CmuxMobileShell +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI +import UIKit + +struct TaskComposerSheet: View { + @Environment(\.dismiss) var dismiss + @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion + @Environment(\.scenePhase) private var scenePhase + @Bindable var store: CMUXMobileShellStore + + @State var prompt = "" + @State private var templates: [MobileTaskTemplate] + @State var selectedTemplateID: MobileTaskTemplate.ID? + @State var selectedMacDeviceID: String + @State var directory: String + @State var didEditDirectory = false + @State var submissionPhase: TaskComposerSubmissionPhase = .idle + @State var submitTask: Task? + @State var failureText: String? + @State private var isEditorPresented = false + @State var isDirectoryPickerPresented = false + @State var shouldPersistDraftOnDisappear = true + @State var submissionIdentity: MobileTaskSubmissionIdentity + @State private var activeSubmissionSnapshot: MobileTaskSubmissionSnapshot? + @State var completedOperationRecovery: TaskComposerCompletedOperationRecovery? + @State var isStartAgainConfirmationPresented = false + + let sessionGeneration: Int + private let availableMachines: [MobilePairedMac]? + let submitTaskComposer: @MainActor ( + _ macDeviceID: String, + _ spec: MobileWorkspaceCreateSpec, + _ willStartCreate: @escaping @MainActor () -> Void + ) async -> Result + private let searchTaskDirectories: (@MainActor ( + _ macDeviceID: String, + _ query: String + ) async -> Result)? + private let listTaskDirectories: (@MainActor ( + _ macDeviceID: String, + _ path: String, + _ offset: Int + ) async -> Result)? + + init( + store: CMUXMobileShellStore, + availableMachines: [MobilePairedMac]? = nil, + submitTaskComposer: (@MainActor ( + _ macDeviceID: String, + _ spec: MobileWorkspaceCreateSpec, + _ willStartCreate: @escaping @MainActor () -> Void + ) async -> Result)? = nil, + searchTaskDirectories: (@MainActor ( + _ macDeviceID: String, + _ query: String + ) async -> Result)? = nil, + listTaskDirectories: (@MainActor ( + _ macDeviceID: String, + _ path: String, + _ offset: Int + ) async -> Result)? = nil + ) { + self.store = store + self.availableMachines = availableMachines + self.sessionGeneration = store.currentSessionGeneration + self.searchTaskDirectories = searchTaskDirectories + self.listTaskDirectories = listTaskDirectories + self.submitTaskComposer = submitTaskComposer ?? { macDeviceID, spec, willStartCreate in + await store.submitTaskComposer( + macDeviceID: macDeviceID, + spec: spec, + willStartCreate: willStartCreate + ) + } + let loadedTemplates = store.taskTemplateStore?.listTemplates() ?? [] + let templates = loadedTemplates + let draft = store.taskTemplateStore?.composerDraft() + let foregroundMacID = store.connectedMacDeviceID + // Restore persisted Mac IDs only while they remain paired. + let pairedMacIDs = (availableMachines ?? store.displayPairedMacs).map(\.macDeviceID) + let restoredMacID = store.taskTemplateStore?.lastMacDeviceID() + .flatMap { id in pairedMacIDs.contains(id) ? id : nil } + let draftMacID = draft?.macDeviceID + .flatMap { id in pairedMacIDs.contains(id) ? id : nil } + let selectedMacID = draftMacID + ?? restoredMacID + ?? foregroundMacID.flatMap { id in pairedMacIDs.contains(id) ? id : nil } + ?? pairedMacIDs.first + ?? foregroundMacID + ?? "" + let draftTemplateID = draft?.templateID + .flatMap { id in templates.contains(where: { $0.id == id }) ? id : nil } + let selectedTemplateID = draftTemplateID + ?? store.taskTemplateStore?.lastTemplateID() + .flatMap { id in templates.contains(where: { $0.id == id }) ? id : nil } + ?? templates.first?.id + let selectedTemplate = selectedTemplateID.flatMap { id in templates.first { $0.id == id } } + let openDirectory = Self.preferredOpenDirectory( + workspaces: store.workspaces, + selectedWorkspaceID: store.selectedWorkspaceID, + macDeviceID: selectedMacID, + connectedMacDeviceID: store.connectedMacDeviceID + ) + let canRestoreDraftDirectory = draft != nil && ( + draft?.didEditDirectory == true + || (draft?.templateID == selectedTemplateID && draft?.macDeviceID == selectedMacID) + ) + let initialDirectory = canRestoreDraftDirectory + ? draft?.directory ?? "~" + : Self.suggestedDirectory( + template: selectedTemplate, + macDeviceID: selectedMacID, + templateStore: store.taskTemplateStore, + openDirectory: openDirectory + ) + let restoredOperationID = ( + draft?.templateID == selectedTemplateID + && draft?.macDeviceID == (selectedMacID.isEmpty ? nil : selectedMacID) + && canRestoreDraftDirectory + ) ? draft?.operationID : nil + let initialPrompt = draft?.prompt ?? "" + let initialOperationID = restoredOperationID ?? UUID() + let initialRequest = selectedTemplate.map { + MobileTaskSubmissionSnapshot( + template: $0, + prompt: initialPrompt, + macDeviceID: selectedMacID, + directory: initialDirectory, + didEditDirectory: canRestoreDraftDirectory && draft?.didEditDirectory == true, + operationID: initialOperationID + ) + } + let canRestoreCompletedOperation = draft?.templateID == selectedTemplateID + && draft?.macDeviceID == (selectedMacID.isEmpty ? nil : selectedMacID) + && canRestoreDraftDirectory + let initialCompletedOperationRecovery = (canRestoreCompletedOperation + ? draft?.completedOperationID + : nil) + .flatMap { operationID in + initialRequest?.withOperationID(operationID) + } + _prompt = State(initialValue: initialPrompt) + _templates = State(initialValue: templates) + _selectedTemplateID = State(initialValue: selectedTemplateID) + _selectedMacDeviceID = State(initialValue: selectedMacID) + _directory = State(initialValue: initialDirectory) + _didEditDirectory = State(initialValue: canRestoreDraftDirectory && draft?.didEditDirectory == true) + _submissionIdentity = State(initialValue: MobileTaskSubmissionIdentity( + id: initialOperationID, + initialRequest: initialRequest + )) + _completedOperationRecovery = State( + initialValue: initialCompletedOperationRecovery.map { + TaskComposerCompletedOperationRecovery(submittedSnapshot: $0) + } + ) + _failureText = State( + initialValue: initialCompletedOperationRecovery == nil + ? nil + : Self.failureMessage(.alreadyCompleted(hostDisplayName: nil)) + ) + } + + var body: some View { + NavigationStack { + ZStack { + Color(uiColor: .systemGroupedBackground) + .ignoresSafeArea() + + RadialGradient( + colors: [ + Color.accentColor.opacity(0.2), + Color.accentColor.opacity(0.055), + .clear, + ], + center: .topLeading, + startRadius: 8, + endRadius: 430 + ) + .ignoresSafeArea() + .allowsHitTesting(false) + + ScrollView { + VStack(spacing: 12) { + TaskComposerPromptCard( + prompt: promptBinding, + placeholder: promptPlaceholder, + isDisabled: submissionPhase.disablesRequestEditing, + templates: templates, + selectedTemplateID: selectedTemplateID, + selectTemplate: selectTemplateFromPicker, + editTemplates: presentTemplateEditor + ) + + TaskComposerContextSection( + machines: machines, + selectedMacDeviceID: selectedMacDeviceID, + directory: directory, + isDisabled: submissionPhase.disablesRequestEditing, + selectMachine: selectMachine, + selectDirectory: { isDirectoryPickerPresented = true } + ) + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) + } + .frame(maxWidth: 680) + .frame(maxWidth: .infinity) + .padding(.horizontal, 20) + .padding(.top, 14) + .padding(.bottom, 20) + } + .scrollDismissesKeyboard(.interactively) + } + .safeAreaInset(edge: .bottom, spacing: 0) { + TaskComposerPrimaryAction( + isSubmitting: submissionPhase.showsProgress, + isEnabled: selectedMachine != nil && canLaunchSelectedTemplate, + templateIcon: selectedTemplate?.icon, + actionTitle: primaryActionTitle, + progressTitle: primaryActionProgressTitle, + caption: primaryActionCaption, + failureText: failureText, + completedOperationRecovery: completedOperationRecovery, + action: startSubmission, + refreshCompletedOperation: startCompletedOperationReconciliation, + requestStartAgain: { isStartAgainConfirmationPresented = true } + ) + } + .navigationTitle(L10n.string("mobile.taskComposer.title", defaultValue: "New Task")) + .mobileInlineNavigationTitle() + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + submitTask?.cancel() + shouldPersistDraftOnDisappear = false + store.clearTaskComposerDraft(ifSessionGeneration: sessionGeneration) + dismiss() + } label: { + Image(systemName: "xmark") + } + // Cancellation remains safe while routing and capability + // checks run. Lock only once the create boundary commits. + .disabled(submissionPhase.locksDismissal) + .accessibilityLabel(L10n.string("mobile.common.cancel", defaultValue: "Cancel")) + .accessibilityIdentifier("MobileTaskComposerCancelButton") + } + } + .sheet(isPresented: $isEditorPresented) { + TaskTemplateEditorView( + templates: templates, + addTemplate: addTemplate, + updateTemplate: updateTemplate, + deleteTemplates: deleteTemplates, + refresh: refreshTemplates + ) + } + .sheet(isPresented: $isDirectoryPickerPresented) { + TaskComposerDirectoryPickerView( + candidates: directoryCandidates, + selectedPath: directory, + select: selectDirectory, + searchMac: { query in + if let searchTaskDirectories { + return await searchTaskDirectories(selectedMacDeviceID, query) + } + return await store.searchTaskDirectories( + macDeviceID: selectedMacDeviceID, + query: query + ) + }, + listMac: { path, offset in + if let listTaskDirectories { + return await listTaskDirectories(selectedMacDeviceID, path, offset) + } + return await store.listTaskDirectories( + macDeviceID: selectedMacDeviceID, + path: path, + offset: offset + ) + } + ) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + .onDisappear { + // Parent-driven dismissal must cancel result application. + submitTask?.cancel() + if shouldPersistDraftOnDisappear { + persistDraft() + } + } + .onChange(of: scenePhase) { _, newPhase in + guard newPhase != .active else { return } + persistDraft() + } + .onChange(of: machines.map(\.macDeviceID)) { _, _ in + validateMacSelection() + } + .modifier(TaskComposerStartAgainConfirmationModifier( + isPresented: $isStartAgainConfirmationPresented, + confirm: confirmStartAgain + )) + } + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + .interactiveDismissDisabled(submissionPhase.locksDismissal) + .background(TaskComposerInitialFocusCoordinator( + isEnabled: !submissionPhase.disablesRequestEditing + )) + } + + var selectedTemplate: MobileTaskTemplate? { + selectedTemplateID.flatMap { id in templates.first { $0.id == id } } + } + + private var machines: [MobilePairedMac] { + availableMachines ?? store.displayPairedMacs + } + + private var selectedMachine: MobilePairedMac? { + machines.first { $0.macDeviceID == selectedMacDeviceID } + } + + private var canLaunchSelectedTemplate: Bool { + guard let selectedTemplate else { return false } + return selectedTemplate.isPlainShell + || !prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var promptPlaceholder: String { + guard let selectedTemplate else { + return L10n.string( + "mobile.taskComposer.promptPlaceholder", + defaultValue: "Describe what you want to accomplish" + ) + } + if selectedTemplate.isPlainShell { + return L10n.string( + "mobile.taskComposer.promptPlaceholder.shell", + defaultValue: "Describe what you want to run" + ) + } + return String( + format: L10n.string( + "mobile.taskComposer.promptPlaceholder.agentFormat", + defaultValue: "Tell %@ what to build, fix, or investigate" + ), + selectedTemplate.name + ) + } + + private var primaryActionTitle: String { + if submissionPhase.offersRetry { + return L10n.string( + "mobile.taskComposer.tryAgain", + defaultValue: "Try Again" + ) + } + guard let selectedTemplate else { + return L10n.string("mobile.taskComposer.startTask", defaultValue: "Start Task") + } + if selectedTemplate.isPlainShell { + return L10n.string("mobile.taskComposer.openShell", defaultValue: "Open Shell") + } + return String( + format: L10n.string( + "mobile.taskComposer.startAgentFormat", + defaultValue: "Start %@" + ), + selectedTemplate.name + ) + } + + private var primaryActionProgressTitle: String { + if submissionPhase == .preparing { + return L10n.string( + "mobile.taskComposer.preparingWorkspace", + defaultValue: "Preparing workspace…" + ) + } + guard let selectedTemplate else { + return L10n.string("mobile.taskComposer.startingTask", defaultValue: "Starting Task…") + } + if selectedTemplate.isPlainShell { + return L10n.string("mobile.taskComposer.openingShell", defaultValue: "Opening Shell…") + } + return String( + format: L10n.string( + "mobile.taskComposer.startingAgentFormat", + defaultValue: "Starting %@…" + ), + selectedTemplate.name + ) + } + + private var primaryActionCaption: String { + guard let selectedTemplate else { + return L10n.string( + "mobile.taskComposer.action.caption", + defaultValue: "Creates a workspace and sends your prompt immediately." + ) + } + if !selectedTemplate.isPlainShell, !canLaunchSelectedTemplate { + return String( + format: L10n.string( + "mobile.taskComposer.action.promptRequiredFormat", + defaultValue: "Add a prompt to put %@ to work." + ), + selectedTemplate.name + ) + } + guard let selectedMachine else { + return L10n.string( + "mobile.taskComposer.action.caption", + defaultValue: "Creates a workspace and sends your prompt immediately." + ) + } + return String( + format: L10n.string( + "mobile.taskComposer.action.routeCaptionFormat", + defaultValue: "New workspace on %@ in %@." + ), + selectedMachine.resolvedName, + TaskComposerDirectoryDisplayPath(path: directory).name + ) + } + + private var promptBinding: Binding { + Binding( + get: { prompt }, + set: { newValue in + guard !submissionPhase.disablesRequestEditing else { return } + updateSubmissionRequest { + prompt = newValue + } + failureText = nil + } + ) + } + + private func selectTemplateFromPicker(_ template: MobileTaskTemplate) { + guard !submissionPhase.disablesRequestEditing else { return } + withAnimation(accessibilityReduceMotion ? nil : .snappy(duration: 0.2)) { + selectTemplate(template) + failureText = nil + } + } + + private func presentTemplateEditor() { + persistDraft() + isEditorPresented = true + } + + private func selectMachine(_ mac: MobilePairedMac) { + guard !submissionPhase.disablesRequestEditing else { return } + updateSubmissionRequest { + selectedMacDeviceID = mac.macDeviceID + syncSuggestedDirectory() + } + failureText = nil + } + + func startSubmission() { + guard submitTask == nil, + completedOperationRecovery == nil, + submissionPhase.allowsSubmission else { return } + if submissionPhase.offersRetry { + failureText = nil + } + submitTask = Task { @MainActor in + await submit() + submitTask = nil + } + } + + private func submit() async { + guard submissionPhase.allowsSubmission, + let snapshot = submissionSnapshot() else { return } + guard store.persistTaskComposerDraft( + snapshot.draft, + ifSessionGeneration: sessionGeneration + ) else { + let message = Self.draftPersistenceFailureMessage + failureText = message + announceFailure(message) + return + } + submissionPhase = .preparing + activeSubmissionSnapshot = snapshot + failureText = nil + let spec = Self.workspaceCreateSpec(for: snapshot) + let result = await submitTaskComposer(snapshot.macDeviceID, spec) { + submissionPhase = .committed + } + submissionPhase = .idle + activeSubmissionSnapshot = nil + // The user dismissed the sheet mid-flight: drop the result instead of + // persisting last-used defaults or re-dismissing a gone sheet. + guard !Task.isCancelled else { return } + switch result { + case .success: + completeSubmission(snapshot) + case .failure(let failure): + restoreSubmittedDraft(snapshot) + if case .alreadyCompleted = failure { + completedOperationRecovery = TaskComposerCompletedOperationRecovery( + submittedSnapshot: snapshot + ) + // Retire the host tombstone immediately. A relaunch preserves + // this same draft with a fresh ID, but UI recovery still gates + // sending it until refresh and explicit confirmation. + submissionIdentity.rotate() + _ = store.persistTaskComposerDraft( + draftSnapshot(), + ifSessionGeneration: sessionGeneration + ) + } else { + _ = store.persistTaskComposerDraft( + snapshot.draft, + ifSessionGeneration: sessionGeneration + ) + submissionPhase = .retryReady + } + let message = Self.failureMessage(failure) + failureText = message + announceFailure(message) + } + } + + private func addTemplate(_ template: MobileTaskTemplate) { + guard !submissionPhase.disablesRequestEditing else { return } + updateSubmissionRequest { + store.taskTemplateStore?.addTemplate(template) + selectedTemplateID = template.id + syncSuggestedDirectory() + } + } + + private func updateTemplate(_ template: MobileTaskTemplate) { + guard !submissionPhase.disablesRequestEditing else { return } + store.taskTemplateStore?.updateTemplate(template) + } + + private func deleteTemplates(_ offsets: IndexSet) { + guard !submissionPhase.disablesRequestEditing else { return } + let ids = Set(offsets.map { templates[$0].id }) + store.taskTemplateStore?.deleteTemplates(ids: ids) + } + + private func refreshTemplates() { + guard !submissionPhase.disablesRequestEditing else { return } + updateSubmissionRequest { + templates = store.taskTemplateStore?.listTemplates() ?? [] + if let selectedTemplateID, !templates.contains(where: { $0.id == selectedTemplateID }) { + self.selectedTemplateID = templates.first?.id + } + // Sync template edits unless the user typed the directory. + syncSuggestedDirectory() + } + failureText = nil + } + + private func validateMacSelection() { + guard !submissionPhase.disablesRequestEditing else { return } + guard selectedMachine == nil else { return } + updateSubmissionRequest { + selectedMacDeviceID = machines.first?.macDeviceID ?? "" + syncSuggestedDirectory() + } + failureText = nil + } + + private func persistDraft() { + guard shouldPersistDraftOnDisappear else { return } + if let activeSubmissionSnapshot { + store.persistTaskComposerDraft( + activeSubmissionSnapshot.draft, + ifSessionGeneration: sessionGeneration + ) + return + } + store.persistTaskComposerDraft(draftSnapshot(), ifSessionGeneration: sessionGeneration) + } + +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerShellIconLabView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerShellIconLabView.swift new file mode 100644 index 000000000000..7864e0e39ebe --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerShellIconLabView.swift @@ -0,0 +1,149 @@ +#if os(iOS) && DEBUG +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI + +/// Debug-only Settings surface for comparing small Shell badge adjustments in +/// the exact task-composer card that ships in the app. +struct TaskComposerShellIconLabView: View { + @Environment(MobileDisplaySettings.self) private var displaySettings + + private let columns = [ + GridItem(.flexible(), spacing: 10), + GridItem(.flexible(), spacing: 10), + GridItem(.flexible(), spacing: 10), + ] + + var body: some View { + @Bindable var displaySettings = displaySettings + return ScrollView { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 10) { + Text(L10n.string( + "mobile.settings.shellIconLab.selected", + defaultValue: "Selected Shell Card" + )) + .font(.headline) + + TaskComposerTemplateOption( + template: shellTemplate, + isSelected: true, + isDisabled: false, + shellIconVariant: displaySettings.taskComposerShellIconVariant, + action: {} + ) + .allowsHitTesting(false) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("MobileShellIconLabSelectedPreview") + } + + VStack(alignment: .leading, spacing: 10) { + Text(L10n.string( + "mobile.settings.shellIconLab.variants", + defaultValue: "Variants" + )) + .font(.headline) + + LazyVGrid(columns: columns, spacing: 10) { + ForEach(TaskComposerShellIconVariant.allCases) { variant in + variantButton( + variant, + selection: $displaySettings.taskComposerShellIconVariant + ) + } + } + } + + Text(L10n.string( + "mobile.settings.shellIconLab.footer", + defaultValue: "The first value is icon size. Stroke weights are 400 regular, 500 medium, and 600 semibold. α changes contrast; ○ changes circle size. Choose a variant, then reopen New Task." + )) + .font(.footnote) + .foregroundStyle(.secondary) + } + .padding(20) + } + .background(Color(uiColor: .systemGroupedBackground)) + .navigationTitle(L10n.string( + "mobile.settings.shellIconLab", + defaultValue: "Shell Icon Lab" + )) + .navigationBarTitleDisplayMode(.inline) + .accessibilityIdentifier("MobileShellIconLab") + } + + private func variantButton( + _ variant: TaskComposerShellIconVariant, + selection: Binding + ) -> some View { + let isSelected = selection.wrappedValue == variant + return Button { + selection.wrappedValue = variant + } label: { + VStack(spacing: 7) { + ZStack(alignment: .topTrailing) { + Circle() + .fill(Color.accentColor.opacity(0.16 * variant.circleOpacityScale)) + .frame( + width: 40 * variant.circleScale, + height: 40 * variant.circleScale + ) + .frame(width: 40, height: 40) + TaskTemplateIcon(value: "terminal", size: 22, shellVariant: variant) + .frame(width: 40, height: 40) + if isSelected { + Image(systemName: "checkmark.circle.fill") + .font(.caption2.weight(.semibold)) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, Color.accentColor) + .offset(x: 3, y: -2) + .accessibilityHidden(true) + } + } + + Text(verbatim: "\(variant.code) · \(variant.title)") + .font(.caption.weight(.semibold)) + .lineLimit(2) + .multilineTextAlignment(.center) + .minimumScaleFactor(0.75) + Text(verbatim: variant.detail) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) + .multilineTextAlignment(.center) + .minimumScaleFactor(0.72) + } + .frame(maxWidth: .infinity, minHeight: 104) + .padding(.horizontal, 6) + .padding(.vertical, 8) + .background( + isSelected ? Color.accentColor.opacity(0.12) : Color.primary.opacity(0.045), + in: RoundedRectangle(cornerRadius: 16, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke( + isSelected ? Color.accentColor.opacity(0.65) : Color.primary.opacity(0.08), + lineWidth: isSelected ? 1.5 : 1 + ) + } + .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + .buttonStyle(.plain) + .accessibilityLabel(Text(verbatim: "\(variant.code), \(variant.title), \(variant.detail)")) + .accessibilityAddTraits(isSelected ? .isSelected : []) + .accessibilityIdentifier("MobileShellIconVariant-\(variant.code)") + } + + private var shellTemplate: MobileTaskTemplate { + MobileTaskTemplate( + name: L10n.string( + "mobile.taskComposer.template.seed.shell", + defaultValue: "Shell" + ), + icon: "terminal", + command: "" + ) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerShellIconVariant.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerShellIconVariant.swift new file mode 100644 index 000000000000..e688c1e86ab5 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerShellIconVariant.swift @@ -0,0 +1,112 @@ +#if os(iOS) +import SwiftUI + +/// Variants used by the debug-only Shell icon lab. +/// +/// Keep each option close to the current treatment so one visual variable can +/// be judged at a time. The chosen values are applied at render time and never +/// mutate the user's persisted task templates. Release builds collapse every +/// render parameter to ``current``. +enum TaskComposerShellIconVariant: String, CaseIterable, Identifiable, Sendable { + case current + case regular + case medium + case semibold92 + case semibold86 + case medium92 + case medium86 + case regular92 + case regular86 + case regular78 + case soft86 + case inset90 + + var id: String { rawValue } + + var code: String { + switch self { + case .current: "A" + case .regular: "B" + case .medium: "C" + case .semibold92: "D" + case .semibold86: "E" + case .medium92: "F" + case .medium86: "G" + case .regular92: "H" + case .regular86: "I" + case .regular78: "J" + case .soft86: "K" + case .inset90: "L" + } + } + + var title: String { + switch self { + case .current: "100% · 600" + case .regular: "100% · 400" + case .medium: "100% · 500" + case .semibold92: "92% · 600" + case .semibold86: "86% · 600" + case .medium92: "92% · 500" + case .medium86: "86% · 500" + case .regular92: "92% · 400" + case .regular86: "86% · 400" + case .regular78: "78% · 400" + case .soft86: "86% · 500" + case .inset90: "90% · 500" + } + } + + var detail: String { + switch self { + case .current, .regular, .medium: "22 pt" + case .semibold92, .medium92, .regular92: "20 pt" + case .semibold86, .medium86, .regular86: "19 pt" + case .regular78: "17 pt" + case .soft86: "19 pt · α82" + case .inset90: "20 pt · ○90" + } + } + + var glyphScale: CGFloat { + switch renderedVariant { + case .current, .regular, .medium: 1 + case .semibold92, .medium92, .regular92: 0.92 + case .semibold86, .medium86, .regular86, .soft86: 0.86 + case .regular78: 0.78 + case .inset90: 0.90 + } + } + + var glyphWeight: Font.Weight { + switch renderedVariant { + case .current, .semibold92, .semibold86: + .semibold + case .medium, .medium92, .medium86, .soft86, .inset90: + .medium + case .regular, .regular92, .regular86, .regular78: + .regular + } + } + + var glyphOpacity: Double { + renderedVariant == .soft86 ? 0.82 : 1 + } + + var circleScale: CGFloat { + renderedVariant == .inset90 ? 0.90 : 1 + } + + var circleOpacityScale: Double { + renderedVariant == .soft86 ? 0.80 : 1 + } + + private var renderedVariant: TaskComposerShellIconVariant { + #if DEBUG + self + #else + .current + #endif + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerTemplateOption.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerTemplateOption.swift new file mode 100644 index 000000000000..ee20902e53c7 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerTemplateOption.swift @@ -0,0 +1,117 @@ +#if os(iOS) +import CmuxMobileShellModel +import SwiftUI + +/// One agent/template choice in the horizontal launch selector. +struct TaskComposerTemplateOption: View { + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(MobileDisplaySettings.self) private var displaySettings + @ScaledMetric(relativeTo: .caption) private var cardWidth: CGFloat = 91 + @ScaledMetric(relativeTo: .caption) private var cardHeight: CGFloat = 48 + @ScaledMetric(relativeTo: .caption) private var iconDiameter: CGFloat = 29 + @ScaledMetric(relativeTo: .caption) private var iconSize: CGFloat = 17 + + let template: MobileTaskTemplate + let isSelected: Bool + let isDisabled: Bool + var shellIconVariant: TaskComposerShellIconVariant? + let action: () -> Void + + init( + template: MobileTaskTemplate, + isSelected: Bool, + isDisabled: Bool, + shellIconVariant: TaskComposerShellIconVariant? = nil, + action: @escaping () -> Void + ) { + self.template = template + self.isSelected = isSelected + self.isDisabled = isDisabled + self.shellIconVariant = shellIconVariant + self.action = action + } + + var body: some View { + Button(action: action) { + HStack(spacing: 7) { + ZStack(alignment: .topTrailing) { + Circle() + .fill( + isSelected + ? Color.accentColor.opacity(0.16 * resolvedShellIconVariant.circleOpacityScale) + : Color.primary.opacity(0.055 * resolvedShellIconVariant.circleOpacityScale) + ) + .frame( + width: resolvedIconDiameter * resolvedShellIconVariant.circleScale, + height: resolvedIconDiameter * resolvedShellIconVariant.circleScale + ) + TaskTemplateIcon( + value: template.icon, + size: resolvedIconSize, + shellVariant: resolvedShellIconVariant + ) + .frame(width: resolvedIconDiameter, height: resolvedIconDiameter) + if isSelected { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 10, weight: .bold)) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, Color.accentColor) + .offset(x: 3, y: -2) + .accessibilityHidden(true) + } + } + + Text(template.name) + .font(.caption2.weight(.semibold)) + .foregroundStyle(isSelected ? Color.accentColor : .primary) + .lineLimit(1) + .minimumScaleFactor(0.72) + } + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .frame(width: resolvedCardWidth, height: resolvedCardHeight) + .background( + isSelected ? Color.accentColor.opacity(0.13) : Color.primary.opacity(0.038), + in: Capsule() + ) + .overlay { + Capsule() + .stroke( + isSelected ? Color.accentColor.opacity(0.62) : Color.primary.opacity(0.065), + lineWidth: isSelected ? 1.25 : 1 + ) + } + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .disabled(isDisabled) + .accessibilityHint(TaskComposerSheet.templateAccessibilityHint) + .accessibilityAddTraits(isSelected ? .isSelected : []) + .animation(.snappy(duration: 0.2), value: isSelected) + } + + private var resolvedCardWidth: CGFloat { + min(cardWidth, dynamicTypeSize.isAccessibilitySize ? 122 : 98) + } + + private var resolvedCardHeight: CGFloat { + min(cardHeight, dynamicTypeSize.isAccessibilitySize ? 60 : 52) + } + + private var resolvedIconDiameter: CGFloat { + min(iconDiameter, dynamicTypeSize.isAccessibilitySize ? 34 : 31) + } + + private var resolvedIconSize: CGFloat { + min(iconSize, dynamicTypeSize.isAccessibilitySize ? 20 : 18) + } + + private var resolvedShellIconVariant: TaskComposerShellIconVariant { + guard template.icon == "terminal" else { return .current } + #if DEBUG + return shellIconVariant ?? displaySettings.taskComposerShellIconVariant + #else + return .current + #endif + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerTemplatePicker.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerTemplatePicker.swift new file mode 100644 index 000000000000..75ec08efb3a3 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerTemplatePicker.swift @@ -0,0 +1,70 @@ +#if os(iOS) +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI + +/// Selects the agent command while keeping template management visually secondary. +struct TaskComposerTemplatePicker: View { + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + let templates: [MobileTaskTemplate] + let selectedTemplateID: MobileTaskTemplate.ID? + let isDisabled: Bool + let selectTemplate: (MobileTaskTemplate) -> Void + let editTemplates: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(L10n.string("mobile.taskComposer.agent", defaultValue: "Agent")) + .font(.headline) + .accessibilityIdentifier("MobileTaskComposerRoute") + Spacer() + Button(action: editTemplates) { + Label( + L10n.string("mobile.common.edit", defaultValue: "Edit"), + systemImage: "slider.horizontal.3" + ) + .font(.subheadline.weight(.medium)) + .frame(minHeight: 44) + } + .buttonStyle(.plain) + .frame(minWidth: 44, minHeight: 46) + .contentShape(Rectangle()) + .foregroundStyle(Color.accentColor) + .disabled(isDisabled) + .accessibilityIdentifier("MobileTaskComposerEditTemplatesButton") + } + + if templates.isEmpty { + Label( + L10n.string( + "mobile.taskComposer.validation.template", + defaultValue: "Add an agent before starting a task." + ), + systemImage: "person.crop.circle.badge.exclamationmark" + ) + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(minHeight: 44) + } else { + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(spacing: 10) { + ForEach(templates) { template in + TaskComposerTemplateOption( + template: template, + isSelected: template.id == selectedTemplateID, + isDisabled: isDisabled, + action: { selectTemplate(template) } + ) + } + } + .padding(.vertical, 2) + } + .contentMargins(.horizontal, 1, for: .scrollContent) + .frame(height: dynamicTypeSize.isAccessibilitySize ? 64 : 52) + } + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskTemplateEditorView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskTemplateEditorView.swift new file mode 100644 index 000000000000..eb3331b423ca --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskTemplateEditorView.swift @@ -0,0 +1,89 @@ +#if os(iOS) +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI + +struct TaskTemplateEditorView: View { + @Environment(\.dismiss) private var dismiss + @State private var editingTemplate: MobileTaskTemplate? + @State private var isAddingTemplate = false + + let templates: [MobileTaskTemplate] + let addTemplate: (MobileTaskTemplate) -> Void + let updateTemplate: (MobileTaskTemplate) -> Void + let deleteTemplates: (IndexSet) -> Void + let refresh: () -> Void + + var body: some View { + NavigationStack { + List { + Section { + ForEach(templates) { template in + Button { + editingTemplate = template + } label: { + HStack(spacing: 12) { + TaskTemplateIcon(value: template.icon) + .frame(width: 28, height: 28) + VStack(alignment: .leading, spacing: 2) { + Text(template.name) + .foregroundStyle(.primary) + Text( + template.isPlainShell + ? L10n.string( + "mobile.taskComposer.template.plainShell", + defaultValue: "Plain shell" + ) + : template.command + ) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + } + .onDelete { offsets in + deleteTemplates(offsets) + refresh() + } + } footer: { + Text(L10n.string( + "mobile.taskComposer.template.hint", + defaultValue: "The task prompt is available to the command as $CMUX_TASK_PROMPT. Example: claude -- \"$CMUX_TASK_PROMPT\"" + )) + } + } + .navigationTitle(L10n.string("mobile.taskComposer.templates.title", defaultValue: "Task Templates")) + .mobileInlineNavigationTitle() + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.string("mobile.common.done", defaultValue: "Done")) { + dismiss() + } + } + ToolbarItem(placement: .primaryAction) { + Button { + isAddingTemplate = true + } label: { + Image(systemName: "plus") + } + .accessibilityLabel(L10n.string("mobile.taskComposer.template.add", defaultValue: "Add Template")) + } + } + .sheet(item: $editingTemplate) { template in + TaskTemplateFormView(template: template) { updated in + updateTemplate(updated) + refresh() + } + } + .sheet(isPresented: $isAddingTemplate) { + TaskTemplateFormView(template: nil) { template in + addTemplate(template) + refresh() + } + } + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskTemplateFormView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskTemplateFormView.swift new file mode 100644 index 000000000000..5ae6e83bb8f3 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskTemplateFormView.swift @@ -0,0 +1,94 @@ +#if os(iOS) +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI + +/// Add/edit form for one task template, presented from ``TaskTemplateEditorView``. +struct TaskTemplateFormView: View { + @Environment(\.dismiss) private var dismiss + private let existing: MobileTaskTemplate? + private let onSave: (MobileTaskTemplate) -> Void + + @State private var name: String + @State private var icon: String + @State private var command: String + @State private var defaultDirectory: String + + init(template: MobileTaskTemplate?, onSave: @escaping (MobileTaskTemplate) -> Void) { + self.existing = template + self.onSave = onSave + _name = State(initialValue: template?.name ?? "") + _icon = State(initialValue: template?.icon ?? "terminal") + _command = State(initialValue: template?.command ?? "") + _defaultDirectory = State(initialValue: template?.defaultDirectory ?? "") + } + + var body: some View { + NavigationStack { + Form { + Section(L10n.string("mobile.taskComposer.template.details", defaultValue: "Details")) { + TextField(L10n.string("mobile.taskComposer.template.name", defaultValue: "Name"), text: $name) + TaskTemplateIconPicker(selection: $icon) + } + Section(L10n.string("mobile.taskComposer.template.command", defaultValue: "Command")) { + TextField( + L10n.string( + "mobile.taskComposer.template.commandPlaceholder", + defaultValue: "claude -- \"$CMUX_TASK_PROMPT\"" + ), + text: $command, + axis: .vertical + ) + .lineLimit(3...10) + .font(.system(.body, design: .monospaced)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + Text(L10n.string( + "mobile.taskComposer.template.hint", + defaultValue: "The task prompt is available to the command as $CMUX_TASK_PROMPT. Example: claude -- \"$CMUX_TASK_PROMPT\"" + )) + .font(.caption) + .foregroundStyle(.secondary) + } + Section(L10n.string("mobile.taskComposer.directory", defaultValue: "Directory")) { + TextField("~", text: $defaultDirectory) + .font(.system(.body, design: .monospaced)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .accessibilityLabel(L10n.string("mobile.taskComposer.directory", defaultValue: "Directory")) + } + } + .navigationTitle(existing == nil ? L10n.string("mobile.taskComposer.template.addTitle", defaultValue: "Add Template") : L10n.string("mobile.taskComposer.template.editTitle", defaultValue: "Edit Template")) + .mobileInlineNavigationTitle() + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.string("mobile.common.cancel", defaultValue: "Cancel")) { + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button(L10n.string("mobile.common.save", defaultValue: "Save")) { + save() + } + .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + } + + private func save() { + let directory = defaultDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedCommand = command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "" + : command + onSave(MobileTaskTemplate( + id: existing?.id ?? UUID(), + name: name.trimmingCharacters(in: .whitespacesAndNewlines), + icon: icon.trimmingCharacters(in: .whitespacesAndNewlines), + command: normalizedCommand, + defaultDirectory: directory.isEmpty ? nil : directory + )) + dismiss() + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskTemplateIcon.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskTemplateIcon.swift new file mode 100644 index 000000000000..cf0c70f959b0 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskTemplateIcon.swift @@ -0,0 +1,77 @@ +#if os(iOS) +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI +import UIKit + +/// Renders a task template's icon: a bundled agent brand image (`agent:` +/// values), an SF Symbol name, or a single emoji. +struct TaskTemplateIcon: View { + let value: String + var size: CGFloat = 18 + var shellVariant: TaskComposerShellIconVariant = .current + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + if let baseName = MobileTaskTemplate.agentIconAssetName(for: value), + let image = Self.brandImage(baseName: baseName, darkMode: colorScheme == .dark) { + Image(uiImage: image) + .resizable() + .scaledToFit() + .frame(width: size, height: size) + .accessibilityHidden(true) + } else { + switch MacAvatarIcon.resolve(custom: value, defaultSymbol: "terminal") { + case .symbol(let name): + let usesShellTreatment = name == "terminal" + Image(systemName: name) + .font(.system( + size: size * (usesShellTreatment ? shellVariant.glyphScale : 1), + weight: usesShellTreatment ? shellVariant.glyphWeight : .semibold + )) + .opacity(usesShellTreatment ? shellVariant.glyphOpacity : 1) + .accessibilityHidden(true) + case .emoji(let emoji): + Text(emoji) + .font(.system(size: size)) + .accessibilityHidden(true) + } + } + } +} + +/// Brand PNGs are shipped as loose package resources and loaded by explicit +/// file path. SwiftPM flattens processed resource directories into the bundle +/// root. This deliberately avoids asset-catalog and `UIImage(named:in:)` +/// lookups: dev reloads apply PRODUCT_BUNDLE_IDENTIFIER to every target, so +/// the SwiftPM resource bundle shares the app's identifier and CoreUI's +/// per-identifier catalog registration resolves against the wrong catalog. +extension TaskTemplateIcon { + @MainActor private static let bundledBrandImages: [String: UIImage] = Dictionary( + uniqueKeysWithValues: ["Claude", "Codex", "Codex-dark", "OpenCode"].compactMap { fileName in + loadBundledBrandImage(fileName: fileName).map { (fileName, $0) } + } + ) + + /// Returns the brand image for `baseName` (e.g. "Codex"), preferring a + /// `-dark` variant file in dark mode when one is bundled. + @MainActor static func brandImage(baseName: String, darkMode: Bool) -> UIImage? { + if darkMode, let dark = bundledBrandImages["\(baseName)-dark"] { + return dark + } + return bundledBrandImages[baseName] + } + + @MainActor private static func loadBundledBrandImage(fileName: String) -> UIImage? { + guard let url = Bundle.module.url( + forResource: "\(fileName)@3x", + withExtension: "png" + ), let data = try? Data(contentsOf: url), + let image = UIImage(data: data, scale: 3) else { + return nil + } + return image + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskTemplateIconPicker.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskTemplateIconPicker.swift new file mode 100644 index 000000000000..c65536242565 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskTemplateIconPicker.swift @@ -0,0 +1,123 @@ +#if os(iOS) +import CmuxMobileSupport +import SwiftUI + +struct TaskTemplateIconPicker: View { + @Binding var selection: String + @State private var emojiInput: String + + init(selection: Binding) { + self._selection = selection + // Show an existing custom-emoji icon in the emoji field so reopening + // the editor reflects the current selection. + self._emojiInput = State(initialValue: Self.customEmojiInput(for: selection.wrappedValue)) + } + + /// Brand icons first (proper nouns, not localized), then SF Symbols. + private static let agentValues = [ + "agent:claude", + "agent:codex", + "agent:opencode", + ] + + private static let symbols = [ + "terminal", + "hammer", + "wrench.and.screwdriver", + "globe", + "folder", + "bolt", + "testtube.2", + "ladybug", + "doc.text", + "shippingbox", + ] + + private static let gridValues = agentValues + symbols + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: 6), spacing: 10) { + ForEach(Self.gridValues, id: \.self) { symbol in + iconButton(value: symbol) + } + } + TextField( + L10n.string("mobile.taskComposer.template.iconEmoji", defaultValue: "Custom emoji"), + text: $emojiInput + ) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .onChange(of: emojiInput) { _, value in + guard let first = value.trimmingCharacters(in: .whitespacesAndNewlines).first else { return } + let emoji = String(first) + selection = emoji + if emojiInput != emoji { + emojiInput = emoji + } + } + } + } + + @ViewBuilder + private func iconButton(value: String) -> some View { + let isSelected = selection == value + Button { + selection = value + emojiInput = Self.customEmojiInput(for: value) + } label: { + TaskTemplateIcon(value: value) + .frame(width: 36, height: 36) + .background(isSelected ? Color.accentColor.opacity(0.2) : Color.secondary.opacity(0.12), in: Circle()) + .overlay(Circle().strokeBorder(isSelected ? Color.accentColor : .clear, lineWidth: 2)) + } + .buttonStyle(.plain) + .frame(minWidth: 44, minHeight: 44) + .accessibilityLabel(Self.accessibilityName(for: value)) + .accessibilityHint( + L10n.string( + "mobile.taskComposer.template.icon.accessibilityHint", + defaultValue: "Selects this icon for the template." + ) + ) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + + static func customEmojiInput(for selection: String) -> String { + Self.gridValues.contains(selection) ? "" : selection + } + + private static func accessibilityName(for value: String) -> String { + switch value { + case "agent:claude": + return L10n.string("mobile.taskComposer.template.seed.claude", defaultValue: "Claude") + case "agent:codex": + return L10n.string("mobile.taskComposer.template.seed.codex", defaultValue: "Codex") + case "agent:opencode": + return L10n.string("mobile.taskComposer.template.seed.opencode", defaultValue: "OpenCode") + case "terminal": + return L10n.string("mobile.taskComposer.template.icon.terminal", defaultValue: "Terminal") + case "hammer": + return L10n.string("mobile.taskComposer.template.icon.hammer", defaultValue: "Hammer") + case "wrench.and.screwdriver": + return L10n.string("mobile.taskComposer.template.icon.tools", defaultValue: "Tools") + case "globe": + return L10n.string("mobile.taskComposer.template.icon.globe", defaultValue: "Globe") + case "folder": + return L10n.string("mobile.taskComposer.template.icon.folder", defaultValue: "Folder") + case "bolt": + return L10n.string("mobile.taskComposer.template.icon.bolt", defaultValue: "Bolt") + case "testtube.2": + return L10n.string("mobile.taskComposer.template.icon.test", defaultValue: "Test") + case "ladybug": + return L10n.string("mobile.taskComposer.template.icon.bug", defaultValue: "Bug") + case "doc.text": + return L10n.string("mobile.taskComposer.template.icon.document", defaultValue: "Document") + case "shippingbox": + return L10n.string("mobile.taskComposer.template.icon.package", defaultValue: "Package") + default: + return value + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView+WorkspaceActionToast.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView+WorkspaceActionToast.swift index f910f6c0f74c..673d31987de3 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView+WorkspaceActionToast.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView+WorkspaceActionToast.swift @@ -29,7 +29,7 @@ extension WorkspaceShellView { guard case let .failure(failure) = result else { return } withAnimation(.snappy(duration: 0.2)) { workspaceActionToast = WorkspaceActionToastContent( - message: workspaceActionFailureMessage(action: action, failure: failure) + message: Self.workspaceActionFailureMessage(action: action, failure: failure) ) } } @@ -40,7 +40,7 @@ extension WorkspaceShellView { } } - private func workspaceActionFailureMessage( + static func workspaceActionFailureMessage( action: WorkspaceActionToastAction, failure: MobileWorkspaceMutationFailure ) -> String { @@ -54,7 +54,7 @@ extension WorkspaceShellView { ) } - private func workspaceActionFailureActionText(_ action: WorkspaceActionToastAction) -> String { + private static func workspaceActionFailureActionText(_ action: WorkspaceActionToastAction) -> String { switch action { case .createWorkspace: return L10n.string("mobile.workspaceAction.failure.action.createWorkspace", defaultValue: "create workspace") @@ -89,7 +89,7 @@ extension WorkspaceShellView { } } - private func workspaceActionFailureReasonText(_ failure: MobileWorkspaceMutationFailure) -> String { + private static func workspaceActionFailureReasonText(_ failure: MobileWorkspaceMutationFailure) -> String { switch failure { case let .notConnected(hostDisplayName): if let hostDisplayName = trimmedWorkspaceActionHostDisplayName(hostDisplayName) { @@ -161,6 +161,21 @@ extension WorkspaceShellView { "mobile.workspaceAction.failure.reason.rejected.generic", defaultValue: "was rejected by your Mac" ) + case .invalidWorkingDirectory: + return L10n.string( + "mobile.workspaceAction.failure.reason.invalidWorkingDirectory", + defaultValue: "the working directory isn't available on your Mac; choose another directory" + ) + case .persistenceUnavailable: + return L10n.string( + "mobile.workspaceAction.failure.reason.persistence", + defaultValue: "your Mac could not safely reserve the request" + ) + case .alreadyCompleted: + return L10n.string( + "mobile.workspaceAction.failure.reason.alreadyCompleted", + defaultValue: "your Mac already accepted the request; refresh workspaces before trying again" + ) case let .unsupported(hostDisplayName): if let hostDisplayName = trimmedWorkspaceActionHostDisplayName(hostDisplayName) { return String.localizedStringWithFormat( @@ -178,7 +193,7 @@ extension WorkspaceShellView { } } - private func trimmedWorkspaceActionHostDisplayName(_ hostDisplayName: String?) -> String? { + private static func trimmedWorkspaceActionHostDisplayName(_ hostDisplayName: String?) -> String? { guard let hostDisplayName = hostDisplayName?.trimmingCharacters(in: .whitespacesAndNewlines), !hostDisplayName.isEmpty else { return nil diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView+WorkspaceActions.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView+WorkspaceActions.swift index 41506bb41072..d35247e31073 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView+WorkspaceActions.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView+WorkspaceActions.swift @@ -3,6 +3,40 @@ import CmuxMobileShellModel import Foundation extension WorkspaceShellView { + #if os(iOS) + var submitTaskComposerFromShell: @MainActor ( + String, + MobileWorkspaceCreateSpec, + @escaping @MainActor () -> Void + ) async -> Result { + let store = store + return { macDeviceID, spec, composerWillStartCreate in + pendingCompactCreateNavigationWorkspaceIDs = nil + var existingWorkspaceIDs: Set? + let result = await store.submitTaskComposer( + macDeviceID: macDeviceID, + spec: spec, + willStartCreate: { + composerWillStartCreate() + guard usesCompactStack else { return } + let targetWorkspaceIDs = Set(store.workspaces.map(\.id)) + existingWorkspaceIDs = targetWorkspaceIDs + pendingCompactCreateNavigationWorkspaceIDs = targetWorkspaceIDs + } + ) + if usesCompactStack, let existingWorkspaceIDs { + settlePendingCompactCreateNavigation( + result: result, + existingWorkspaceIDs: existingWorkspaceIDs + ) + } else { + pendingCompactCreateNavigationWorkspaceIDs = nil + } + return result + } + } + #endif + /// Workspace action closures, always present for the real store. Row and /// detail affordances gate themselves on each workspace's owning-Mac /// capability snapshot, so a secondary Mac is not hidden behind the @@ -158,7 +192,10 @@ extension WorkspaceShellView { pendingCompactCreateNavigationWorkspaceIDs = existingWorkspaceIDs if store.usesLocalWorkspaceCreationFallback { store.createWorkspace(inGroup: groupID) - clearPendingCompactCreateNavigationIfSettled(existingWorkspaceIDs: existingWorkspaceIDs) + settlePendingCompactCreateNavigation( + result: .success(()), + existingWorkspaceIDs: existingWorkspaceIDs + ) return } Task { @MainActor in @@ -167,11 +204,10 @@ extension WorkspaceShellView { result, action: groupID == nil ? .createWorkspace : .createWorkspaceInGroup ) - if case .failure = result { - pendingCompactCreateNavigationWorkspaceIDs = nil - return - } - clearPendingCompactCreateNavigationIfSettled(existingWorkspaceIDs: existingWorkspaceIDs) + settlePendingCompactCreateNavigation( + result: result, + existingWorkspaceIDs: existingWorkspaceIDs + ) } } @@ -202,13 +238,16 @@ extension WorkspaceShellView { } } - private func clearPendingCompactCreateNavigationIfSettled( + func settlePendingCompactCreateNavigation( + result: Result, existingWorkspaceIDs: Set ) { - if let createdPath = compactNavigationPolicy.pathForCreatedWorkspaceSelection( + let succeeded = if case .success = result { true } else { false } + if let createdPath = compactNavigationPolicy.pathForCompletedCreate( currentPath: compactNavigationPath, selectedWorkspaceID: store.selectedWorkspaceID, - existingWorkspaceIDs: existingWorkspaceIDs + existingWorkspaceIDs: existingWorkspaceIDs, + succeeded: succeeded ) { pendingCompactCreateNavigationWorkspaceIDs = nil compactNavigationPath = createdPath diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift index ca10038dbb18..dc4d0c6c28a8 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift @@ -26,6 +26,7 @@ struct WorkspaceShellView: View { @State private var splitColumnVisibility: NavigationSplitViewVisibility = .automatic @State private var macSelection: WorkspaceMacSelection = .all @State var workspaceActionToast: WorkspaceActionToastContent? + @State private var isTaskComposerPresented = false @State private var pendingMacSwitchID: String? @State private var pendingMacSwitchGeneration: UInt64 = 0 var workspaceActionToastClock: any Clock = ContinuousClock() @@ -34,7 +35,7 @@ struct WorkspaceShellView: View { @Environment(\.verticalSizeClass) private var verticalSizeClass #endif - private var usesCompactStack: Bool { + var usesCompactStack: Bool { #if os(iOS) MobileWorkspaceShellLayoutPolicy.usesCompactStack( horizontalSizeClass: horizontalSizeClass, @@ -100,6 +101,14 @@ struct WorkspaceShellView: View { .onAppear { consumeDeeplinkNavigationRequestIfNeeded() } + #if os(iOS) + .sheet(isPresented: $isTaskComposerPresented) { + TaskComposerSheet( + store: store, + submitTaskComposer: submitTaskComposerFromShell + ) + } + #endif .accessibilityIdentifier("MobileWorkspaceShell") } @@ -151,6 +160,11 @@ struct WorkspaceShellView: View { initialConnectionTimedOut: initialConnectionTimedOut, retryInitialConnection: retryInitialConnection ) + #if os(iOS) + .overlay(alignment: .bottomTrailing) { + taskComposerButtonOverlay + } + #endif .navigationDestination(for: MobileWorkspacePreview.ID.self) { workspaceID in workspaceDestination( for: workspaceID, @@ -258,6 +272,11 @@ struct WorkspaceShellView: View { initialConnectionTimedOut: initialConnectionTimedOut, retryInitialConnection: retryInitialConnection ) + #if os(iOS) + .overlay(alignment: .bottomTrailing) { + taskComposerButtonOverlay + } + #endif .navigationSplitViewColumnWidth(min: 320, ideal: 380, max: 440) } detail: { workspaceDestination( @@ -272,6 +291,19 @@ struct WorkspaceShellView: View { } } + #if os(iOS) + @ViewBuilder + private var taskComposerButtonOverlay: some View { + if displaySettings.taskComposerEnabled { + TaskComposerButton { + isTaskComposerPresented = true + } + .padding(.trailing, 20) + .padding(.bottom, 6) + } + } + #endif + /// Apply (and clear) a pending deep-link navigation intent. On the compact /// stack this pushes the workspace; on the split layout the store's /// selection already presents the detail column, so consuming just clears @@ -429,7 +461,7 @@ struct InteractiveSwipeBackEnabler: UIViewControllerRepresentable { } // The pushed workspace detail hosts surfaces with their own pan/scroll - // gesture recognizers — the terminal's full-bounds scroll-mechanics + // gesture recognizers: the terminal's full-bounds scroll-mechanics // `UIScrollView` and the browser's `WKWebView` scroll view. Taking over // the navigation controller's `interactivePopGestureRecognizer` delegate // (above, so the custom back button can re-enable the swipe) drops diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift index 0ac88ba7c827..5fdeb6a4f029 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift @@ -74,6 +74,22 @@ import Testing #expect(!MobileDisplaySettings(defaults: defaults).terminalFilesChipEnabled) } + @Test func taskComposerDefaultsToFalseWithoutAWrite() throws { + let defaults = try makeDefaults("taskComposerDefaults") + let settings = MobileDisplaySettings(defaults: defaults) + #expect(!settings.taskComposerEnabled) + #expect(defaults.object(forKey: "cmux.mobile.taskComposerEnabled") == nil) + } + + @Test func taskComposerPersistsAcrossInstances() throws { + let defaults = try makeDefaults("taskComposerPersists") + let settings = MobileDisplaySettings(defaults: defaults) + settings.taskComposerEnabled = true + #expect(MobileDisplaySettings(defaults: defaults).taskComposerEnabled) + settings.taskComposerEnabled = false + #expect(!MobileDisplaySettings(defaults: defaults).taskComposerEnabled) + } + @Test func previewLineCountPersistsAcrossInstances() throws { let defaults = try makeDefaults("persists") let settings = MobileDisplaySettings(defaults: defaults) @@ -103,7 +119,45 @@ import Testing #expect(defaults.object(forKey: "cmux.mobile.debug.unreadIndicatorLeftShift.v2") == nil) #expect(defaults.object(forKey: "cmux.mobile.debug.profilePictureLeftShift") == nil) #expect(defaults.object(forKey: "cmux.mobile.debug.profilePictureSize") == nil) + #expect(settings.taskComposerShellIconVariant == .current) + #expect(defaults.object(forKey: "cmux.mobile.debug.taskComposerShellIconVariant.v1") == nil) + } + + @Test func shellIconExperimentsAreScopedToDebugBuilds() throws { + let defaults = try makeDefaults("shellIconBuildScope") + defaults.set( + TaskComposerShellIconVariant.medium86.rawValue, + forKey: "cmux.mobile.debug.taskComposerShellIconVariant.v1" + ) + + let settings = MobileDisplaySettings(defaults: defaults) + #if DEBUG + #expect(settings.taskComposerShellIconVariant == .medium86) + #expect(TaskComposerShellIconVariant.medium86.glyphScale == 0.86) + #else + #expect(settings.taskComposerShellIconVariant == .current) + let current = TaskComposerShellIconVariant.current + for variant in TaskComposerShellIconVariant.allCases { + #expect(variant.glyphScale == current.glyphScale) + #expect(variant.glyphWeight == current.glyphWeight) + #expect(variant.glyphOpacity == current.glyphOpacity) + #expect(variant.circleScale == current.circleScale) + #expect(variant.circleOpacityScale == current.circleOpacityScale) + } + #endif + } + + #if DEBUG + @Test func shellIconVariantPersistsAndRejectsUnknownValues() throws { + let defaults = try makeDefaults("shellIconVariant") + let settings = MobileDisplaySettings(defaults: defaults) + settings.taskComposerShellIconVariant = .medium86 + #expect(MobileDisplaySettings(defaults: defaults).taskComposerShellIconVariant == .medium86) + + defaults.set("removed-variant", forKey: "cmux.mobile.debug.taskComposerShellIconVariant.v1") + #expect(MobileDisplaySettings(defaults: defaults).taskComposerShellIconVariant == .current) } + #endif @Test func debugLayoutSettingsPersistAcrossInstances() throws { let defaults = try makeDefaults("debugLayoutPersists") diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TaskComposerDirectoryBrowseStateTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TaskComposerDirectoryBrowseStateTests.swift new file mode 100644 index 000000000000..82ca120caa32 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TaskComposerDirectoryBrowseStateTests.swift @@ -0,0 +1,246 @@ +#if os(iOS) +import CmuxMobileRPC +import CmuxMobileShell +import Foundation +import Testing +@testable import CmuxMobileShellUI + +@Suite struct TaskComposerDirectoryBrowseStateTests { + @Test func pageTwoFailurePreservesPageOneAndRetriesTheExactOffset() throws { + var state = TaskComposerDirectoryBrowseState(initialPath: "/root") + let firstRequest = try #require(state.pendingRequest) + state.resolve( + .success(try Self.page( + path: "/root", + entries: [ + Self.entry(name: "a", path: "/root/a"), + Self.entry(name: "b", path: "/root/b"), + ], + offset: 0, + totalCount: 4, + nextOffset: 2 + )), + for: firstRequest + ) + let firstPage = try #require(state.snapshot) + + let didRequestNextPage = state.requestNextPage() + #expect(didRequestNextPage) + let failedRequest = try #require(state.pendingRequest) + state.resolve(.failure(.timedOut), for: failedRequest) + + #expect(state.snapshot == firstPage) + #expect(state.failure?.reason == .timedOut) + #expect(state.failure?.request == failedRequest) + + let didRetry = state.retryFailedRequest() + #expect(didRetry) + let retryRequest = try #require(state.pendingRequest) + #expect(retryRequest.path == failedRequest.path) + #expect(retryRequest.offset == failedRequest.offset) + #expect(retryRequest.expectedCurrentPath == failedRequest.expectedCurrentPath) + #expect(retryRequest.kind == failedRequest.kind) + #expect(retryRequest.generation != failedRequest.generation) + + state.resolve( + .success(try Self.page( + path: "/root", + entries: [ + Self.entry(name: "c", path: "/root/c"), + Self.entry(name: "d", path: "/root/d"), + ], + offset: 2, + totalCount: 4, + nextOffset: nil + )), + for: retryRequest + ) + + #expect(state.snapshot?.entries.map(\.name) == ["a", "b", "c", "d"]) + #expect(state.snapshot?.nextOffset == nil) + #expect(state.failure == nil) + } + + @Test func navigationFailurePreservesTheLastSuccessfulDirectory() throws { + var state = TaskComposerDirectoryBrowseState(initialPath: "/root") + let initialRequest = try #require(state.pendingRequest) + state.resolve( + .success(try Self.page( + path: "/root", + entries: [Self.entry(name: "a", path: "/root/a")], + offset: 0, + totalCount: 1, + nextOffset: nil + )), + for: initialRequest + ) + let lastSuccessfulDirectory = try #require(state.snapshot) + + let didNavigate = state.navigate(to: "/restricted") + #expect(didNavigate) + let navigationRequest = try #require(state.pendingRequest) + #expect(state.snapshot == lastSuccessfulDirectory) + + state.resolve(.failure(.permissionDenied), for: navigationRequest) + + #expect(state.snapshot == lastSuccessfulDirectory) + #expect(state.displayPath == "/root") + #expect(state.failure?.request == navigationRequest) + } + + @Test func staleResponseCannotReplaceANewerPendingNavigation() throws { + var state = TaskComposerDirectoryBrowseState(initialPath: "/first") + let staleRequest = try #require(state.pendingRequest) + let didNavigate = state.navigate(to: "/second") + #expect(didNavigate) + let currentRequest = try #require(state.pendingRequest) + + state.resolve( + .success(try Self.page( + path: "/first", + entries: [Self.entry(name: "old", path: "/first/old")], + offset: 0, + totalCount: 1, + nextOffset: nil + )), + for: staleRequest + ) + + #expect(state.pendingRequest == currentRequest) + #expect(state.snapshot == nil) + #expect(state.failure == nil) + + state.resolve( + .success(try Self.page( + path: "/second", + entries: [Self.entry(name: "new", path: "/second/new")], + offset: 0, + totalCount: 1, + nextOffset: nil + )), + for: currentRequest + ) + #expect(state.snapshot?.currentPath == "/second") + } + + @Test func mismatchedResponsePathAndOffsetAreRejected() throws { + var wrongPathState = TaskComposerDirectoryBrowseState(initialPath: "/root") + let wrongPathRequest = try #require(wrongPathState.pendingRequest) + wrongPathState.resolve( + .success(try Self.page( + path: "/other", + entries: [Self.entry(name: "a", path: "/other/a")], + offset: 0, + totalCount: 1, + nextOffset: nil + )), + for: wrongPathRequest + ) + #expect(wrongPathState.snapshot == nil) + #expect(wrongPathState.failure?.reason == .rejected) + #expect(wrongPathState.failure?.request == wrongPathRequest) + + var wrongOffsetState = TaskComposerDirectoryBrowseState(initialPath: "/root") + let wrongOffsetRequest = try #require(wrongOffsetState.pendingRequest) + wrongOffsetState.resolve( + .success(try Self.page( + path: "/root", + entries: [Self.entry(name: "b", path: "/root/b")], + offset: 1, + totalCount: 2, + nextOffset: nil + )), + for: wrongOffsetRequest + ) + #expect(wrongOffsetState.snapshot == nil) + #expect(wrongOffsetState.failure?.reason == .rejected) + #expect(wrongOffsetState.failure?.request == wrongOffsetRequest) + } + + @Test func duplicatePathAcrossPagesRejectsTheIncomingPage() throws { + var state = TaskComposerDirectoryBrowseState(initialPath: "/root") + let initialRequest = try #require(state.pendingRequest) + state.resolve( + .success(try Self.page( + path: "/root", + entries: [ + Self.entry(name: "a", path: "/root/a"), + Self.entry(name: "b", path: "/root/b"), + ], + offset: 0, + totalCount: 4, + nextOffset: 2 + )), + for: initialRequest + ) + let firstPage = try #require(state.snapshot) + let didRequestNextPage = state.requestNextPage() + #expect(didRequestNextPage) + let appendRequest = try #require(state.pendingRequest) + + state.resolve( + .success(try Self.page( + path: "/root", + entries: [ + Self.entry(name: "c", path: "/root/b"), + Self.entry(name: "d", path: "/root/d"), + ], + offset: 2, + totalCount: 4, + nextOffset: nil + )), + for: appendRequest + ) + + #expect(state.snapshot == firstPage) + #expect(state.failure?.reason == .rejected) + #expect(state.failure?.request == appendRequest) + } + + @Test func unreadableEntryHasNoNavigationDestination() throws { + let state = TaskComposerDirectoryBrowseState(initialPath: "/root") + let readable = Self.entry(name: "readable", path: "/root/readable") + let unreadable = Self.entry( + name: "unreadable", + path: "/root/unreadable", + isReadable: false + ) + + #expect(state.navigationDestination(for: readable) == "/root/readable") + #expect(state.navigationDestination(for: unreadable) == nil) + } + + private static func entry( + name: String, + path: String, + isReadable: Bool = true + ) -> MobileTaskDirectoryListEntry { + MobileTaskDirectoryListEntry( + name: name, + path: path, + isHidden: false, + isPackage: false, + isSymbolicLink: false, + isReadable: isReadable + )! + } + + private static func page( + path: String, + entries: [MobileTaskDirectoryListEntry], + offset: Int, + totalCount: Int, + nextOffset: Int? + ) throws -> MobileTaskDirectoryListResponse { + try #require(MobileTaskDirectoryListResponse( + currentPath: path, + parentPath: path == "/" ? nil : "/", + entries: entries, + offset: offset, + limit: entries.count, + totalCount: totalCount, + nextOffset: nextOffset + )) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TaskComposerDirectoryDisplayPathTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TaskComposerDirectoryDisplayPathTests.swift new file mode 100644 index 000000000000..c813bc019115 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TaskComposerDirectoryDisplayPathTests.swift @@ -0,0 +1,33 @@ +#if os(iOS) +import Testing +@testable import CmuxMobileShellUI + +@Suite struct TaskComposerDirectoryDisplayPathTests { + @Test func separatesNearbyFolderNamesFromTheirParentPaths() { + let projectPath = "/Users/me/Dev/Manaflow/cmuxterm-hq/worktrees/feat-ios-task-composer" + + let project = TaskComposerDirectoryDisplayPath(path: projectPath) + let web = TaskComposerDirectoryDisplayPath(path: "\(projectPath)/web") + + #expect(project.name == "feat-ios-task-composer") + #expect(project.parentPath == "/Users/me/Dev/Manaflow/cmuxterm-hq/worktrees") + #expect(web.name == "web") + #expect(web.parentPath == projectPath) + } + + @Test func preservesUsefulRootAndHomePathLabels() { + let cases: [(path: String, name: String, parent: String?)] = [ + (path: "/", name: "/", parent: nil), + (path: "~", name: "~", parent: nil), + (path: "~/Dev/cmux/", name: "cmux", parent: "~/Dev"), + (path: "/Users", name: "Users", parent: "/"), + ] + + for testCase in cases { + let display = TaskComposerDirectoryDisplayPath(path: testCase.path) + #expect(display.name == testCase.name) + #expect(display.parentPath == testCase.parent) + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TaskComposerFailureMessageTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TaskComposerFailureMessageTests.swift new file mode 100644 index 000000000000..e1ed812b15dd --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TaskComposerFailureMessageTests.swift @@ -0,0 +1,84 @@ +#if os(iOS) +import CmuxMobileShell +import CmuxMobileShellModel +import Foundation +import Testing +@testable import CmuxMobileShellUI + +@MainActor +@Suite struct TaskComposerFailureMessageTests { + @Test func invalidWorkingDirectoryUsesActionableLocalizedDefault() { + let message = TaskComposerSheet.failureMessage( + .invalidWorkingDirectory(hostDisplayName: "Test Mac") + ) + + #expect(message == "Choose an existing folder on that Mac.") + } + + @Test func persistenceFailureExplainsSafeReservationFailure() { + let message = TaskComposerSheet.failureMessage( + .persistenceUnavailable(hostDisplayName: "Test Mac") + ) + + #expect(message == "The Mac could not safely reserve this task.") + } + + @Test func localDraftPersistenceFailureExplainsHowToRecover() { + #expect( + TaskComposerSheet.draftPersistenceFailureMessage + == "cmux couldn’t save this draft safely. Reopen the composer and try again." + ) + } + + @Test func completedOperationExplainsHowToRecover() { + let message = TaskComposerSheet.failureMessage( + .alreadyCompleted(hostDisplayName: "Test Mac") + ) + + #expect(message == "The Mac already accepted this task. Refresh workspaces before trying again.") + } + + @Test func completedOperationRequiresReconciliationBeforeStartAgain() { + let snapshot = Self.snapshot(operationID: UUID()) + var recovery = TaskComposerCompletedOperationRecovery(submittedSnapshot: snapshot) + + #expect(recovery.phase == .refreshRequired) + #expect(recovery.allowsStartAgain == false) + recovery.recordReconciliationStillMissing() + #expect(recovery.phase == .startAgainAvailable) + #expect(recovery.allowsStartAgain) + #expect(recovery.submittedSnapshot == snapshot) + } + + @Test func startAgainUsesAFreshOperationIdentityWithoutChangingTheDraft() throws { + let snapshot = Self.snapshot(operationID: UUID()) + var identity = MobileTaskSubmissionIdentity( + id: snapshot.operationID, + initialRequest: snapshot + ) + + identity.rotate() + let retried = try #require(identity.resolveCurrentRequest { nil }) + + #expect(retried.operationID != snapshot.operationID) + #expect(retried.isRequestEquivalent(to: snapshot)) + #expect(retried.prompt == snapshot.prompt) + #expect(retried.directory == snapshot.directory) + } + + private static func snapshot(operationID: UUID) -> MobileTaskSubmissionSnapshot { + MobileTaskSubmissionSnapshot( + template: MobileTaskTemplate( + name: "Codex", + icon: "agent:codex", + command: "codex -- \"$CMUX_TASK_PROMPT\"" + ), + prompt: "Fix the flaky test", + macDeviceID: "mac-1", + directory: "~/Dev/cmux", + didEditDirectory: true, + operationID: operationID + ) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TaskComposerSubmissionPhaseTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TaskComposerSubmissionPhaseTests.swift new file mode 100644 index 000000000000..a7834d637e66 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TaskComposerSubmissionPhaseTests.swift @@ -0,0 +1,46 @@ +#if os(iOS) +import Testing +@testable import CmuxMobileShellUI + +@Suite struct TaskComposerSubmissionPhaseTests { + @Test func idleAllowsAnInitialSubmissionWithoutLockingTheDraft() { + let phase = TaskComposerSubmissionPhase.idle + + #expect(phase.allowsSubmission) + #expect(!phase.offersRetry) + #expect(!phase.disablesRequestEditing) + #expect(!phase.showsProgress) + #expect(!phase.locksDismissal) + } + + @Test func retryReadyOffersAnActionableRetryWithoutLockingTheDraft() { + let phase = TaskComposerSubmissionPhase.retryReady + + #expect(phase.allowsSubmission) + #expect(phase.offersRetry) + #expect(!phase.disablesRequestEditing) + #expect(!phase.showsProgress) + #expect(!phase.locksDismissal) + } + + @Test func preparingShowsProgressAndLocksOnlyRequestEditing() { + let phase = TaskComposerSubmissionPhase.preparing + + #expect(!phase.allowsSubmission) + #expect(!phase.offersRetry) + #expect(phase.disablesRequestEditing) + #expect(phase.showsProgress) + #expect(!phase.locksDismissal) + } + + @Test func committedLocksEditingAndDismissalWhileShowingProgress() { + let phase = TaskComposerSubmissionPhase.committed + + #expect(!phase.allowsSubmission) + #expect(!phase.offersRetry) + #expect(phase.disablesRequestEditing) + #expect(phase.showsProgress) + #expect(phase.locksDismissal) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TaskTemplateIconPickerStateTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TaskTemplateIconPickerStateTests.swift new file mode 100644 index 000000000000..2cba749d919c --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TaskTemplateIconPickerStateTests.swift @@ -0,0 +1,13 @@ +#if os(iOS) +import Testing +@testable import CmuxMobileShellUI + +@MainActor +@Suite struct TaskTemplateIconPickerStateTests { + @Test func customEmojiInputReflectsCurrentSelection() { + #expect(TaskTemplateIconPicker.customEmojiInput(for: "🚀") == "🚀") + #expect(TaskTemplateIconPicker.customEmojiInput(for: "terminal").isEmpty) + #expect(TaskTemplateIconPicker.customEmojiInput(for: "agent:codex").isEmpty) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TaskTemplateIconTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TaskTemplateIconTests.swift new file mode 100644 index 000000000000..faaf151a1fbc --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TaskTemplateIconTests.swift @@ -0,0 +1,14 @@ +#if os(iOS) +import Testing +@testable import CmuxMobileShellUI + +@MainActor +@Suite struct TaskTemplateIconTests { + @Test func bundledAgentBrandImagesLoad() { + #expect(TaskTemplateIcon.brandImage(baseName: "Claude", darkMode: false) != nil) + #expect(TaskTemplateIcon.brandImage(baseName: "Codex", darkMode: false) != nil) + #expect(TaskTemplateIcon.brandImage(baseName: "Codex", darkMode: true) != nil) + #expect(TaskTemplateIcon.brandImage(baseName: "OpenCode", darkMode: false) != nil) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceActionFailureMessageTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceActionFailureMessageTests.swift new file mode 100644 index 000000000000..b72aa504ae0f --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceActionFailureMessageTests.swift @@ -0,0 +1,19 @@ +#if os(iOS) +import CmuxMobileShell +import Testing +@testable import CmuxMobileShellUI + +@MainActor +@Suite struct WorkspaceActionFailureMessageTests { + @Test func invalidWorkingDirectoryExplainsRecovery() { + let message = WorkspaceShellView.workspaceActionFailureMessage( + action: .createWorkspace, + failure: .invalidWorkingDirectory(hostDisplayName: "Test Mac") + ) + + #expect( + message == "Couldn't create workspace: the working directory isn't available on your Mac; choose another directory." + ) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift index 11b594834b5e..44394af74313 100644 --- a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift +++ b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift @@ -97,6 +97,28 @@ public struct UITestConfig { #endif } + /// Whether the standalone task-composer accessibility preview is enabled. + /// + /// The preview presents the production sheet with deterministic templates + /// and a paired Mac, so UI tests can inspect its native accessibility tree + /// without depending on authentication or network pairing. DEBUG-only. + public static var taskComposerPreviewEnabled: Bool { + taskComposerPreviewEnabled(from: ProcessInfo.processInfo.environment) + } + + /// Returns whether an explicit environment enables the standalone task + /// composer accessibility preview. + /// + /// - Parameter env: The environment dictionary to inspect. + /// - Returns: `true` only for a DEBUG build whose preview value is `"1"`. + public static func taskComposerPreviewEnabled(from env: [String: String]) -> Bool { + #if DEBUG + return env["CMUX_UITEST_TASK_COMPOSER_PREVIEW"] == "1" + #else + return false + #endif + } + /// Whether the workspace detail delayed-terminal lifecycle preview is enabled. /// /// When `CMUX_UITEST_WORKSPACE_DETAIL_DELAYED_TERMINAL=1`, the root view renders diff --git a/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/UITestConfigTests.swift b/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/UITestConfigTests.swift index c4b919bcf782..0e602274c952 100644 --- a/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/UITestConfigTests.swift +++ b/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/UITestConfigTests.swift @@ -128,6 +128,22 @@ import Testing ) == false) } + @Test func taskComposerPreviewFlagIsDebugOnly() { + let env = ["CMUX_UITEST_TASK_COMPOSER_PREVIEW": "1"] + #if DEBUG + #expect(UITestConfig.taskComposerPreviewEnabled(from: env)) + #else + #expect(!UITestConfig.taskComposerPreviewEnabled(from: env)) + #endif + } + + @Test func taskComposerPreviewFlagRequiresOne() { + #expect(!UITestConfig.taskComposerPreviewEnabled(from: [:])) + #expect(!UITestConfig.taskComposerPreviewEnabled(from: [ + "CMUX_UITEST_TASK_COMPOSER_PREVIEW": "0", + ])) + } + @Test func agentChatPreviewFlagIsDebugOnly() { let env = ["CMUX_UITEST_AGENT_CHAT_PREVIEW": "1"] let config = UITestEnvironmentConfig(environment: env) diff --git a/Packages/iOS/CmuxMobileWorkspace/Sources/CmuxMobileWorkspace/WorkspaceShellCompactNavigationPolicy.swift b/Packages/iOS/CmuxMobileWorkspace/Sources/CmuxMobileWorkspace/WorkspaceShellCompactNavigationPolicy.swift index 57ac87c44401..34fe1d03eac0 100644 --- a/Packages/iOS/CmuxMobileWorkspace/Sources/CmuxMobileWorkspace/WorkspaceShellCompactNavigationPolicy.swift +++ b/Packages/iOS/CmuxMobileWorkspace/Sources/CmuxMobileWorkspace/WorkspaceShellCompactNavigationPolicy.swift @@ -58,6 +58,22 @@ public struct WorkspaceShellCompactNavigationPolicy { return [selectedWorkspaceID] } + /// Resolves a completed create attempt. A failure never changes the path; + /// callers clear their one-shot pending intent after applying this result. + public func pathForCompletedCreate( + currentPath: [ID], + selectedWorkspaceID: ID?, + existingWorkspaceIDs: Set, + succeeded: Bool + ) -> [ID]? { + guard succeeded else { return nil } + return pathForCreatedWorkspaceSelection( + currentPath: currentPath, + selectedWorkspaceID: selectedWorkspaceID, + existingWorkspaceIDs: existingWorkspaceIDs + ) + } + /// Computes the navigation path after the workspace list's visible IDs /// change. Keep the current detail route mounted while the routed workspace /// is still selected, even if a transient list refresh omits it while the diff --git a/Packages/iOS/CmuxMobileWorkspace/Tests/CmuxMobileWorkspaceTests/WorkspaceShellCompactNavigationPolicyTests.swift b/Packages/iOS/CmuxMobileWorkspace/Tests/CmuxMobileWorkspaceTests/WorkspaceShellCompactNavigationPolicyTests.swift index cb10ddbaa1d2..dd0e91828555 100644 --- a/Packages/iOS/CmuxMobileWorkspace/Tests/CmuxMobileWorkspaceTests/WorkspaceShellCompactNavigationPolicyTests.swift +++ b/Packages/iOS/CmuxMobileWorkspace/Tests/CmuxMobileWorkspaceTests/WorkspaceShellCompactNavigationPolicyTests.swift @@ -28,6 +28,42 @@ import Testing #expect(path == [MobileWorkspacePreview.ID(rawValue: "workspace-created")]) } + @Test func composerSuccessFromEmptyPathPushesCreatedWorkspace() { + let path = policy.pathForCompletedCreate( + currentPath: [MobileWorkspacePreview.ID](), + selectedWorkspaceID: .init(rawValue: "workspace-created"), + existingWorkspaceIDs: [.init(rawValue: "workspace-a")], + succeeded: true + ) + + #expect(path == [MobileWorkspacePreview.ID(rawValue: "workspace-created")]) + } + + @Test func composerFailureClearsPendingIntentWithoutPushing() { + let path = policy.pathForCompletedCreate( + currentPath: [MobileWorkspacePreview.ID](), + selectedWorkspaceID: .init(rawValue: "workspace-created"), + existingWorkspaceIDs: [.init(rawValue: "workspace-a")], + succeeded: false + ) + + #expect(path == nil) + } + + @Test func composerSuccessRetargetsExistingNonemptyPath() { + let path = policy.pathForCompletedCreate( + currentPath: [MobileWorkspacePreview.ID(rawValue: "workspace-open")], + selectedWorkspaceID: MobileWorkspacePreview.ID(rawValue: "workspace-created"), + existingWorkspaceIDs: [ + MobileWorkspacePreview.ID(rawValue: "workspace-a"), + MobileWorkspacePreview.ID(rawValue: "workspace-open"), + ], + succeeded: true + ) + + #expect(path == [MobileWorkspacePreview.ID(rawValue: "workspace-created")]) + } + @Test func doesNotTreatExistingSelectionAsCreatedWorkspace() { let path = policy.pathForCreatedWorkspaceSelection( currentPath: [MobileWorkspacePreview.ID](), diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift index ee3b17bab5df..b9ed701cb297 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift @@ -481,8 +481,9 @@ public final class TerminalSurface: Identifiable, ObservableObject { self.configTemplate = configTemplate self.workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) self.portOrdinal = portOrdinal - let trimmedCommand = initialCommand?.trimmingCharacters(in: .whitespacesAndNewlines) - self.initialCommand = (trimmedCommand?.isEmpty == false) ? trimmedCommand : nil + self.initialCommand = initialCommand.flatMap { + $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : $0 + } let trimmedTmuxStartCommand = tmuxStartCommand?.trimmingCharacters(in: .whitespacesAndNewlines) self.tmuxStartCommand = (trimmedTmuxStartCommand?.isEmpty == false) ? trimmedTmuxStartCommand : nil let trimmedInput = initialInput?.isEmpty == false ? initialInput : nil diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index fb399e3ea937..52911d9a9d92 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -3456,9 +3456,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "snapshotDisplay={\(debugSessionDisplayDescription(snapshot.display))}" ) #endif - context.tabManager.restoreSessionSnapshot(snapshot.tabManager) - // Seed the in-memory per-config ring from the restored snapshot so the - // window can return to remembered frames on later configuration switches. + context.tabManager.restoreSessionSnapshot(snapshot.tabManager, workspaceCreateIdempotencyCache: TerminalController.shared.workspaceCreateIdempotencyCache) + // Seed restored per-config frames for later configuration switches. if let configFrames = snapshot.configFrames { windowConfigFrames[context.windowId] = SessionConfigFrameRing(entries: configFrames) } @@ -8585,7 +8584,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let restoredPanelIdsByWorkspaceIndex = tabManager.restoreSessionSnapshot( sessionWindowSnapshot.tabManager, remapClosedPanelHistory: remapClosedPanelHistoryFromSessionSnapshot, - excludingStableIdentities: excludingStableIdentitiesFromSessionSnapshot + excludingStableIdentities: excludingStableIdentitiesFromSessionSnapshot, + workspaceCreateIdempotencyCache: TerminalController.shared.workspaceCreateIdempotencyCache ) if let configFrames = sessionWindowSnapshot.configFrames { windowConfigFrames[windowId] = SessionConfigFrameRing(entries: configFrames) diff --git a/Sources/Mobile/MobileHostService+Capabilities.swift b/Sources/Mobile/MobileHostService+Capabilities.swift index 3aec9674c3a0..3a26f63abc13 100644 --- a/Sources/Mobile/MobileHostService+Capabilities.swift +++ b/Sources/Mobile/MobileHostService+Capabilities.swift @@ -35,6 +35,10 @@ extension MobileHostService { "workspace.group_actions.v1", "workspace.group_create.v1", "workspace.create_in_group.v1", + "workspace.task_create.v1", + "workspace.directory_browse.v1", + "workspace.directory_search.v1", + "workspace.directory_search.v2", "chat.artifact.v1", "chat.artifact.folders.v1", "chat.artifact.gallery.v1", diff --git a/Sources/Mobile/MobileHostService+TicketAuthorization.swift b/Sources/Mobile/MobileHostService+TicketAuthorization.swift index f9ca09fbfb8e..8c19cf3ef974 100644 --- a/Sources/Mobile/MobileHostService+TicketAuthorization.swift +++ b/Sources/Mobile/MobileHostService+TicketAuthorization.swift @@ -38,7 +38,8 @@ extension MobileHostService { } switch request.method { - case "mobile.workspace.list", "workspace.list": + case "mobile.workspace.list", "workspace.list", + "mobile.directory.list", "mobile.directory.search": return nil case "workspace.create": guard request.params["group_id"] == nil || request.params["group_id"] is NSNull else { diff --git a/Sources/Mobile/MobileTaskDirectoryListItem.swift b/Sources/Mobile/MobileTaskDirectoryListItem.swift new file mode 100644 index 000000000000..7c1e6ce88b56 --- /dev/null +++ b/Sources/Mobile/MobileTaskDirectoryListItem.swift @@ -0,0 +1,21 @@ +import Foundation + +/// One direct child directory and its navigation-relevant filesystem metadata. +struct MobileTaskDirectoryListItem: Equatable, Sendable { + let name: String + let path: String + let isHidden: Bool + let isPackage: Bool + let isSymbolicLink: Bool + let isReadable: Bool + + static func precedes(_ lhs: Self, _ rhs: Self) -> Bool { + if lhs.name.utf8.lexicographicallyPrecedes(rhs.name.utf8) { + return true + } + if rhs.name.utf8.lexicographicallyPrecedes(lhs.name.utf8) { + return false + } + return lhs.path.utf8.lexicographicallyPrecedes(rhs.path.utf8) + } +} diff --git a/Sources/Mobile/MobileTaskDirectoryListPage.swift b/Sources/Mobile/MobileTaskDirectoryListPage.swift new file mode 100644 index 000000000000..203031b3adc3 --- /dev/null +++ b/Sources/Mobile/MobileTaskDirectoryListPage.swift @@ -0,0 +1,12 @@ +import Foundation + +/// One exact page from a direct-child directory listing. +struct MobileTaskDirectoryListPage: Equatable, Sendable { + let currentPath: String + let parentPath: String? + let entries: [MobileTaskDirectoryListItem] + let offset: Int + let limit: Int + let totalCount: Int + let nextOffset: Int? +} diff --git a/Sources/Mobile/MobileTaskDirectoryListService.swift b/Sources/Mobile/MobileTaskDirectoryListService.swift new file mode 100644 index 000000000000..ffcc9dbaa1e8 --- /dev/null +++ b/Sources/Mobile/MobileTaskDirectoryListService.swift @@ -0,0 +1,166 @@ +import Darwin +import Foundation + +/// Lists one Mac directory level at a time so mobile can traverse every +/// accessible path without an exhaustive filesystem index. +actor MobileTaskDirectoryListService { + static let maximumPageSize = 100 + + private let fileManager: FileManager + private let homeDirectory: URL + + init( + fileManager: FileManager = .default, + homeDirectory: URL? = nil + ) { + self.fileManager = fileManager + self.homeDirectory = (homeDirectory ?? fileManager.homeDirectoryForCurrentUser) + .standardizedFileURL + } + + func list( + path rawPath: String, + offset requestedOffset: Int, + limit: Int + ) throws -> MobileTaskDirectoryListPage { + guard requestedOffset >= 0, (1...Self.maximumPageSize).contains(limit) else { + throw MobileTaskDirectoryListServiceError.invalidRequest + } + try Task.checkCancellation() + let directory = try resolvedDirectoryURL(rawPath) + let childURLs: [URL] + do { + childURLs = try fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [ + .isHiddenKey, + .isPackageKey, + ], + options: [] + ) + } catch { + throw mappedListingError(error) + } + + var entries: [MobileTaskDirectoryListItem] = [] + entries.reserveCapacity(childURLs.count) + for childURL in childURLs { + try Task.checkCancellation() + if let item = directoryItem(at: childURL) { + entries.append(item) + } + } + entries.sort(by: Self.precedes) + try Task.checkCancellation() + let offset = min(requestedOffset, entries.count) + let end = min(offset + limit, entries.count) + let pageEntries = Array(entries[offset.. URL { + guard Self.isValidRequestPath(rawPath) else { + throw MobileTaskDirectoryListServiceError.invalidPath + } + let expandedPath: String + if rawPath == "~" { + expandedPath = homeDirectory.path + } else if rawPath.hasPrefix("~/") { + expandedPath = homeDirectory + .appendingPathComponent(String(rawPath.dropFirst(2)), isDirectory: true) + .path + } else { + expandedPath = rawPath + } + + let directory = URL(fileURLWithPath: expandedPath, isDirectory: true).standardizedFileURL + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: directory.path, isDirectory: &isDirectory) else { + throw MobileTaskDirectoryListServiceError.notFound + } + guard isDirectory.boolValue else { + throw MobileTaskDirectoryListServiceError.notDirectory + } + guard fileManager.isReadableFile(atPath: directory.path) else { + throw MobileTaskDirectoryListServiceError.unreadable + } + return directory + } + + private func directoryItem(at url: URL) -> MobileTaskDirectoryListItem? { + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory), + isDirectory.boolValue else { + return nil + } + + let resourceValues = try? url.resourceValues(forKeys: [.isHiddenKey, .isPackageKey]) + let attributes = try? fileManager.attributesOfItem(atPath: url.path) + let name = url.lastPathComponent + return MobileTaskDirectoryListItem( + name: name, + path: url.standardizedFileURL.path, + isHidden: (resourceValues?.isHidden ?? false) || name.hasPrefix("."), + isPackage: resourceValues?.isPackage ?? false, + isSymbolicLink: attributes?[.type] as? FileAttributeType == .typeSymbolicLink, + isReadable: fileManager.isReadableFile(atPath: url.path) + ) + } + + private func mappedListingError(_ error: any Error) -> MobileTaskDirectoryListServiceError { + let nsError = error as NSError + if nsError.domain == NSCocoaErrorDomain { + switch nsError.code { + case NSFileNoSuchFileError: + return .notFound + case NSFileReadNoPermissionError: + return .permissionDenied + default: + break + } + } + if nsError.domain == NSPOSIXErrorDomain { + switch nsError.code { + case Int(EACCES), Int(EPERM): + return .permissionDenied + case Int(ENOENT): + return .notFound + case Int(ENOTDIR): + return .notDirectory + default: + break + } + } + return .unreadable + } + + private static func precedes( + _ lhs: MobileTaskDirectoryListItem, + _ rhs: MobileTaskDirectoryListItem + ) -> Bool { + MobileTaskDirectoryListItem.precedes(lhs, rhs) + } + + private static func isValidRequestPath(_ path: String) -> Bool { + guard !path.isEmpty, path.utf8.count <= 4_096, + !path.unicodeScalars.contains(where: { $0.value == 0 }) else { + return false + } + return path.hasPrefix("/") || path == "~" || path.hasPrefix("~/") + } + + private static func parentPath(of path: String) -> String? { + guard path != "/" else { return nil } + let components = (path as NSString).pathComponents + guard components.count > 1 else { return "/" } + return NSString.path(withComponents: Array(components.dropLast())) + } +} diff --git a/Sources/Mobile/MobileTaskDirectoryListServiceError.swift b/Sources/Mobile/MobileTaskDirectoryListServiceError.swift new file mode 100644 index 000000000000..62accf4c7269 --- /dev/null +++ b/Sources/Mobile/MobileTaskDirectoryListServiceError.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Filesystem failures that the directory-list RPC exposes without collapsing +/// user-actionable conditions into a generic internal error. +enum MobileTaskDirectoryListServiceError: Error, Equatable { + case invalidRequest + case invalidPath + case notFound + case notDirectory + case permissionDenied + case unreadable +} diff --git a/Sources/Mobile/MobileTaskDirectoryMetadataQueryRunner.swift b/Sources/Mobile/MobileTaskDirectoryMetadataQueryRunner.swift new file mode 100644 index 000000000000..dc7a7845ee28 --- /dev/null +++ b/Sources/Mobile/MobileTaskDirectoryMetadataQueryRunner.swift @@ -0,0 +1,182 @@ +import Foundation + +/// Runs one bounded Spotlight directory query while keeping the non-Sendable +/// `NSMetadataQuery` lifecycle confined to the main actor. +@MainActor +final class MobileTaskDirectoryMetadataQueryRunner { + nonisolated enum QueryError: Error, Equatable { + case unavailable + } + + nonisolated struct Snapshot: Equatable, Sendable { + let paths: [String] + let gatheringComplete: Bool + let totalMatchCount: Int + let truncated: Bool + } + + typealias DeadlineSleep = @MainActor @Sendable (Duration) async -> Void + + private let notificationCenter: NotificationCenter + private let deadlineSleep: DeadlineSleep + private var activeQuery: NSMetadataQuery? + private var finishObserver: NSObjectProtocol? + private var deadlineTask: Task? + private var gatheringContinuation: CheckedContinuation? + private var queryDidStart = false + + init( + notificationCenter: NotificationCenter = .default, + deadlineSleep: DeadlineSleep? = nil + ) { + self.notificationCenter = notificationCenter + self.deadlineSleep = deadlineSleep ?? { duration in + try? await ContinuousClock().sleep(for: duration) + } + } + + func search( + query rawQuery: String, + maximumResults: Int, + timeout: Duration + ) async throws -> Snapshot { + precondition(activeQuery == nil) + precondition(maximumResults > 0) + + let query = NSMetadataQuery() + query.searchScopes = [ + NSMetadataQueryLocalComputerScope, + NSMetadataQueryNetworkScope, + ] + query.predicate = Self.makePredicate(query: rawQuery) + query.sortDescriptors = [NSSortDescriptor(key: NSMetadataItemPathKey, ascending: true)] + activeQuery = query + + let gatheringComplete = await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + gatheringContinuation = continuation + finishObserver = notificationCenter.addObserver( + forName: .NSMetadataQueryDidFinishGathering, + object: query, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.finishGathering(complete: true) + } + } + + queryDidStart = query.start() + guard queryDidStart else { + finishGathering(complete: false) + return + } + deadlineTask = Task { @MainActor [weak self, deadlineSleep] in + await deadlineSleep(timeout) + guard !Task.isCancelled else { return } + self?.finishGathering(complete: false) + } + if Task.isCancelled { + cancelActiveQuery() + } + } + } onCancel: { + Task { @MainActor [weak self] in + self?.cancelActiveQuery() + } + } + + guard queryDidStart else { + cleanup() + throw QueryError.unavailable + } + guard !Task.isCancelled else { + cleanup() + throw CancellationError() + } + + query.disableUpdates() + let totalMatchCount = query.resultCount + let resultLimit = min(totalMatchCount, maximumResults) + var paths: [String] = [] + paths.reserveCapacity(resultLimit) + var seen = Set() + for index in 0.. resultLimit + ) + cleanup() + return snapshot + } + + private func finishGathering(complete: Bool) { + guard let continuation = gatheringContinuation else { return } + gatheringContinuation = nil + deadlineTask?.cancel() + deadlineTask = nil + continuation.resume(returning: complete) + } + + private func cancelActiveQuery() { + finishGathering(complete: false) + activeQuery?.stop() + } + + private func cleanup() { + deadlineTask?.cancel() + deadlineTask = nil + if let finishObserver { + notificationCenter.removeObserver(finishObserver) + } + finishObserver = nil + activeQuery?.stop() + activeQuery = nil + gatheringContinuation = nil + queryDidStart = false + } + + static func makePredicate(query: String) -> NSPredicate { + let tokens = query + .split { $0 == "/" || $0.isWhitespace } + .prefix(8) + .map(String.init) + let directoryPredicate = NSPredicate( + format: "ANY %K == %@", + NSMetadataItemContentTypeTreeKey, + "public.directory" + ) + let tokenPredicates = tokens.map { token in + NSCompoundPredicate(orPredicateWithSubpredicates: [ + NSPredicate( + format: "%K CONTAINS[cd] %@", + NSMetadataItemFSNameKey, + token + ), + NSPredicate( + format: "%K CONTAINS[cd] %@", + NSMetadataItemPathKey, + token + ), + ]) + } + return NSCompoundPredicate( + andPredicateWithSubpredicates: [directoryPredicate] + tokenPredicates + ) + } +} diff --git a/Sources/Mobile/MobileTaskDirectorySearchResult.swift b/Sources/Mobile/MobileTaskDirectorySearchResult.swift new file mode 100644 index 000000000000..134f9ef25675 --- /dev/null +++ b/Sources/Mobile/MobileTaskDirectorySearchResult.swift @@ -0,0 +1,19 @@ +import Foundation + +/// One bounded directory-search response together with an honest description +/// of how much of the Mac filesystem the search could cover. +struct MobileTaskDirectorySearchResult: Equatable, Sendable { + enum Scope: String, Equatable, Sendable { + /// Spotlight metadata from indexed local and user-mounted network volumes. + case allIndexedVolumes = "all_indexed_volumes" + /// Only contextual paths already known to cmux were available. + case contextualCandidatesOnly = "contextual_candidates_only" + } + + let directories: [String] + let scope: Scope + let gatheringComplete: Bool + let filesystemComplete: Bool + let truncated: Bool + let indexedMatchCount: Int +} diff --git a/Sources/Mobile/MobileTaskDirectorySearchService+FuzzyMatching.swift b/Sources/Mobile/MobileTaskDirectorySearchService+FuzzyMatching.swift new file mode 100644 index 000000000000..2209f9a140d1 --- /dev/null +++ b/Sources/Mobile/MobileTaskDirectorySearchService+FuzzyMatching.swift @@ -0,0 +1,36 @@ +import Foundation + +extension MobileTaskDirectorySearchService { + nonisolated static func hasFuzzyComponent(_ query: String, in components: [String]) -> Bool { + guard query.count >= 3 else { return false } + let maximum = query.count >= 7 ? 2 : 1 + return components.contains { component in + abs(component.count - query.count) <= maximum + && editDistance(component, query, maximum: maximum) <= maximum + } + } + + private nonisolated static func editDistance(_ lhs: String, _ rhs: String, maximum: Int) -> Int { + let left = Array(lhs) + let right = Array(rhs) + guard abs(left.count - right.count) <= maximum else { return maximum + 1 } + var previous = Array(0...right.count) + for (leftIndex, leftCharacter) in left.enumerated() { + var current = [leftIndex + 1] + current.reserveCapacity(right.count + 1) + var rowMinimum = current[0] + for (rightIndex, rightCharacter) in right.enumerated() { + let value = min( + current[rightIndex] + 1, + previous[rightIndex + 1] + 1, + previous[rightIndex] + (leftCharacter == rightCharacter ? 0 : 1) + ) + current.append(value) + rowMinimum = min(rowMinimum, value) + } + if rowMinimum > maximum { return maximum + 1 } + previous = current + } + return previous.last ?? maximum + 1 + } +} diff --git a/Sources/Mobile/MobileTaskDirectorySearchService.swift b/Sources/Mobile/MobileTaskDirectorySearchService.swift new file mode 100644 index 000000000000..b3eaf62cf0f7 --- /dev/null +++ b/Sources/Mobile/MobileTaskDirectorySearchService.swift @@ -0,0 +1,324 @@ +import Foundation + +/// Searches Spotlight metadata across all indexed mounted volumes, merges +/// contextual cmux paths, and reports the remaining coverage limits explicitly. +actor MobileTaskDirectorySearchService { + struct Configuration: Sendable { + var maximumMetadataResults = 2_048 + var maximumWireResults = 64 + var queryTimeout: Duration = .seconds(2) + } + + struct SearchablePath: Sendable { + let path: String + let pathBytes: [UInt8] + let foldedPath: String + let components: [String] + let basename: String + } + + private struct RankedPath { + let candidate: SearchablePath + let tier: Int + let unmatchedComponents: Int + } + + typealias MetadataSearchOperation = @MainActor @Sendable ( + _ query: String, + _ maximumResults: Int, + _ timeout: Duration + ) async throws -> MobileTaskDirectoryMetadataQueryRunner.Snapshot + typealias RankOperation = @Sendable ( + _ paths: [SearchablePath], + _ query: String, + _ limit: Int + ) async -> [String] + typealias DirectoryExists = @Sendable (_ path: String) -> Bool + + private let homeDirectory: URL + private let configuration: Configuration + private let metadataSearchOperation: MetadataSearchOperation + private let rankOperation: RankOperation + private let directoryExists: DirectoryExists + + init( + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, + configuration: Configuration = Configuration(), + metadataSearchOperation: MetadataSearchOperation? = nil, + rankOperation: RankOperation? = nil, + directoryExists: DirectoryExists? = nil + ) { + precondition(configuration.maximumMetadataResults > 0) + precondition(configuration.maximumWireResults > 0) + self.homeDirectory = homeDirectory.standardizedFileURL + self.configuration = configuration + self.metadataSearchOperation = metadataSearchOperation ?? { query, maximumResults, timeout in + try await MobileTaskDirectoryMetadataQueryRunner().search( + query: query, + maximumResults: maximumResults, + timeout: timeout + ) + } + self.rankOperation = rankOperation ?? { paths, query, limit in + await Task.detached(priority: .userInitiated) { + Self.rank(searchablePaths: paths, query: query, limit: limit) + }.value + } + self.directoryExists = directoryExists ?? { path in + var isDirectory: ObjCBool = false + return FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) + && isDirectory.boolValue + } + } + + func search( + query rawQuery: String, + seedPaths: [String], + limit: Int = 64 + ) async throws -> MobileTaskDirectorySearchResult { + let query = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty, limit > 0 else { + return MobileTaskDirectorySearchResult( + directories: [], + scope: .contextualCandidatesOnly, + gatheringComplete: false, + filesystemComplete: false, + truncated: false, + indexedMatchCount: 0 + ) + } + let expandedQuery = Self.expandHome(query, homeDirectory: homeDirectory.path) + let maximumResults = min(limit, configuration.maximumWireResults, 64) + let contextualPaths = Self.seedCandidates( + seedPaths: seedPaths, + homeDirectory: homeDirectory, + directoryExists: directoryExists + ) + + guard !Task.isCancelled else { + throw CancellationError() + } + + let metadataSearchOperation = metadataSearchOperation + let maximumMetadataResults = configuration.maximumMetadataResults + let queryTimeout = configuration.queryTimeout + let metadataTask = Task { @MainActor in + try await metadataSearchOperation(expandedQuery, maximumMetadataResults, queryTimeout) + } + + let metadataSnapshot: MobileTaskDirectoryMetadataQueryRunner.Snapshot + do { + metadataSnapshot = try await withTaskCancellationHandler { + try await metadataTask.value + } onCancel: { + metadataTask.cancel() + } + } catch is CancellationError { + throw CancellationError() + } catch MobileTaskDirectoryMetadataQueryRunner.QueryError.unavailable { + return try await contextualResult( + paths: contextualPaths, + query: expandedQuery, + maximumResults: maximumResults + ) + } catch { + return try await contextualResult( + paths: contextualPaths, + query: expandedQuery, + maximumResults: maximumResults + ) + } + guard !Task.isCancelled else { + throw CancellationError() + } + + let metadataPaths = Self.prepare(paths: metadataSnapshot.paths) + let merged = Self.unique(contextualPaths + metadataPaths) + let ranked = await rankOperation(merged, expandedQuery, merged.count) + guard !Task.isCancelled else { + throw CancellationError() + } + return MobileTaskDirectorySearchResult( + directories: Array(ranked.prefix(maximumResults)), + scope: .allIndexedVolumes, + gatheringComplete: metadataSnapshot.gatheringComplete, + filesystemComplete: false, + truncated: metadataSnapshot.truncated || ranked.count > maximumResults, + indexedMatchCount: metadataSnapshot.totalMatchCount + ) + } + + private func contextualResult( + paths: [SearchablePath], + query: String, + maximumResults: Int + ) async throws -> MobileTaskDirectorySearchResult { + let ranked = await rankOperation(paths, query, paths.count) + guard !Task.isCancelled else { + throw CancellationError() + } + return MobileTaskDirectorySearchResult( + directories: Array(ranked.prefix(maximumResults)), + scope: .contextualCandidatesOnly, + gatheringComplete: false, + filesystemComplete: false, + truncated: ranked.count > maximumResults, + indexedMatchCount: 0 + ) + } + + nonisolated static func rank(paths: [String], query: String, limit: Int) -> [String] { + rank(searchablePaths: prepare(paths: paths), query: query, limit: limit) + } + + private nonisolated static func rank( + searchablePaths: [SearchablePath], + query: String, + limit: Int + ) -> [String] { + guard limit > 0 else { return [] } + let foldedQuery = fold(query) + let queryComponents = components(foldedQuery) + let queryBasename = queryComponents.last ?? foldedQuery + var top: [RankedPath] = [] + top.reserveCapacity(min(limit, searchablePaths.count)) + + for candidate in searchablePaths { + guard !Task.isCancelled else { return [] } + guard let match = match( + candidate: candidate, + rawQuery: query, + foldedQuery: foldedQuery, + queryBasename: queryBasename, + queryComponents: queryComponents + ) else { continue } + let ranked = RankedPath( + candidate: candidate, + tier: match.tier, + unmatchedComponents: match.unmatchedComponents + ) + let insertionIndex = top.firstIndex { isBetter(ranked, than: $0) } ?? top.endIndex + top.insert(ranked, at: insertionIndex) + if top.count > limit { + top.removeLast() + } + } + return top.map(\.candidate.path) + } + + private nonisolated static func prepare(paths: [String]) -> [SearchablePath] { + var prepared: [SearchablePath] = [] + prepared.reserveCapacity(paths.count) + for path in paths { + guard !Task.isCancelled else { return [] } + let foldedPath = fold(path) + let pathComponents = components(foldedPath) + prepared.append(SearchablePath( + path: path, + pathBytes: Array(path.utf8), + foldedPath: foldedPath, + components: pathComponents, + basename: pathComponents.last ?? foldedPath + )) + } + return prepared + } + + private nonisolated static func unique(_ paths: [SearchablePath]) -> [SearchablePath] { + var seen = Set() + return paths.filter { seen.insert(Data($0.pathBytes)).inserted } + } + + private nonisolated static func seedCandidates( + seedPaths: [String], + homeDirectory: URL, + directoryExists: DirectoryExists + ) -> [SearchablePath] { + var paths: [String] = [] + var seen = Set() + paths.reserveCapacity(seedPaths.count) + + for seedPath in seedPaths { + guard !Task.isCancelled else { return [] } + let trimmed = seedPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let expanded = expandHome(trimmed, homeDirectory: homeDirectory.path) + let path = URL(fileURLWithPath: expanded, isDirectory: true).standardizedFileURL.path + guard directoryExists(path), seen.insert(Data(path.utf8)).inserted else { continue } + paths.append(path) + } + return prepare(paths: paths) + } + + private nonisolated static func match( + candidate: SearchablePath, + rawQuery: String, + foldedQuery: String, + queryBasename: String, + queryComponents: [String] + ) -> (tier: Int, unmatchedComponents: Int)? { + let unmatched = max(0, candidate.components.count - queryComponents.count) + if candidate.pathBytes.elementsEqual(rawQuery.utf8) { return (6, 0) } + if candidate.path.hasPrefix(rawQuery) { return (5, unmatched) } + if candidate.foldedPath.hasPrefix(foldedQuery) + || (queryComponents.count == 1 && candidate.basename.hasPrefix(queryBasename)) { + return (4, unmatched) + } + if matchesOrderedComponentPrefixes(queryComponents, in: candidate.components) { + return (3, unmatched) + } + if candidate.foldedPath.contains(foldedQuery) + || (queryComponents.count == 1 && candidate.basename.contains(queryBasename)) { + return (2, unmatched) + } + if queryComponents.count == 1, hasFuzzyComponent(queryBasename, in: candidate.components) { + return (1, unmatched) + } + return nil + } + + private nonisolated static func isBetter(_ lhs: RankedPath, than rhs: RankedPath) -> Bool { + if lhs.tier != rhs.tier { return lhs.tier > rhs.tier } + if lhs.unmatchedComponents != rhs.unmatchedComponents { + return lhs.unmatchedComponents < rhs.unmatchedComponents + } + let lhsBytes = lhs.candidate.pathBytes + let rhsBytes = rhs.candidate.pathBytes + if lhsBytes.count != rhsBytes.count { return lhsBytes.count < rhsBytes.count } + return lhsBytes.lexicographicallyPrecedes(rhsBytes) + } + + private nonisolated static func fold(_ value: String) -> String { + value.folding( + options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], + locale: Locale(identifier: "en_US_POSIX") + ) + } + + private nonisolated static func components(_ value: String) -> [String] { + value.split { $0 == "/" || $0.isWhitespace }.map(String.init) + } + + private nonisolated static func matchesOrderedComponentPrefixes( + _ query: [String], + in candidate: [String] + ) -> Bool { + guard !query.isEmpty else { return false } + var candidateIndex = candidate.startIndex + for queryComponent in query { + guard let match = candidate[candidateIndex...].firstIndex(where: { + $0.hasPrefix(queryComponent) + }) else { + return false + } + candidateIndex = candidate.index(after: match) + } + return true + } + + private nonisolated static func expandHome(_ path: String, homeDirectory: String) -> String { + if path == "~" { return homeDirectory } + if path.hasPrefix("~/") { return homeDirectory + path.dropFirst() } + return path + } +} diff --git a/Sources/Mobile/MobileTaskFilesystemJobQuota.swift b/Sources/Mobile/MobileTaskFilesystemJobQuota.swift new file mode 100644 index 000000000000..1891d5cb04eb --- /dev/null +++ b/Sources/Mobile/MobileTaskFilesystemJobQuota.swift @@ -0,0 +1,32 @@ +import os + +/// Admits a bounded number of mobile task filesystem jobs without queueing +/// additional work when the host is already saturated. +nonisolated final class MobileTaskFilesystemJobQuota: Sendable { + private let maximumConcurrentJobs: Int + // lint:allow lock - async handlers need synchronous fail-fast admission + // and synchronous `defer` release so cancellation cannot leak capacity. + private let inFlightJobs = OSAllocatedUnfairLock(initialState: 0) + + init(maximumConcurrentJobs: Int = 2) { + precondition(maximumConcurrentJobs > 0) + self.maximumConcurrentJobs = maximumConcurrentJobs + } + + /// Reserves one filesystem job slot without waiting for capacity. + func acquire() -> Bool { + inFlightJobs.withLock { inFlightJobs in + guard inFlightJobs < maximumConcurrentJobs else { return false } + inFlightJobs += 1 + return true + } + } + + /// Releases one slot returned by a successful ``acquire()``. + func release() { + inFlightJobs.withLock { inFlightJobs in + precondition(inFlightJobs > 0, "release without a matching acquire") + inFlightJobs -= 1 + } + } +} diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index f719b55e2242..d64e3c1b0469 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -1742,6 +1742,7 @@ struct SessionWorkspaceSnapshot: Codable, Sendable { /// legacy or externally-created snapshots can leave it nil. var workspaceId: UUID? = nil var stableId: UUID? = nil + var taskCreateOperationID: UUID? = nil var processTitle: String var customTitle: String? /// Provenance of `customTitle`; absent provenance restores as user-set for compatibility. @@ -1769,8 +1770,7 @@ struct SessionWorkspaceSnapshot: Codable, Sendable { var progress: SessionProgressSnapshot? var gitBranch: SessionGitBranchSnapshot? var remote: SessionRemoteWorkspaceSnapshot? - /// User-defined per-workspace environment variables (issue #5995). Optional - /// with a `nil` default so manifests written before this field decode cleanly. + /// Optional so manifests written before this field decode cleanly. var environment: [String: String]? = nil /// Manual task-status override raw values and the persisted checklist. Optional-with-nil-default /// (the `groupId` back-compat pattern); bridging to/from live `WorkspaceTodoState` lives in `SessionPersistence+Todos.swift`. diff --git a/Sources/TabManager+SessionRestorePolicy.swift b/Sources/TabManager+SessionRestorePolicy.swift new file mode 100644 index 000000000000..1e597d0827ae --- /dev/null +++ b/Sources/TabManager+SessionRestorePolicy.swift @@ -0,0 +1,22 @@ +import Foundation + +extension TabManager { + static func isCloudVMSessionRestoreWorkspace(_ snapshot: SessionWorkspaceSnapshot) -> Bool { + isManagedCloudVMSessionRestoreWorkspace(snapshot) + } + + static func isManagedCloudVMSessionRestoreWorkspace(_ snapshot: SessionWorkspaceSnapshot) -> Bool { + guard let managedCloudVMID = snapshot.remote?.managedCloudVMID else { return false } + return !managedCloudVMID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + /// Reconciles task-create provenance only when the caller owns a cache. + /// A nil cache intentionally leaves unrelated snapshot restores isolated. + static func recordRestoredTaskCreateProvenance( + for workspace: Workspace, + in cache: TerminalController.WorkspaceCreateIdempotencyCache? + ) { + guard let operationID = workspace.taskCreateOperationID, let cache else { return } + cache.record(operationID: operationID, workspaceID: workspace.id) + } +} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 28626883b5df..044a08de498a 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -5947,19 +5947,12 @@ extension TabManager { return (filtered, remappedSelection) } - private static func isCloudVMSessionRestoreWorkspace(_ snapshot: SessionWorkspaceSnapshot) -> Bool { - isManagedCloudVMSessionRestoreWorkspace(snapshot) - } - - private static func isManagedCloudVMSessionRestoreWorkspace(_ snapshot: SessionWorkspaceSnapshot) -> Bool { - guard let managedCloudVMID = snapshot.remote?.managedCloudVMID else { return false } - return !managedCloudVMID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - @discardableResult func restoreSessionSnapshot( _ snapshot: SessionTabManagerSnapshot, - remapClosedPanelHistory: Bool = true, excludingStableIdentities: Set = [] + remapClosedPanelHistory: Bool = true, + excludingStableIdentities: Set = [], + workspaceCreateIdempotencyCache: TerminalController.WorkspaceCreateIdempotencyCache? = nil ) -> [[UUID: UUID]] { isRestoringSessionSnapshot = true defer { isRestoringSessionSnapshot = false } @@ -6008,6 +6001,7 @@ extension TabManager { ) workspace.owningTabManager = self let restoredPanelIds = workspace.restoreSessionSnapshot(workspaceSnapshot, excludingStableIdentities: excludingStableIdentities) + Self.recordRestoredTaskCreateProvenance(for: workspace, in: workspaceCreateIdempotencyCache) wireClosedBrowserTracking(for: workspace) newTabs.append(workspace) restoredPanelIdsByWorkspaceIndex.append(restoredPanelIds) diff --git a/Sources/TerminalController+MobileDirectoryList.swift b/Sources/TerminalController+MobileDirectoryList.swift new file mode 100644 index 000000000000..af48a1f1fd20 --- /dev/null +++ b/Sources/TerminalController+MobileDirectoryList.swift @@ -0,0 +1,76 @@ +import Foundation + +extension TerminalController { + /// Lists a stable page of direct child directories for the iOS task + /// composer while preserving typed filesystem failures on the RPC wire. + func v2MobileDirectoryList( + params: [String: Any], + filesystemJobQuota: MobileTaskFilesystemJobQuota + ) async -> V2CallResult { + guard let path = params["path"] as? String, + let offset = params["offset"] as? Int, + let limit = params["limit"] as? Int, + offset >= 0, + (1...MobileTaskDirectoryListService.maximumPageSize).contains(limit) else { + return .err( + code: "invalid_params", + message: "Directory path, nonnegative offset, and page limit from 1 to 100 are required", + data: nil + ) + } + guard filesystemJobQuota.acquire() else { + return .err( + code: "busy", + message: "Too many filesystem requests are already in progress", + data: nil + ) + } + defer { filesystemJobQuota.release() } + + do { + let page = try await MobileTaskDirectoryListService().list( + path: path, + offset: offset, + limit: limit + ) + let entries: [[String: Any]] = page.entries.map { entry in + [ + "name": entry.name, + "path": entry.path, + "is_hidden": entry.isHidden, + "is_package": entry.isPackage, + "is_symbolic_link": entry.isSymbolicLink, + "is_readable": entry.isReadable, + ] + } + return .ok([ + "current_path": page.currentPath, + "parent_path": page.parentPath ?? NSNull(), + "entries": entries, + "offset": page.offset, + "limit": page.limit, + "total_count": page.totalCount, + "next_offset": page.nextOffset ?? NSNull(), + ]) + } catch let error as MobileTaskDirectoryListServiceError { + switch error { + case .invalidRequest: + return .err(code: "invalid_params", message: "Directory pagination values are invalid", data: nil) + case .invalidPath: + return .err(code: "invalid_params", message: "Directory path must be absolute or start with ~", data: nil) + case .notFound: + return .err(code: "directory_not_found", message: "Directory does not exist", data: nil) + case .notDirectory: + return .err(code: "not_a_directory", message: "Path is not a directory", data: nil) + case .permissionDenied: + return .err(code: "permission_denied", message: "Permission to read the directory was denied", data: nil) + case .unreadable: + return .err(code: "directory_unreadable", message: "Directory cannot be read", data: nil) + } + } catch is CancellationError { + return .err(code: "cancelled", message: "Directory listing was cancelled", data: nil) + } catch { + return .err(code: "internal_error", message: "Directory listing failed", data: nil) + } + } +} diff --git a/Sources/TerminalController+MobileDirectorySearch.swift b/Sources/TerminalController+MobileDirectorySearch.swift new file mode 100644 index 000000000000..b4703f389931 --- /dev/null +++ b/Sources/TerminalController+MobileDirectorySearch.swift @@ -0,0 +1,77 @@ +import Foundation + +extension TerminalController { + /// Searches indexed directories across mounted volumes for the iOS task + /// composer and reports the filesystem coverage limits on the wire. + func v2MobileDirectorySearch( + params: [String: Any], + filesystemJobQuota: MobileTaskFilesystemJobQuota + ) async -> V2CallResult { + guard let rawQuery = params["query"] as? String else { + return .err(code: "invalid_params", message: "Missing query", data: nil) + } + let query = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty, query.unicodeScalars.count <= 256 else { + return .err(code: "invalid_params", message: "Query must contain 1 to 256 characters", data: nil) + } + guard filesystemJobQuota.acquire() else { + return .err( + code: "busy", + message: "Too many filesystem requests are already in progress", + data: nil + ) + } + defer { filesystemJobQuota.release() } + + let seedPaths = mobileDirectorySearchSeedPaths() + do { + // Construct per request until the controller composition root can + // inject the stateless service without colliding with its refactor. + let result = try await MobileTaskDirectorySearchService().search( + query: query, + seedPaths: seedPaths + ) + return .ok([ + "directories": result.directories, + "search_scope": result.scope.rawValue, + "gathering_complete": result.gatheringComplete, + "filesystem_complete": result.filesystemComplete, + "truncated": result.truncated, + "indexed_match_count": result.indexedMatchCount, + ]) + } catch is CancellationError { + return .err(code: "cancelled", message: "Directory search was cancelled", data: nil) + } catch { + return .err(code: "internal_error", message: "Directory search failed", data: nil) + } + } + + private func mobileDirectorySearchSeedPaths() -> [String] { + guard let app = AppDelegate.shared else { return [] } + var paths: [String] = [] + var seenWindows = Set() + for summary in app.listMainWindowSummaries() where seenWindows.insert(summary.windowId).inserted { + guard let tabManager = app.tabManagerFor(windowId: summary.windowId) else { continue } + for workspace in tabManager.tabs { + if let path = mobileDirectorySearchNonEmpty(workspace.presentedCurrentDirectory) { + paths.append(path) + } + for terminal in mobileTerminalPanels(in: workspace) { + if let path = workspace.effectivePanelDirectory( + panelId: terminal.id, + localFallback: mobileDirectorySearchNonEmpty(terminal.directory) + ?? mobileDirectorySearchNonEmpty(terminal.requestedWorkingDirectory) + ) { + paths.append(path) + } + } + } + } + return paths + } + + private func mobileDirectorySearchNonEmpty(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + } +} diff --git a/Sources/TerminalController+WorkspaceCreate.swift b/Sources/TerminalController+WorkspaceCreate.swift index 657e96581100..c954ab1da280 100644 --- a/Sources/TerminalController+WorkspaceCreate.swift +++ b/Sources/TerminalController+WorkspaceCreate.swift @@ -1,173 +1,162 @@ -import CmuxSettings import Foundation extension TerminalController { + struct TaskCreateWorkspaceCandidate { + let tabManager: TabManager + let windowID: UUID? + } + + struct TaskCreateWorkspaceResolution { + let workspace: Workspace + let candidate: TaskCreateWorkspaceCandidate + } + + nonisolated static func v2ExpandedWorkingDirectory(_ raw: String?) -> String? { + guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + guard trimmed.hasPrefix("~") else { return trimmed } + return (trimmed as NSString).expandingTildeInPath + } + // Shared workspace-create implementation: the workspace.create command moved // to ControlCommandCoordinator, but v2MobileWorkspaceCreate still drives // this body for the mobile data-plane create path. func v2WorkspaceCreate( params: [String: Any], - tabManager resolvedTabManager: TabManager? = nil + tabManager resolvedTabManager: TabManager? = nil, + taskCreateCandidates: [TaskCreateWorkspaceCandidate]? = nil, + idempotencyCache: WorkspaceCreateIdempotencyCache? = nil ) -> V2CallResult { - guard let tabManager = resolvedTabManager ?? v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - - let requestedWorkingDirectory = v2RawString(params, "working_directory")?.trimmingCharacters(in: .whitespacesAndNewlines) - let workingDirectory = (requestedWorkingDirectory?.isEmpty == false) ? requestedWorkingDirectory : nil - - let requestedInitialCommand = v2RawString(params, "initial_command")?.trimmingCharacters(in: .whitespacesAndNewlines) - let initialCommand = (requestedInitialCommand?.isEmpty == false) ? requestedInitialCommand : nil - - let rawInitialEnv = v2StringMap(params, "initial_env") ?? [:] - let initialEnv = rawInitialEnv.reduce(into: [String: String]()) { result, pair in - let key = pair.key.trimmingCharacters(in: .whitespacesAndNewlines) - guard !key.isEmpty else { return } - result[key] = pair.value - } - // Persistent per-workspace environment (issue #5995): applied to the - // initial shell and every later pane/surface/split, then round-tripped - // through session restore. - let workspaceEnv = Workspace.sanitizedWorkspaceEnvironment( - v2StringMap(params, "workspace_env") ?? [:] + let outcome = v2PrepareWorkspaceCreate( + params: params, + tabManager: resolvedTabManager, + taskCreateCandidates: taskCreateCandidates, + idempotencyCache: idempotencyCache ) - let cwd: String? - if let workingDirectory { - cwd = workingDirectory - } else if let raw = params["cwd"] { - guard let str = raw as? String else { - return .err(code: "invalid_params", message: "cwd must be a string", data: nil) - } - cwd = str - } else { - cwd = nil - } - - let requestedTitle = v2RawString(params, "title")?.trimmingCharacters(in: .whitespacesAndNewlines) - let title = (requestedTitle?.isEmpty == false) ? requestedTitle : nil - let description = v2RawString(params, "description") - - let groupId = v2UUID(params, "group_id") - if v2HasNonNullParam(params, "group_id"), groupId == nil { - return .err(code: "invalid_params", message: "Missing or invalid group_id", data: nil) - } - let hasGroupPlacementParam = v2HasNonNullParam(params, "group_placement") - || v2HasNonNullParam(params, "placement") - let hasGroupReferenceParam = v2HasNonNullParam(params, "group_reference_workspace_id") - || v2HasNonNullParam(params, "reference_workspace_id") - if groupId == nil, hasGroupPlacementParam || hasGroupReferenceParam { + let preparation: WorkspaceCreatePreparation + switch outcome { + case let .failure(result): + return result + case let .existing(resolution): + return workspaceCreateResult( + workspace: resolution.workspace, + windowID: resolution.candidate.windowID + ) + case let .completed(_, operationID): return .err( - code: "invalid_params", - message: "group_id is required for group placement", - data: nil + code: "already_completed", + message: "workspace.create operation already completed", + data: ["operation_id": operationID.uuidString] ) + case let .ready(ready): + preparation = ready } - let rawGroupPlacement = v2RawString(params, "group_placement") - ?? (groupId == nil ? nil : v2RawString(params, "placement")) - let groupPlacement = WorkspaceGroupNewPlacement(rawString: rawGroupPlacement) - if let raw = rawGroupPlacement, - !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - groupPlacement == nil { - return .err(code: "invalid_params", message: "Invalid group_placement", data: ["group_placement": raw]) - } - let groupReferenceWorkspaceId: UUID? - if v2HasNonNullParam(params, "group_reference_workspace_id") { - guard let parsed = v2UUID(params, "group_reference_workspace_id") else { - return .err(code: "invalid_params", message: "Missing or invalid group_reference_workspace_id", data: nil) - } - groupReferenceWorkspaceId = parsed - } else if v2HasNonNullParam(params, "reference_workspace_id") { - guard let parsed = v2UUID(params, "reference_workspace_id") else { - return .err(code: "invalid_params", message: "Missing or invalid group_reference_workspace_id", data: nil) - } - groupReferenceWorkspaceId = parsed - } else { - groupReferenceWorkspaceId = nil + let workingDirectory = Self.v2ExpandedWorkingDirectory( + v2RawString(params, "working_directory") + ) + let execution: WorkspaceCreateExecutionPreparation + switch v2PrepareWorkspaceCreateExecution( + params: params, + preparation: preparation, + workingDirectory: workingDirectory + ) { + case let .failure(result): + return result + case let .ready(ready): + execution = ready } + return v2PerformWorkspaceCreate( + preparation: preparation, + execution: execution + ) + } - // Decode optional layout param (same JSON schema as cmux.json layout field). - // Validate before creating the workspace so malformed layouts fail fast. - var layoutNode: CmuxLayoutNode? - if let rawLayout = params["layout"] { - guard JSONSerialization.isValidJSONObject(rawLayout), - let layoutData = try? JSONSerialization.data(withJSONObject: rawLayout) else { - return .err(code: "invalid_params", message: "layout must be a valid JSON object", data: nil) - } + private func v2PerformWorkspaceCreate( + preparation: WorkspaceCreatePreparation, + execution: WorkspaceCreateExecutionPreparation, + operationAlreadyAccepted: Bool = false + ) -> V2CallResult { + let tabManager = preparation.tabManager + let operationID = preparation.operationID + + var newWorkspace: Workspace? + if let operationID, !operationAlreadyAccepted { + // Acceptance must be durable before addWorkspace constructs a + // terminal and can execute the task command. A crash in between + // intentionally favors at-most-once startup over workspace recovery. do { - layoutNode = try JSONDecoder().decode(CmuxLayoutNode.self, from: layoutData) + try preparation.idempotencyCache.accept(operationID: operationID) } catch { - return .err(code: "invalid_params", message: "Invalid layout: \(error.localizedDescription)", data: nil) - } - } - - var newId: UUID? - var initialSurfaceId: UUID? - let shouldFocus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? false) - let shouldEagerLoadTerminal = v2Bool(params, "eager_load_terminal") ?? !shouldFocus - let shouldAutoRefreshMetadata = v2Bool(params, "auto_refresh_metadata") ?? true - if let groupId { - let validation = v2MainSync { - let groupExists = tabManager.workspaceGroups.contains(where: { $0.id == groupId }) - let referenceIsMember = groupReferenceWorkspaceId.map { referenceWorkspaceId in - tabManager.tabs.contains { $0.id == referenceWorkspaceId && $0.groupId == groupId } - } ?? true - return (groupExists: groupExists, referenceIsMember: referenceIsMember) - } - guard validation.groupExists else { - return .err( - code: "not_found", - message: "Group not found", - data: ["group_id": groupId.uuidString] + workspaceCreateIdempotencyLogger.error( + "Task reservation failed: \(String(describing: error), privacy: .private)" ) - } - guard validation.referenceIsMember else { return .err( - code: "invalid_params", - message: controlWorkspaceGroupStrings().invalidReferenceWorkspace, - data: ["group_reference_workspace_id": groupReferenceWorkspaceId?.uuidString ?? ""] + code: "persistence_failed", + message: "Workspace task could not be reserved safely", + data: nil ) } } v2MainSync { let ws = tabManager.addWorkspace( - title: title, - workingDirectory: cwd, - initialTerminalCommand: layoutNode == nil ? initialCommand : nil, - initialTerminalEnvironment: layoutNode == nil ? initialEnv : [:], - workspaceEnvironment: workspaceEnv, - select: shouldFocus, - eagerLoadTerminal: shouldEagerLoadTerminal, - autoRefreshMetadata: shouldAutoRefreshMetadata + title: execution.title, + workingDirectory: execution.workingDirectory, + initialTerminalCommand: execution.layoutNode == nil ? execution.initialCommand : nil, + initialTerminalEnvironment: execution.layoutNode == nil ? execution.initialEnvironment : [:], + workspaceEnvironment: execution.workspaceEnvironment, + select: execution.shouldFocus, + eagerLoadTerminal: execution.shouldEagerLoadTerminal, + autoRefreshMetadata: execution.shouldAutoRefreshMetadata ) - ws.setCustomDescription(description) - if let layoutNode { - ws.applyCustomLayout(layoutNode, baseCwd: cwd ?? ws.currentDirectory) + ws.taskCreateOperationID = operationID + ws.setCustomDescription(execution.description) + if let layoutNode = execution.layoutNode { + ws.applyCustomLayout( + layoutNode, + baseCwd: execution.workingDirectory ?? ws.currentDirectory + ) } - if let groupId { + if let groupID = execution.groupID { tabManager.addWorkspaceToGroup( workspaceId: ws.id, - groupId: groupId, - placement: groupPlacement ?? .top, - referenceWorkspaceId: groupReferenceWorkspaceId + groupId: groupID, + placement: execution.groupPlacement ?? .top, + referenceWorkspaceId: execution.groupReferenceWorkspaceID ) } - newId = ws.id - initialSurfaceId = ws.focusedPanelId + newWorkspace = ws } - guard let newId else { + guard let newWorkspace else { return .err(code: "internal_error", message: "Failed to create workspace", data: nil) } - let windowId = v2ResolveWindowId(tabManager: tabManager) + if let operationID { + preparation.idempotencyCache.associate(operationID: operationID, workspaceID: newWorkspace.id) + } + return workspaceCreateResult( + workspace: newWorkspace, + windowID: v2ResolveWindowId(tabManager: tabManager) + ) + } + + private func workspaceCreateResult( + workspace: Workspace, + windowID: UUID? + ) -> V2CallResult { + let workspaceID = workspace.id + let groupID = workspace.groupId + let surfaceID = workspace.focusedPanelId return .ok([ - "window_id": v2OrNull(windowId?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: windowId), - "workspace_id": newId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: newId), - "group_id": v2OrNull(groupId?.uuidString), - "group_ref": v2Ref(kind: .workspaceGroup, uuid: groupId), - "surface_id": v2OrNull(initialSurfaceId?.uuidString), - "surface_ref": v2Ref(kind: .surface, uuid: initialSurfaceId) + "window_id": v2OrNull(windowID?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowID), + "workspace_id": workspaceID.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceID), + "group_id": v2OrNull(groupID?.uuidString), + "group_ref": v2Ref(kind: .workspaceGroup, uuid: groupID), + "surface_id": v2OrNull(surfaceID?.uuidString), + "surface_ref": v2Ref(kind: .surface, uuid: surfaceID) ]) } @@ -244,15 +233,112 @@ extension TerminalController { ]) } - func v2MobileWorkspaceCreate(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "Workspace context is unavailable", data: nil) - } + func v2MobileWorkspaceCreate( + params: [String: Any], + workingDirectoryValidator: WorkspaceCreateWorkingDirectoryValidator? = nil, + tabManager resolvedTabManager: TabManager? = nil, + idempotencyCache: WorkspaceCreateIdempotencyCache? = nil + ) async -> V2CallResult { var createParams = params createParams["focus"] = false createParams["eager_load_terminal"] = false createParams["auto_refresh_metadata"] = false - let createResult = v2WorkspaceCreate(params: createParams, tabManager: tabManager) + let outcome = v2PrepareWorkspaceCreate( + params: createParams, + tabManager: resolvedTabManager, + taskCreateCandidates: nil, + idempotencyCache: idempotencyCache + ) + let preparation: WorkspaceCreatePreparation + switch outcome { + case let .failure(result): + return result + case let .existing(resolution): + return mobileWorkspaceCreateResult( + resolution: resolution, + params: createParams + ) + case let .completed(_, operationID): + return Self.v2MobileCompletedOperationResult(operationID: operationID) + case let .ready(ready): + preparation = ready + } + guard !Task.isCancelled else { + return .err(code: "cancelled", message: "Workspace creation was cancelled", data: nil) + } + let rawWorkingDirectory: String? + let isWorkingDirectoryProvided: Bool + if v2HasNonNullParam(createParams, "working_directory") { + rawWorkingDirectory = v2RawString(createParams, "working_directory") + isWorkingDirectoryProvided = true + } else if v2HasNonNullParam(createParams, "cwd") { + guard let cwd = v2RawString(createParams, "cwd") else { + return .err(code: "invalid_params", message: "cwd must be a string", data: nil) + } + rawWorkingDirectory = cwd + isWorkingDirectoryProvided = true + } else { + rawWorkingDirectory = nil + isWorkingDirectoryProvided = false + } + let validator = workingDirectoryValidator ?? Self.v2ValidateMobileWorkingDirectory + let validation = await validator( + rawWorkingDirectory, + isWorkingDirectoryProvided + ) + guard !Task.isCancelled, validation != .cancelled else { + return .err(code: "cancelled", message: "Workspace creation was cancelled", data: nil) + } + let workingDirectory: String? + switch validation { + case .notProvided: + workingDirectory = nil + case let .valid(path): + workingDirectory = path + case .invalid: + return Self.v2InvalidWorkingDirectoryResult + case .busy: + return .err( + code: "busy", + message: "working_directory validation is busy", + data: ["field": "working_directory"] + ) + case .timedOut: + return .err( + code: "request_timeout", + message: "working_directory validation timed out", + data: ["field": "working_directory"] + ) + case .cancelled: + return .err(code: "cancelled", message: "Workspace creation was cancelled", data: nil) + } + let execution: WorkspaceCreateExecutionPreparation + switch v2PrepareWorkspaceCreateExecution( + params: createParams, + preparation: preparation, + workingDirectory: workingDirectory + ) { + case let .failure(result): + return result + case let .ready(ready): + execution = ready + } + var operationAlreadyAccepted = false + switch await v2ReserveMobileWorkspaceCreate(preparation: preparation) { + case .notRequired: + break + case .accepted: + operationAlreadyAccepted = true + case let .live(resolution): + return mobileWorkspaceCreateResult(resolution: resolution, params: createParams) + case let .failure(result): + return result + } + let createResult = v2PerformWorkspaceCreate( + preparation: preparation, + execution: execution, + operationAlreadyAccepted: operationAlreadyAccepted + ) switch createResult { case let .ok(payload): let createdWorkspaceID = (payload as? [String: Any])?["workspace_id"] as? String @@ -263,11 +349,25 @@ extension TerminalController { // which watches TabManager.tabsPublisher directly. Don't fire here. return v2MobileWorkspaceList( params: createParams, - tabManager: tabManager, + tabManager: preparation.tabManager, createdWorkspaceID: createdWorkspaceID ) case .err: return createResult } } + + private func mobileWorkspaceCreateResult( + resolution: TaskCreateWorkspaceResolution, + params: [String: Any] + ) -> V2CallResult { + let workspaceID = resolution.workspace.id.uuidString + var listParams = params + listParams["workspace_id"] = workspaceID + return v2MobileWorkspaceList( + params: listParams, + tabManager: resolution.candidate.tabManager, + createdWorkspaceID: workspaceID + ) + } } diff --git a/Sources/TerminalController+WorkspaceCreateExecutionPreparation.swift b/Sources/TerminalController+WorkspaceCreateExecutionPreparation.swift new file mode 100644 index 000000000000..bf894577f060 --- /dev/null +++ b/Sources/TerminalController+WorkspaceCreateExecutionPreparation.swift @@ -0,0 +1,179 @@ +import CmuxSettings +import Foundation + +private func sanitizedInitialEnvironment(_ environment: [String: String]) -> [String: String] { + environment.reduce(into: [:]) { result, pair in + let key = pair.key.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty, + !key.contains("\0"), + !key.contains("="), + !pair.value.contains("\0") else { + return + } + result[key] = pair.value + } +} + +extension TerminalController { + struct WorkspaceCreateExecutionPreparation { + let title: String? + let description: String? + let initialCommand: String? + let initialEnvironment: [String: String] + let workspaceEnvironment: [String: String] + let workingDirectory: String? + let groupID: UUID? + let groupPlacement: WorkspaceGroupNewPlacement? + let groupReferenceWorkspaceID: UUID? + let layoutNode: CmuxLayoutNode? + let shouldFocus: Bool + let shouldEagerLoadTerminal: Bool + let shouldAutoRefreshMetadata: Bool + } + + enum WorkspaceCreateExecutionPreparationOutcome { + case failure(V2CallResult) + case ready(WorkspaceCreateExecutionPreparation) + } + + func v2PrepareWorkspaceCreateExecution( + params: [String: Any], + preparation: WorkspaceCreatePreparation, + workingDirectory: String? + ) -> WorkspaceCreateExecutionPreparationOutcome { + let requestedInitialCommand = v2RawString(params, "initial_command") + let initialCommand = requestedInitialCommand.flatMap { + $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : $0 + } + let initialEnvironment = sanitizedInitialEnvironment(v2StringMap(params, "initial_env") ?? [:]) + let workspaceEnvironment = Workspace.sanitizedWorkspaceEnvironment( + v2StringMap(params, "workspace_env") ?? [:] + ) + let cwd: String? + if let workingDirectory { + cwd = workingDirectory + } else if let raw = params["cwd"] { + guard let string = raw as? String else { + return .failure(.err(code: "invalid_params", message: "cwd must be a string", data: nil)) + } + cwd = Self.v2ExpandedWorkingDirectory(string) + } else { + cwd = nil + } + + let requestedTitle = v2RawString(params, "title")?.trimmingCharacters(in: .whitespacesAndNewlines) + let title = requestedTitle?.isEmpty == false ? requestedTitle : nil + let description = v2RawString(params, "description") + let groupID = v2UUID(params, "group_id") + if v2HasNonNullParam(params, "group_id"), groupID == nil { + return .failure(.err(code: "invalid_params", message: "Missing or invalid group_id", data: nil)) + } + let hasGroupPlacement = v2HasNonNullParam(params, "group_placement") + || v2HasNonNullParam(params, "placement") + let hasGroupReference = v2HasNonNullParam(params, "group_reference_workspace_id") + || v2HasNonNullParam(params, "reference_workspace_id") + if groupID == nil, hasGroupPlacement || hasGroupReference { + return .failure(.err( + code: "invalid_params", + message: "group_id is required for group placement", + data: nil + )) + } + let rawGroupPlacement = v2RawString(params, "group_placement") + ?? (groupID == nil ? nil : v2RawString(params, "placement")) + let groupPlacement = WorkspaceGroupNewPlacement(rawString: rawGroupPlacement) + if let rawGroupPlacement, + !rawGroupPlacement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + groupPlacement == nil { + return .failure(.err( + code: "invalid_params", + message: "Invalid group_placement", + data: ["group_placement": rawGroupPlacement] + )) + } + let groupReferenceWorkspaceID: UUID? + if v2HasNonNullParam(params, "group_reference_workspace_id") { + guard let parsed = v2UUID(params, "group_reference_workspace_id") else { + return .failure(.err( + code: "invalid_params", + message: "Missing or invalid group_reference_workspace_id", + data: nil + )) + } + groupReferenceWorkspaceID = parsed + } else if v2HasNonNullParam(params, "reference_workspace_id") { + guard let parsed = v2UUID(params, "reference_workspace_id") else { + return .failure(.err( + code: "invalid_params", + message: "Missing or invalid group_reference_workspace_id", + data: nil + )) + } + groupReferenceWorkspaceID = parsed + } else { + groupReferenceWorkspaceID = nil + } + + var layoutNode: CmuxLayoutNode? + if let rawLayout = params["layout"] { + guard JSONSerialization.isValidJSONObject(rawLayout), + let layoutData = try? JSONSerialization.data(withJSONObject: rawLayout) else { + return .failure(.err( + code: "invalid_params", + message: "layout must be a valid JSON object", + data: nil + )) + } + do { + layoutNode = try JSONDecoder().decode(CmuxLayoutNode.self, from: layoutData) + } catch { + return .failure(.err( + code: "invalid_params", + message: "Invalid layout: \(error.localizedDescription)", + data: nil + )) + } + } + + if let groupID { + let validation = v2MainSync { + let groupExists = preparation.tabManager.workspaceGroups.contains { $0.id == groupID } + let referenceIsMember = groupReferenceWorkspaceID.map { referenceID in + preparation.tabManager.tabs.contains { $0.id == referenceID && $0.groupId == groupID } + } ?? true + return (groupExists, referenceIsMember) + } + guard validation.0 else { + return .failure(.err( + code: "not_found", + message: "Group not found", + data: ["group_id": groupID.uuidString] + )) + } + guard validation.1 else { + return .failure(.err( + code: "invalid_params", + message: controlWorkspaceGroupStrings().invalidReferenceWorkspace, + data: ["group_reference_workspace_id": groupReferenceWorkspaceID?.uuidString ?? ""] + )) + } + } + + return .ready(WorkspaceCreateExecutionPreparation( + title: title, + description: description, + initialCommand: initialCommand, + initialEnvironment: initialEnvironment, + workspaceEnvironment: workspaceEnvironment, + workingDirectory: cwd, + groupID: groupID, + groupPlacement: groupPlacement, + groupReferenceWorkspaceID: groupReferenceWorkspaceID, + layoutNode: layoutNode, + shouldFocus: v2FocusAllowed(requested: v2Bool(params, "focus") ?? false), + shouldEagerLoadTerminal: v2Bool(params, "eager_load_terminal") + ?? !v2FocusAllowed(requested: v2Bool(params, "focus") ?? false), + shouldAutoRefreshMetadata: v2Bool(params, "auto_refresh_metadata") ?? true + )) + } +} diff --git a/Sources/TerminalController+WorkspaceCreateIdempotencyCache.swift b/Sources/TerminalController+WorkspaceCreateIdempotencyCache.swift new file mode 100644 index 000000000000..537b126f55cc --- /dev/null +++ b/Sources/TerminalController+WorkspaceCreateIdempotencyCache.swift @@ -0,0 +1,243 @@ +import Foundation +import OSLog + +extension TerminalController { + /// Bounded durable tombstones and live workspace IDs for idempotent creates. + final class WorkspaceCreateIdempotencyCache { + private static let legacyPersistenceKey = "cmux.workspaceCreate.completedOperationIDs.v1" + + private let capacity: Int + private let persistence: any WorkspaceCreateIdempotencyPersisting + private let persistenceWriter: WorkspaceCreateIdempotencyPersistenceWriter + private let legacyDefaults: UserDefaults? + private let legacyPersistenceKey: String? + private var loadFailure: (any Error)? + private var workspaceIDs: [UUID: UUID] = [:] + private var completedOperationIDs: Set = [] + private var insertionOrder: [UUID] = [] + private var stateRevision: UInt64 = 0 + private var pendingAcceptance: (id: UUID, task: Task)? + + convenience init(capacity: Int) { + self.init( + capacity: capacity, + persistence: WorkspaceCreateIdempotencyFileStore(), + legacyDefaults: .standard, + legacyPersistenceKey: Self.legacyPersistenceKey + ) + } + + init( + capacity: Int, + persistence: any WorkspaceCreateIdempotencyPersisting, + legacyDefaults: UserDefaults? = nil, + legacyPersistenceKey: String? = nil + ) { + precondition(capacity > 0) + self.capacity = capacity + self.persistence = persistence + persistenceWriter = WorkspaceCreateIdempotencyPersistenceWriter(persistence: persistence) + self.legacyDefaults = legacyDefaults + self.legacyPersistenceKey = legacyPersistenceKey + + let loaded: [UUID] + do { + loaded = try persistence.loadOperationIDs() + } catch { + loaded = [] + loadFailure = error + } + + var retained = Self.uniqueSuffix(loaded, capacity: capacity) + if let legacyDefaults, let legacyPersistenceKey { + let legacy = (legacyDefaults.stringArray(forKey: legacyPersistenceKey) ?? []) + .compactMap(UUID.init(uuidString:)) + let merged = Self.uniqueSuffix(retained + legacy, capacity: capacity) + if merged != retained, loadFailure == nil { + do { + try persistence.saveOperationIDs(merged) + legacyDefaults.removeObject(forKey: legacyPersistenceKey) + } catch { + // Keep the legacy copy until a later accepted operation + // successfully commits the merged snapshot. + workspaceCreateIdempotencyLogger.error( + "Legacy tombstone migration deferred: \(String(describing: error), privacy: .private)" + ) + } + } + retained = merged + } + + insertionOrder = retained + completedOperationIDs = Set(retained) + } + + /// Compatibility seam for tests that need to observe or reject writes. + /// Production uses the crash-durable file store above. + convenience init( + capacity: Int, + defaults: UserDefaults, + persistenceKey: String + ) { + self.init( + capacity: capacity, + persistence: WorkspaceCreateIdempotencyDefaultsStore( + defaults: defaults, + persistenceKey: persistenceKey + ) + ) + } + + func workspaceID(for operationID: UUID) -> UUID? { + workspaceIDs[operationID] + } + + func containsCompletedOperation(_ operationID: UUID) -> Bool { + completedOperationIDs.contains(operationID) + } + + /// Persists an accepted operation before workspace startup can execute. + /// Memory changes only after the durable transaction commits. + func accept(operationID: UUID) throws { + guard !completedOperationIDs.contains(operationID) else { return } + guard pendingAcceptance == nil else { + throw WorkspaceCreateIdempotencyCacheError.acceptanceInProgress + } + try retryInitialLoadSynchronouslyIfNeeded() + + let nextOrder = orderByAppending(operationID) + try persistence.saveOperationIDs(nextOrder) + commitAcceptedOrder(nextOrder) + } + + /// Persists on a serial background actor while the caller awaits. The + /// main actor remains available for UI and later RPCs, and concurrent + /// accepts are ordered from the last committed snapshot. + func acceptAsynchronously(operationID: UUID) async throws -> Bool { + while let pendingAcceptance { + _ = try? await pendingAcceptance.task.value + if self.pendingAcceptance?.id == pendingAcceptance.id { + self.pendingAcceptance = nil + } + } + guard !completedOperationIDs.contains(operationID) else { return false } + + let task = Task { @MainActor [weak self] in + guard let self else { return false } + try await retryInitialLoadAsynchronouslyIfNeeded() + guard !completedOperationIDs.contains(operationID) else { return false } + while true { + let expectedRevision = stateRevision + let nextOrder = orderByAppending(operationID) + try await persistenceWriter.saveOperationIDs(nextOrder) + guard stateRevision != expectedRevision else { + commitAcceptedOrder(nextOrder) + return true + } + // Session restore can add an in-memory tombstone while this + // actor is suspended on I/O. Rebuild from that newer state + // and save again so the completed write cannot erase it. + } + } + let pendingID = UUID() + pendingAcceptance = (pendingID, task) + do { + let accepted = try await task.value + if pendingAcceptance?.id == pendingID { pendingAcceptance = nil } + return accepted + } catch { + if pendingAcceptance?.id == pendingID { pendingAcceptance = nil } + throw error + } + } + + /// Associates a live workspace after construction. This mapping is an + /// in-memory convenience; durable acceptance remains authoritative. + func associate(operationID: UUID, workspaceID: UUID) { + workspaceIDs[operationID] = workspaceID + } + + /// Session restore may discover a live operation created by an older + /// build. If its durable upgrade fails, retain an in-memory tombstone + /// so this process still fails closed after that workspace closes. + func record(operationID: UUID, workspaceID: UUID) { + associate(operationID: operationID, workspaceID: workspaceID) + do { + try accept(operationID: operationID) + } catch { + workspaceCreateIdempotencyLogger.error( + "Restored task tombstone is memory-only: \(String(describing: error), privacy: .private)" + ) + commitInMemory(orderByAppending(operationID)) + } + } + + private func commitInMemory(_ nextOrder: [UUID]) { + let evictedIDs = completedOperationIDs.subtracting(nextOrder) + for evictedID in evictedIDs { + workspaceIDs.removeValue(forKey: evictedID) + } + insertionOrder = nextOrder + completedOperationIDs = Set(nextOrder) + stateRevision &+= 1 + } + + private func orderByAppending(_ operationID: UUID) -> [UUID] { + var nextOrder = insertionOrder.filter { $0 != operationID } + if nextOrder.count == capacity { nextOrder.removeFirst() } + nextOrder.append(operationID) + return nextOrder + } + + private func commitAcceptedOrder(_ nextOrder: [UUID]) { + if let legacyDefaults, let legacyPersistenceKey { + legacyDefaults.removeObject(forKey: legacyPersistenceKey) + } + commitInMemory(nextOrder) + } + + private func retryInitialLoadSynchronouslyIfNeeded() throws { + guard loadFailure != nil else { return } + let loaded = try persistence.loadOperationIDs() + reconcileReloadedOperationIDs(loaded) + } + + private func retryInitialLoadAsynchronouslyIfNeeded() async throws { + guard loadFailure != nil else { return } + let loaded = try await persistenceWriter.loadOperationIDs() + reconcileReloadedOperationIDs(loaded) + } + + private func reconcileReloadedOperationIDs(_ loaded: [UUID]) { + let retained = Self.uniqueSuffix(loaded + insertionOrder, capacity: capacity) + commitInMemory(retained) + loadFailure = nil + } + + private static func uniqueSuffix(_ operationIDs: [UUID], capacity: Int) -> [UUID] { + var seen: Set = [] + let uniqueReversed = operationIDs.reversed().filter { seen.insert($0).inserted } + return Array(uniqueReversed.prefix(capacity).reversed()) + } + } +} + +private enum WorkspaceCreateIdempotencyCacheError: Error { + case acceptanceInProgress +} + +private actor WorkspaceCreateIdempotencyPersistenceWriter { + private let persistence: any TerminalController.WorkspaceCreateIdempotencyPersisting + + init(persistence: any TerminalController.WorkspaceCreateIdempotencyPersisting) { + self.persistence = persistence + } + + func loadOperationIDs() throws -> [UUID] { + try persistence.loadOperationIDs() + } + + func saveOperationIDs(_ operationIDs: [UUID]) throws { + try persistence.saveOperationIDs(operationIDs) + } +} diff --git a/Sources/TerminalController+WorkspaceCreateIdempotencyDefaultsStore.swift b/Sources/TerminalController+WorkspaceCreateIdempotencyDefaultsStore.swift new file mode 100644 index 000000000000..c48010fe8f3a --- /dev/null +++ b/Sources/TerminalController+WorkspaceCreateIdempotencyDefaultsStore.swift @@ -0,0 +1,29 @@ +import Foundation + +extension TerminalController { + final class WorkspaceCreateIdempotencyDefaultsStore: WorkspaceCreateIdempotencyPersisting, @unchecked Sendable { + private enum StoreError: Error { + case writeRejected + } + + private let defaults: UserDefaults + private let persistenceKey: String + + init(defaults: UserDefaults, persistenceKey: String) { + self.defaults = defaults + self.persistenceKey = persistenceKey + } + + func loadOperationIDs() -> [UUID] { + (defaults.stringArray(forKey: persistenceKey) ?? []).compactMap(UUID.init(uuidString:)) + } + + func saveOperationIDs(_ operationIDs: [UUID]) throws { + let rawIDs = operationIDs.map(\.uuidString) + defaults.set(rawIDs, forKey: persistenceKey) + guard defaults.stringArray(forKey: persistenceKey) == rawIDs else { + throw StoreError.writeRejected + } + } + } +} diff --git a/Sources/TerminalController+WorkspaceCreateIdempotencyFileStore.swift b/Sources/TerminalController+WorkspaceCreateIdempotencyFileStore.swift new file mode 100644 index 000000000000..39e47c30b738 --- /dev/null +++ b/Sources/TerminalController+WorkspaceCreateIdempotencyFileStore.swift @@ -0,0 +1,196 @@ +import Darwin +import Foundation + +extension TerminalController { + final class WorkspaceCreateIdempotencyFileStore: WorkspaceCreateIdempotencyPersisting, @unchecked Sendable { + private struct Snapshot: Codable { + let version: Int + let operationIDs: [UUID] + } + + private enum StoreError: Error { + case unsupportedVersion(Int) + case shortWrite + } + + private static let version = 1 + private let fileManager: FileManager + private let beforeRename: (() throws -> Void)? + let fileURL: URL + + init( + fileURL: URL = WorkspaceCreateIdempotencyFileStore.defaultFileURL(), + fileManager: FileManager = .default, + beforeRename: (() throws -> Void)? = nil + ) { + self.fileURL = fileURL + self.fileManager = fileManager + self.beforeRename = beforeRename + } + + func loadOperationIDs() throws -> [UUID] { + try removeStaleTemporaryFiles() + guard fileManager.fileExists(atPath: fileURL.path) else { return [] } + let snapshot = try JSONDecoder().decode(Snapshot.self, from: Data(contentsOf: fileURL)) + guard snapshot.version == Self.version else { + throw StoreError.unsupportedVersion(snapshot.version) + } + return snapshot.operationIDs + } + + func saveOperationIDs(_ operationIDs: [UUID]) throws { + let snapshot = Snapshot(version: Self.version, operationIDs: operationIDs) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(snapshot) + let directoryURL = fileURL.deletingLastPathComponent() + let directoryExisted = fileManager.fileExists(atPath: directoryURL.path) + try fileManager.createDirectory( + at: directoryURL, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + if !directoryExisted { + try Self.synchronizeDirectory(at: directoryURL.deletingLastPathComponent()) + } + + let temporaryURL = directoryURL.appendingPathComponent( + ".\(fileURL.lastPathComponent).\(UUID().uuidString).tmp", + isDirectory: false + ) + do { + try Self.writeAndFullSync(data, to: temporaryURL) + try beforeRename?() + try Self.rename(temporaryURL, to: fileURL) + try Self.synchronizeDirectory(at: directoryURL) + } catch { + try? fileManager.removeItem(at: temporaryURL) + throw error + } + } + + private func removeStaleTemporaryFiles() throws { + let directoryURL = fileURL.deletingLastPathComponent() + guard fileManager.fileExists(atPath: directoryURL.path) else { return } + let prefix = ".\(fileURL.lastPathComponent)." + for candidate in try fileManager.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: nil + ) where candidate.lastPathComponent.hasPrefix(prefix) + && candidate.pathExtension == "tmp" { + try fileManager.removeItem(at: candidate) + } + } + + private static func defaultFileURL() -> URL { + let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first ?? FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support", isDirectory: true) + let rawBundleID = Bundle.main.bundleIdentifier ?? "com.cmuxterm.app" + let bundleID = rawBundleID.map { character in + character.isLetter || character.isNumber || character == "." || character == "-" + ? character + : "_" + } + return appSupport + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent( + "workspace-create-tombstones-\(String(bundleID))-v1.json", + isDirectory: false + ) + } + + private static func writeAndFullSync(_ data: Data, to url: URL) throws { + let descriptor = try openDescriptor( + url, + flags: O_WRONLY | O_CREAT | O_EXCL, + permissions: S_IRUSR | S_IWUSR + ) + var descriptorIsOpen = true + defer { + if descriptorIsOpen { _ = Darwin.close(descriptor) } + } + + try data.withUnsafeBytes { buffer in + guard let baseAddress = buffer.baseAddress else { return } + var offset = 0 + while offset < buffer.count { + let written = Darwin.write( + descriptor, + baseAddress.advanced(by: offset), + buffer.count - offset + ) + if written < 0 { + if errno == EINTR { continue } + throw posixError(path: url.path) + } + guard written > 0 else { throw StoreError.shortWrite } + offset += written + } + } + try retryingInterruptedCall(path: url.path) { + Darwin.fcntl(descriptor, F_FULLFSYNC) + } + guard Darwin.close(descriptor) == 0 else { + descriptorIsOpen = false + throw posixError(path: url.path) + } + descriptorIsOpen = false + } + + private static func rename(_ source: URL, to destination: URL) throws { + try source.withUnsafeFileSystemRepresentation { sourcePath in + try destination.withUnsafeFileSystemRepresentation { destinationPath in + guard let sourcePath, let destinationPath else { + throw CocoaError(.fileWriteInvalidFileName) + } + try retryingInterruptedCall(path: destination.path) { + Darwin.rename(sourcePath, destinationPath) + } + } + } + } + + private static func synchronizeDirectory(at url: URL) throws { + let descriptor = try openDescriptor(url, flags: O_RDONLY, permissions: 0) + defer { _ = Darwin.close(descriptor) } + try retryingInterruptedCall(path: url.path) { + Darwin.fsync(descriptor) + } + } + + private static func openDescriptor( + _ url: URL, + flags: Int32, + permissions: mode_t + ) throws -> Int32 { + try url.withUnsafeFileSystemRepresentation { path in + guard let path else { throw CocoaError(.fileWriteInvalidFileName) } + let descriptor = Darwin.open(path, flags, permissions) + guard descriptor >= 0 else { throw posixError(path: url.path) } + return descriptor + } + } + + private static func retryingInterruptedCall( + path: String, + _ call: () -> Int32 + ) throws { + while true { + if call() == 0 { return } + if errno == EINTR { continue } + throw posixError(path: path) + } + } + + private static func posixError(path: String) -> NSError { + NSError( + domain: NSPOSIXErrorDomain, + code: Int(errno), + userInfo: [NSFilePathErrorKey: path] + ) + } + } +} diff --git a/Sources/TerminalController+WorkspaceCreateIdempotencyPersisting.swift b/Sources/TerminalController+WorkspaceCreateIdempotencyPersisting.swift new file mode 100644 index 000000000000..f8100c185dad --- /dev/null +++ b/Sources/TerminalController+WorkspaceCreateIdempotencyPersisting.swift @@ -0,0 +1,14 @@ +import Foundation +import OSLog + +let workspaceCreateIdempotencyLogger = Logger( + subsystem: "com.cmuxterm.app", + category: "WorkspaceCreateIdempotency" +) + +extension TerminalController { + protocol WorkspaceCreateIdempotencyPersisting: Sendable { + func loadOperationIDs() throws -> [UUID] + func saveOperationIDs(_ operationIDs: [UUID]) throws + } +} diff --git a/Sources/TerminalController+WorkspaceCreatePreparation.swift b/Sources/TerminalController+WorkspaceCreatePreparation.swift new file mode 100644 index 000000000000..e43fbd41379e --- /dev/null +++ b/Sources/TerminalController+WorkspaceCreatePreparation.swift @@ -0,0 +1,181 @@ +import Foundation + +extension TerminalController { + struct WorkspaceCreatePreparation { + let tabManager: TabManager + let operationID: UUID? + let idempotencyCache: WorkspaceCreateIdempotencyCache + } + + enum WorkspaceCreatePreparationOutcome { + case failure(V2CallResult) + case existing(TaskCreateWorkspaceResolution) + case completed(tabManager: TabManager, operationID: UUID) + case ready(WorkspaceCreatePreparation) + } + + enum TaskCreateOperationResolution { + case live(TaskCreateWorkspaceResolution) + case completed + } + + func v2PrepareWorkspaceCreate( + params: [String: Any], + tabManager resolvedTabManager: TabManager?, + taskCreateCandidates: [TaskCreateWorkspaceCandidate]?, + idempotencyCache suppliedIdempotencyCache: WorkspaceCreateIdempotencyCache? + ) -> WorkspaceCreatePreparationOutcome { + let operationID: UUID? + if v2HasNonNullParam(params, "operation_id") { + guard let parsed = v2UUID(params, "operation_id") else { + return .failure( + .err(code: "invalid_params", message: "operation_id must be a UUID", data: nil) + ) + } + operationID = parsed + } else { + operationID = nil + } + guard let tabManager = resolvedTabManager ?? v2ResolveTabManager(params: params) else { + return .failure(.err(code: "unavailable", message: "TabManager not available", data: nil)) + } + let idempotencyCache = suppliedIdempotencyCache ?? workspaceCreateIdempotencyCache + + let candidates = taskCreateCandidates ?? taskCreateWorkspaceCandidates(requested: tabManager) + if let operationID { + switch taskCreateOperationResolution( + operationID: operationID, + candidates: candidates, + idempotencyCache: idempotencyCache + ) { + case let .live(resolution): + return .existing(resolution) + case .completed: + return .completed(tabManager: tabManager, operationID: operationID) + case nil: + break + } + } + return .ready(WorkspaceCreatePreparation( + tabManager: tabManager, + operationID: operationID, + idempotencyCache: idempotencyCache + )) + } + + func taskCreateWorkspaceCandidates(requested tabManager: TabManager) -> [TaskCreateWorkspaceCandidate] { + var candidates = [TaskCreateWorkspaceCandidate( + tabManager: tabManager, + windowID: v2ResolveWindowId(tabManager: tabManager) + )] + candidates.append(contentsOf: AppDelegate.shared?.scriptableMainWindows().map { + TaskCreateWorkspaceCandidate(tabManager: $0.tabManager, windowID: $0.windowId) + } ?? []) + return candidates + } + + func taskCreateOperationResolution( + operationID: UUID, + candidates: [TaskCreateWorkspaceCandidate], + idempotencyCache: WorkspaceCreateIdempotencyCache + ) -> TaskCreateOperationResolution? { + let resolution = Self.resolveTaskCreateWorkspace( + operationID: operationID, + cachedWorkspaceID: idempotencyCache.workspaceID(for: operationID), + candidates: candidates + ) + if let resolution { + idempotencyCache.record( + operationID: operationID, + workspaceID: resolution.workspace.id + ) + return .live(resolution) + } + // Durable acceptance is authoritative even if a crash happened before + // the workspace reached the session snapshot. Losing the workspace is + // preferable to executing arbitrary startup work more than once. + return idempotencyCache.containsCompletedOperation(operationID) ? .completed : nil + } + + static func resolveTaskCreateWorkspace( + operationID: UUID, + cachedWorkspaceID: UUID?, + candidates: [TaskCreateWorkspaceCandidate] + ) -> TaskCreateWorkspaceResolution? { + var seen: Set = [] + let uniqueCandidates = candidates.filter { seen.insert(ObjectIdentifier($0.tabManager)).inserted } + if let cachedWorkspaceID { + for candidate in uniqueCandidates { + if let workspace = candidate.tabManager.tabs.first(where: { $0.id == cachedWorkspaceID }), + workspace.taskCreateOperationID == operationID { + return TaskCreateWorkspaceResolution(workspace: workspace, candidate: candidate) + } + } + } + for candidate in uniqueCandidates { + if let workspace = candidate.tabManager.tabs.first(where: { $0.taskCreateOperationID == operationID }) { + return TaskCreateWorkspaceResolution(workspace: workspace, candidate: candidate) + } + } + return nil + } +} + +extension TerminalController { + enum MobileWorkspaceCreateReservationOutcome { + case notRequired + case accepted + case live(TaskCreateWorkspaceResolution) + case failure(V2CallResult) + } + + func v2ReserveMobileWorkspaceCreate( + preparation: WorkspaceCreatePreparation + ) async -> MobileWorkspaceCreateReservationOutcome { + guard let operationID = preparation.operationID else { return .notRequired } + let resolution = { + self.taskCreateOperationResolution( + operationID: operationID, + candidates: self.taskCreateWorkspaceCandidates(requested: preparation.tabManager), + idempotencyCache: preparation.idempotencyCache + ) + } + switch resolution() { + case let .live(workspace): + return .live(workspace) + case .completed: + return .failure(Self.v2MobileCompletedOperationResult(operationID: operationID)) + case nil: + break + } + + do { + guard try await preparation.idempotencyCache.acceptAsynchronously( + operationID: operationID + ) else { + if case let .live(workspace)? = resolution() { return .live(workspace) } + return .failure(Self.v2MobileCompletedOperationResult(operationID: operationID)) + } + return .accepted + } catch { + workspaceCreateIdempotencyLogger.error( + "Task reservation failed: \(String(describing: error), privacy: .private)" + ) + return .failure(.err( + code: "persistence_failed", + message: "Workspace task could not be reserved safely", + data: nil + )) + } + } + + nonisolated static func v2MobileCompletedOperationResult( + operationID: UUID + ) -> V2CallResult { + .err( + code: "already_completed", + message: "workspace.create operation already completed", + data: ["operation_id": operationID.uuidString] + ) + } +} diff --git a/Sources/TerminalController+WorkspaceCreateWorkingDirectory.swift b/Sources/TerminalController+WorkspaceCreateWorkingDirectory.swift new file mode 100644 index 000000000000..da7207e61e1e --- /dev/null +++ b/Sources/TerminalController+WorkspaceCreateWorkingDirectory.swift @@ -0,0 +1,231 @@ +import Darwin +import Foundation + +extension TerminalController { + enum WorkspaceCreateWorkingDirectoryValidation: Equatable, Sendable { + case notProvided + case valid(String) + case invalid + case busy + case timedOut + case cancelled + } + + typealias WorkspaceCreateWorkingDirectoryValidator = @Sendable ( + _ rawValue: String?, + _ isProvided: Bool + ) async -> WorkspaceCreateWorkingDirectoryValidation + + nonisolated static let v2MaximumWorkingDirectoryUTF8Bytes = 4_096 + + nonisolated static let v2MobileWorkingDirectoryValidationService = + WorkspaceCreateWorkingDirectoryValidationService( + timeout: .seconds(3), + localCapacity: 1, + externalCapacity: 2, + classificationCapacity: 3, + maximumPendingWaiters: 64, + maximumPathUTF8Bytes: v2MaximumWorkingDirectoryUTF8Bytes, + laneClassifier: { @Sendable path in + await Task.detached(priority: .utility) { + TerminalController.v2WorkingDirectoryProbeLane(path) + }.value + }, + probe: { path, lane in + await TerminalController.v2ProbeWorkingDirectory(path, lane: lane) + }, + sleepUntilDeadline: { timeout in + try? await ContinuousClock().sleep(for: timeout) + } + ) + + struct WorkspaceCreateMountEntry { + let path: String + let isLocal: Bool + } + + private enum WorkspaceCreatePathInspection { + case local(isDirectory: Bool) + case external + } + + nonisolated static func v2WorkingDirectoryProbeLane( + _ path: String + ) -> WorkspaceCreateWorkingDirectoryValidationService.ProbeLane { + switch v2InspectWorkingDirectoryPath(path) { + case .local: + return .local + case .external: + return .external + } + } + + /// Runs the selected lane's probe without letting a path mutation redirect + /// reserved local-lane work through a symbolic link. + nonisolated static func v2ProbeWorkingDirectory( + _ path: String, + lane: WorkspaceCreateWorkingDirectoryValidationService.ProbeLane + ) async -> Bool { + await Task.detached(priority: .utility) { + switch lane { + case .local: + guard case let .local(isDirectory) = v2InspectWorkingDirectoryPath(path) else { + return false + } + return isDirectory + case .external: + var isDirectory: ObjCBool = false + return FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) + && isDirectory.boolValue + } + }.value + } + + /// Inspects every component relative to an already-open parent directory. + /// Unknown mounts, symbolic links, and inspection failures are quarantined + /// to the external lane. `O_NOFOLLOW` prevents a component-swap race from + /// redirecting this walk through the link target. + nonisolated private static func v2InspectWorkingDirectoryPath( + _ path: String + ) -> WorkspaceCreatePathInspection { + guard (path as NSString).isAbsolutePath else { return .external } + guard !v2WorkingDirectoryContainsDotComponent(path) else { return .external } + let normalizedPath = URL(fileURLWithPath: path).standardized.path + guard !normalizedPath.utf8.contains(0) else { return .external } + let mounts = v2WorkingDirectoryMountEntries() + guard v2WorkingDirectoryMountIsLocal(path: "/", mounts: mounts) == true else { + return .external + } + + var directoryFD = open("/", O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NONBLOCK) + guard directoryFD >= 0 else { return .external } + defer { close(directoryFD) } + + let components = normalizedPath.split(separator: "/").map(String.init) + if components.isEmpty { + var fileStatus = stat() + guard fstat(directoryFD, &fileStatus) == 0 else { return .external } + return .local(isDirectory: (fileStatus.st_mode & S_IFMT) == S_IFDIR) + } + + var prefix = "" + for (index, component) in components.enumerated() { + prefix += "/\(component)" + guard v2WorkingDirectoryMountIsLocal(path: prefix, mounts: mounts) == true else { + return .external + } + + var fileStatus = stat() + let status = component.withCString { + fstatat(directoryFD, $0, &fileStatus, AT_SYMLINK_NOFOLLOW) + } + guard status == 0 else { return .external } + let fileType = fileStatus.st_mode & S_IFMT + guard fileType != S_IFLNK else { return .external } + if index == components.index(before: components.endIndex) { + return .local(isDirectory: fileType == S_IFDIR) + } + guard fileType == S_IFDIR else { return .external } + + let nextDirectoryFD = component.withCString { + openat( + directoryFD, + $0, + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK + ) + } + guard nextDirectoryFD >= 0 else { return .external } + close(directoryFD) + directoryFD = nextDirectoryFD + } + return .external + } + + nonisolated private static func v2WorkingDirectoryMountEntries() -> [WorkspaceCreateMountEntry] { + var mountBuffer: UnsafeMutablePointer? + let mountCount = getmntinfo_r_np(&mountBuffer, MNT_NOWAIT) + defer { + if let mountBuffer { + free(UnsafeMutableRawPointer(mountBuffer)) + } + } + guard mountCount > 0, let mountBuffer else { return [] } + + var entries: [WorkspaceCreateMountEntry] = [] + entries.reserveCapacity(Int(mountCount)) + for index in 0.. Bool? { + let foldedPath = v2CaseFoldedMountPath(path) + var longestMatchLength = -1 + var longestMatchIsLocal: Bool? + for mount in mounts { + let foldedMountPath = v2CaseFoldedMountPath(mount.path) + let matches = foldedPath == foldedMountPath + || foldedPath.hasPrefix( + foldedMountPath == "/" ? "/" : "\(foldedMountPath)/" + ) + guard matches else { continue } + let matchLength = foldedMountPath.count + if matchLength > longestMatchLength { + longestMatchLength = matchLength + longestMatchIsLocal = mount.isLocal + } else if matchLength == longestMatchLength, !mount.isLocal { + // Conflicting snapshots or case aliases fail into the bounded + // external lane regardless of mount enumeration order. + longestMatchIsLocal = false + } + } + return longestMatchIsLocal + } + + nonisolated static func v2WorkingDirectoryContainsDotComponent(_ path: String) -> Bool { + path.split(separator: "/", omittingEmptySubsequences: false).contains { + $0 == "." || $0 == ".." + } + } + + nonisolated private static func v2CaseFoldedMountPath(_ path: String) -> String { + path.folding(options: .caseInsensitive, locale: Locale(identifier: "en_US_POSIX")) + } + + nonisolated static var v2InvalidWorkingDirectoryResult: V2CallResult { + .err( + code: "invalid_working_directory", + message: "working_directory must be an absolute existing directory", + data: ["field": "working_directory"] + ) + } + + nonisolated static func v2ValidateMobileWorkingDirectory( + rawValue: String?, + isProvided: Bool + ) async -> WorkspaceCreateWorkingDirectoryValidation { + guard !Task.isCancelled else { return .cancelled } + guard !isProvided || (rawValue?.utf8.count ?? 0) <= v2MaximumWorkingDirectoryUTF8Bytes else { + return .invalid + } + let validation = await v2MobileWorkingDirectoryValidationService.validate( + rawValue: rawValue, + isProvided: isProvided + ) + guard !Task.isCancelled else { return .cancelled } + return validation + } +} diff --git a/Sources/TerminalController+WorkspaceCreateWorkingDirectoryValidationService.swift b/Sources/TerminalController+WorkspaceCreateWorkingDirectoryValidationService.swift new file mode 100644 index 000000000000..aff774322eb7 --- /dev/null +++ b/Sources/TerminalController+WorkspaceCreateWorkingDirectoryValidationService.swift @@ -0,0 +1,227 @@ +import Foundation + +extension TerminalController { + /// Coalesces bounded working-directory probes and quarantines wedged work. + actor WorkspaceCreateWorkingDirectoryValidationService { + enum ProbeLane: Equatable, Sendable { + case local + case external + } + + typealias Probe = @Sendable (_ path: String, _ lane: ProbeLane) async -> Bool + typealias LaneClassifier = @Sendable (_ path: String) async -> ProbeLane + typealias DeadlineSleep = @Sendable (_ timeout: Duration) async -> Void + + private struct QueuedProbe { + let pathID: Data + let path: String + let lane: ProbeLane + } + + private struct QueuedClassification { + let pathID: Data + let path: String + } + + private struct Waiter { + let pathID: Data + let continuation: CheckedContinuation + let deadlineTask: Task + } + + private let timeout: Duration + private let localCapacity: Int + private let externalCapacity: Int + private let classificationCapacity: Int + private let maximumPendingWaiters: Int + private let maximumPathUTF8Bytes: Int + private let laneClassifier: LaneClassifier + private let probe: Probe + private let sleepUntilDeadline: DeadlineSleep + private var activeLanesByPath: [Data: ProbeLane] = [:] + private var queuedProbes: [QueuedProbe] = [] + private var classifyingPathIDs: Set = [] + private var queuedClassifications: [QueuedClassification] = [] + private var waiterIDsByPath: [Data: Set] = [:] + private var waiters: [UUID: Waiter] = [:] + + init( + timeout: Duration, + localCapacity: Int, + externalCapacity: Int, + classificationCapacity: Int = 3, + maximumPendingWaiters: Int, + maximumPathUTF8Bytes: Int = 4_096, + laneClassifier: @escaping LaneClassifier, + probe: @escaping Probe, + sleepUntilDeadline: @escaping DeadlineSleep + ) { + precondition( + localCapacity > 0 && externalCapacity > 0 && classificationCapacity > 0 + && maximumPendingWaiters > 0 && maximumPathUTF8Bytes > 0 + ) + self.timeout = timeout + self.localCapacity = localCapacity + self.externalCapacity = externalCapacity + self.classificationCapacity = classificationCapacity + self.maximumPendingWaiters = maximumPendingWaiters + self.maximumPathUTF8Bytes = maximumPathUTF8Bytes + self.laneClassifier = laneClassifier + self.probe = probe + self.sleepUntilDeadline = sleepUntilDeadline + } + + func validate( + rawValue: String?, + isProvided: Bool + ) async -> WorkspaceCreateWorkingDirectoryValidation { + guard isProvided else { return .notProvided } + guard let rawValue, rawValue.utf8.count <= maximumPathUTF8Bytes else { + return .invalid + } + guard let path = TerminalController.v2ExpandedWorkingDirectory(rawValue), + path.utf8.count <= maximumPathUTF8Bytes, + (path as NSString).isAbsolutePath, + !TerminalController.v2WorkingDirectoryContainsDotComponent(path) else { + return .invalid + } + guard !Task.isCancelled else { return .cancelled } + let waiterID = UUID() + return await withTaskCancellationHandler { + guard !Task.isCancelled else { return .cancelled } + return await withCheckedContinuation { continuation in + register(waiterID: waiterID, path: path, continuation: continuation) + } + } onCancel: { + Task { await self.cancelWaiter(waiterID) } + } + } + + private func register( + waiterID: UUID, + path: String, + continuation: CheckedContinuation + ) { + guard waiters.count < maximumPendingWaiters else { + continuation.resume(returning: .busy) + return + } + let deadlineTask = Task { [weak self, sleepUntilDeadline, timeout] in + await sleepUntilDeadline(timeout) + guard !Task.isCancelled else { return } + await self?.timeoutWaiter(waiterID) + } + let pathID = Data(path.utf8) + waiters[waiterID] = Waiter( + pathID: pathID, + continuation: continuation, + deadlineTask: deadlineTask + ) + waiterIDsByPath[pathID, default: []].insert(waiterID) + if activeLanesByPath[pathID] == nil, + !classifyingPathIDs.contains(pathID), + !queuedClassifications.contains(where: { $0.pathID == pathID }), + !queuedProbes.contains(where: { $0.pathID == pathID }) { + queuedClassifications.append(QueuedClassification(pathID: pathID, path: path)) + } + startClassificationsUpToLimit() + startProbesUpToLimit() + } + + private func cancelWaiter(_ waiterID: UUID) { + guard waiters[waiterID] != nil else { return } + finishWaiter(waiterID, result: .cancelled) + } + + private func timeoutWaiter(_ waiterID: UUID) { + finishWaiter(waiterID, result: .timedOut) + } + + private func finishWaiter( + _ waiterID: UUID, + result: WorkspaceCreateWorkingDirectoryValidation + ) { + guard let waiter = waiters.removeValue(forKey: waiterID) else { return } + let pathID = waiter.pathID + waiter.deadlineTask.cancel() + waiterIDsByPath[pathID]?.remove(waiterID) + if waiterIDsByPath[pathID]?.isEmpty == true { + waiterIDsByPath.removeValue(forKey: pathID) + if activeLanesByPath[pathID] == nil { + queuedClassifications.removeAll { $0.pathID == pathID } + queuedProbes.removeAll { $0.pathID == pathID } + } + } + waiter.continuation.resume(returning: result) + } + + private func startClassificationsUpToLimit() { + while classifyingPathIDs.count < classificationCapacity, + !queuedClassifications.isEmpty { + let queued = queuedClassifications.removeFirst() + guard waiterIDsByPath[queued.pathID]?.isEmpty == false else { continue } + classifyingPathIDs.insert(queued.pathID) + Task.detached(priority: .utility) { [weak self, laneClassifier] in + let lane = await laneClassifier(queued.path) + await self?.completeClassification(queued, lane: lane) + } + } + } + + private func completeClassification( + _ classification: QueuedClassification, + lane: ProbeLane + ) { + guard classifyingPathIDs.remove(classification.pathID) != nil else { return } + if waiterIDsByPath[classification.pathID]?.isEmpty == false { + queuedProbes.append(QueuedProbe( + pathID: classification.pathID, + path: classification.path, + lane: lane + )) + } + startClassificationsUpToLimit() + startProbesUpToLimit() + } + + private func startProbesUpToLimit() { + while let index = queuedProbes.firstIndex(where: { hasCapacity(for: $0.lane) }) { + let queued = queuedProbes.remove(at: index) + let pathID = queued.pathID + let path = queued.path + guard waiterIDsByPath[pathID]?.isEmpty == false else { continue } + activeLanesByPath[pathID] = queued.lane + Task { [weak self, probe, lane = queued.lane] in + let isDirectory = await probe(path, lane) + await self?.completeProbe( + pathID: pathID, + path: path, + isDirectory: isDirectory + ) + } + } + } + + private func completeProbe(pathID: Data, path: String, isDirectory: Bool) { + guard activeLanesByPath.removeValue(forKey: pathID) != nil else { return } + let waiterIDs = Array(waiterIDsByPath[pathID] ?? []) + let result: WorkspaceCreateWorkingDirectoryValidation = isDirectory ? .valid(path) : .invalid + for waiterID in waiterIDs { + finishWaiter(waiterID, result: result) + } + startProbesUpToLimit() + } + + private func hasCapacity(for lane: ProbeLane) -> Bool { + let activeCount = activeLanesByPath.values.reduce(into: 0) { count, activeLane in + if activeLane == lane { count += 1 } + } + switch lane { + case .local: + return activeCount < localCapacity + case .external: + return activeCount < externalCapacity + } + } + } +} diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 6b2bf579daa2..fba89cea402a 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -116,13 +116,13 @@ nonisolated private func v2RemotePTYUserFacingErrorMessage(_ message: String) -> @MainActor class TerminalController { static let shared = TerminalController() - private nonisolated let remotePTYControllerAvailabilityCondition = NSCondition() private nonisolated(unsafe) var remotePTYControllerAvailabilityGeneration: UInt64 = 0 + /// One process-wide admission budget shared by every mobile connection. + nonisolated let mobileTaskFilesystemJobQuota: MobileTaskFilesystemJobQuota var tabManager: TabManager? - /// The shared auth coordinator + browser sign-in flow, injected once via - /// `attachAuth` at app startup (AppDelegate `configure`) before the socket - /// listener starts. Socket auth commands read these on the main actor. + let workspaceCreateIdempotencyCache = WorkspaceCreateIdempotencyCache(capacity: 256) + /// Auth coordinator and browser flow are injected by `attachAuth` before socket startup. @MainActor private(set) var authCoordinator: AuthCoordinator? @MainActor private(set) var browserSignInFlow: HostBrowserSignInFlow? @MainActor var agentChatTranscriptService: AgentChatTranscriptService? @@ -355,6 +355,7 @@ class TerminalController { socketClientPreauthorizationLimiter: SocketClientPreauthorizationLimiter = .init( maximumConcurrentClaims: 32 ), + mobileTaskFilesystemJobQuota: MobileTaskFilesystemJobQuota = .init(), terminalArtifactAuthorizationStore: TerminalArtifactAuthorizationStore = .init(), remoteProxyBroker: any RemoteProxyBrokering = RemoteProxyBroker( tunnelProvider: RemoteDaemonProxyTunnelProvider(strings: .appLocalized, ptyBridgeStrings: AppRemotePTYBridgeStrings()) @@ -368,6 +369,7 @@ class TerminalController { self.socketPasswordFileWatcher = socketPasswordFileWatcher self.socketClientCapabilityAuthority = Self.makeSocketClientCapabilityAuthority() self.socketClientPreauthorizationLimiter = socketClientPreauthorizationLimiter + self.mobileTaskFilesystemJobQuota = mobileTaskFilesystemJobQuota self.terminalArtifactAuthorizationStore = terminalArtifactAuthorizationStore self.transport = transport self.remoteProxyBroker = remoteProxyBroker @@ -13814,8 +13816,18 @@ class TerminalController { result = await v2MobileAttachTicketCreate(params: request.params) case "mobile.workspace.list", "workspace.list": result = v2MobileWorkspaceList(params: request.params) + case "mobile.directory.search": + result = await v2MobileDirectorySearch( + params: request.params, + filesystemJobQuota: mobileTaskFilesystemJobQuota + ) + case "mobile.directory.list": + result = await v2MobileDirectoryList( + params: request.params, + filesystemJobQuota: mobileTaskFilesystemJobQuota + ) case "workspace.create": - result = v2MobileWorkspaceCreate(params: request.params) + result = await v2MobileWorkspaceCreate(params: request.params) case "mobile.terminal.create", "terminal.create": result = v2MobileTerminalCreate(params: request.params) case "mobile.terminal.input", "terminal.input": diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 62448c638a6e..d6bc3b618562 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -122,6 +122,7 @@ extension Workspace { var snapshot = SessionWorkspaceSnapshot( workspaceId: id, stableId: stableId, + taskCreateOperationID: taskCreateOperationID, processTitle: processTitle, customTitle: customTitle, customTitleSource: effectiveCustomTitleSource, @@ -162,6 +163,7 @@ extension Workspace { sessionRestoreIdentityExclusions.shouldAdopt(persistedStableId) { stableId = persistedStableId } + taskCreateOperationID = snapshot.taskCreateOperationID restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) #if DEBUG @@ -1951,10 +1953,11 @@ final class Workspace: Identifiable, ObservableObject { static let terminalScrollBarHiddenDidChangeNotification = Notification.Name( "cmux.workspaceTerminalScrollBarHiddenDidChange" ) - let id: UUID /// Restart-stable workspace identifier persisted for durable deep links. private(set) var stableId = UUID() + /// Durable idempotency key for task-composer workspace creation. + var taskCreateOperationID: UUID? private var forkAgentConversationInFlightPanelIds: Set = [] func beginForkAgentConversationAction(panelId: UUID) -> Bool { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 8a74d3285f69..ac4bb284e5a5 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -955,6 +955,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 809100000000000000000001 /* HostSettingsShortcutNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 809100000000000000000002 /* HostSettingsShortcutNotificationTests.swift */; }; F1C1AA21B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C1AA20B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift */; }; DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000002 /* InfoPlist.xcstrings */; }; + C0DE71450000000000000003 /* InMemoryWorkspaceCreateIdempotencyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE71450000000000000004 /* InMemoryWorkspaceCreateIdempotencyStore.swift */; }; C0DEA7720000000000000001 /* InternalFlagsWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEA7720000000000000002 /* InternalFlagsWindow.swift */; }; 4D8B37E0E11A29997632765F /* InternalTabDragConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 610008EA5E3744DDBE05C8E2 /* InternalTabDragConfiguration.swift */; }; E7B200000000000000000001 /* IrohTransportDebugMenuButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7B100000000000000000001 /* IrohTransportDebugMenuButtons.swift */; }; @@ -1099,6 +1100,18 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources A2538FE83AE79539E3BF9248 /* MobilePairingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9BC53FA4991A6EBFB930EDF /* MobilePairingView.swift */; }; 18A8E29E9BD87E184983527C /* MobilePairingWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AE701F43A0B49F08835191C /* MobilePairingWindowController.swift */; }; 9916B44C7A9914E172D112D4 /* MobileRouteResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A956C76162B79EC74AF9965 /* MobileRouteResolver.swift */; }; + D1B800000000000000000001 /* MobileTaskDirectoryListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1B800000000000000000011 /* MobileTaskDirectoryListItem.swift */; }; + D1B800000000000000000002 /* MobileTaskDirectoryListPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1B800000000000000000012 /* MobileTaskDirectoryListPage.swift */; }; + D1B800000000000000000003 /* MobileTaskDirectoryListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1B800000000000000000013 /* MobileTaskDirectoryListService.swift */; }; + D1B800000000000000000004 /* MobileTaskDirectoryListServiceError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1B800000000000000000014 /* MobileTaskDirectoryListServiceError.swift */; }; + D1B800000000000000000005 /* MobileTaskDirectoryListServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1B800000000000000000015 /* MobileTaskDirectoryListServiceTests.swift */; }; + D1C900000000000000000001 /* MobileTaskDirectoryMetadataQueryRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1C900000000000000000011 /* MobileTaskDirectoryMetadataQueryRunner.swift */; }; + D1C900000000000000000002 /* MobileTaskDirectorySearchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1C900000000000000000012 /* MobileTaskDirectorySearchResult.swift */; }; + C248FC7D0000000000000002 /* MobileTaskDirectorySearchService+FuzzyMatching.swift in Sources */ = {isa = PBXBuildFile; fileRef = C248FC7D0000000000000001 /* MobileTaskDirectorySearchService+FuzzyMatching.swift */; }; + D1A700000000000000000001 /* MobileTaskDirectorySearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A700000000000000000004 /* MobileTaskDirectorySearchService.swift */; }; + D1A700000000000000000003 /* MobileTaskDirectorySearchServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A700000000000000000006 /* MobileTaskDirectorySearchServiceTests.swift */; }; + F5A700000000000000000002 /* MobileTaskFilesystemJobQuota.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5A700000000000000000012 /* MobileTaskFilesystemJobQuota.swift */; }; + F5A700000000000000000001 /* MobileTaskFilesystemJobQuotaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5A700000000000000000011 /* MobileTaskFilesystemJobQuotaTests.swift */; }; AB57F0C27BECE8EDFC6B2B97 /* MobileTerminalByteTee.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F05EBBC2FE0D5CB2F662B8A /* MobileTerminalByteTee.swift */; }; C9CFB398DF94D6DDEAF42D67 /* MobileTerminalRenderObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F51B54FAD18160281FC1C2B6 /* MobileTerminalRenderObserver.swift */; }; F7A110000000000000000003 /* MobileTerminalThemeEmissionDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A110000000000000000004 /* MobileTerminalThemeEmissionDecision.swift */; }; @@ -1706,6 +1719,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources E3B7A4000000000000000009 /* TabManager+NotificationDismissalHosting.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3B7A400000000000000000A /* TabManager+NotificationDismissalHosting.swift */; }; C0DE1A030000000000000001 /* TabManager+SavedLayouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE1A030000000000000002 /* TabManager+SavedLayouts.swift */; }; C548600B0000000000000001 /* TabManager+SessionRestoreIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = C548600B0000000000000002 /* TabManager+SessionRestoreIdentity.swift */; }; + A905DFE00000000000000001 /* TabManager+SessionRestorePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A905DFE00000000000000002 /* TabManager+SessionRestorePolicy.swift */; }; C5B6A10000000000000000B1 /* TabManager+SidebarGitHosting.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5B6A10000000000000000B2 /* TabManager+SidebarGitHosting.swift */; }; 604500100000000000000007 /* TabManager+WindowTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 604500100000000000000008 /* TabManager+WindowTitle.swift */; }; 736200000000000000000004 /* TabManager+WorkspaceCustomTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 736200000000000000000003 /* TabManager+WorkspaceCustomTitle.swift */; }; @@ -1764,6 +1778,8 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources A7C0F0020000000000000002 /* TerminalController+MobileAttachTicket.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7C0F0020000000000000001 /* TerminalController+MobileAttachTicket.swift */; }; ACA7C4A7000000000000000D /* TerminalController+MobileChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA7C4A7000000000000000E /* TerminalController+MobileChat.swift */; }; C7AF10000000000000000003 /* TerminalController+MobileChatArtifacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7AF10000000000000000004 /* TerminalController+MobileChatArtifacts.swift */; }; + D1B800000000000000000006 /* TerminalController+MobileDirectoryList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1B800000000000000000016 /* TerminalController+MobileDirectoryList.swift */; }; + D1A700000000000000000002 /* TerminalController+MobileDirectorySearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A700000000000000000005 /* TerminalController+MobileDirectorySearch.swift */; }; D7AB00000000000000B001 /* TerminalController+MobileNotificationSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000B000 /* TerminalController+MobileNotificationSync.swift */; }; D7AB00000000000000B011 /* TerminalController+MobileScrollPrefetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000B010 /* TerminalController+MobileScrollPrefetch.swift */; }; C7AF20000000000000000001 /* TerminalController+MobileTerminalArtifacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7AF20000000000000000002 /* TerminalController+MobileTerminalArtifacts.swift */; }; @@ -1779,6 +1795,14 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C79470020000000000000001 /* TerminalController+SocketListenerRearm.swift in Sources */ = {isa = PBXBuildFile; fileRef = C79470020000000000000002 /* TerminalController+SocketListenerRearm.swift */; }; D6212D0C00000000000000D3 /* TerminalController+WindowDockBrowserRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6212D0C00000000000000D4 /* TerminalController+WindowDockBrowserRouting.swift */; }; C0DE00000000000000000C84 /* TerminalController+WorkspaceCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C83 /* TerminalController+WorkspaceCreate.swift */; }; + D1A70000000000000000000C /* TerminalController+WorkspaceCreateExecutionPreparation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A70000000000000000000B /* TerminalController+WorkspaceCreateExecutionPreparation.swift */; }; + C0DE00000000000000000E91 /* TerminalController+WorkspaceCreateIdempotencyCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000E90 /* TerminalController+WorkspaceCreateIdempotencyCache.swift */; }; + C0DE00000000000000000EA5 /* TerminalController+WorkspaceCreateIdempotencyDefaultsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000EA4 /* TerminalController+WorkspaceCreateIdempotencyDefaultsStore.swift */; }; + C0DE00000000000000000EA3 /* TerminalController+WorkspaceCreateIdempotencyFileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000EA2 /* TerminalController+WorkspaceCreateIdempotencyFileStore.swift */; }; + C0DE00000000000000000EA1 /* TerminalController+WorkspaceCreateIdempotencyPersisting.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000EA0 /* TerminalController+WorkspaceCreateIdempotencyPersisting.swift */; }; + C0DE00000000000000000D36 /* TerminalController+WorkspaceCreatePreparation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000D35 /* TerminalController+WorkspaceCreatePreparation.swift */; }; + C0DE00000000000000000D32 /* TerminalController+WorkspaceCreateWorkingDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000D31 /* TerminalController+WorkspaceCreateWorkingDirectory.swift */; }; + C0DE00000000000000000E93 /* TerminalController+WorkspaceCreateWorkingDirectoryValidationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000E92 /* TerminalController+WorkspaceCreateWorkingDirectoryValidationService.swift */; }; C0DE00000000000000000C8B /* TerminalController+WorkspaceGroupAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C8A /* TerminalController+WorkspaceGroupAction.swift */; }; C0DE00000000000000000C89 /* TerminalController+WorkspaceMove.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C88 /* TerminalController+WorkspaceMove.swift */; }; A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; }; @@ -2000,6 +2024,12 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 5732A0045732A0045732A004 /* WorkspaceContentMinimalModeSafeAreaModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5732B0045732B0045732B004 /* WorkspaceContentMinimalModeSafeAreaModifier.swift */; }; A5001407 /* WorkspaceContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001417 /* WorkspaceContentView.swift */; }; F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; }; + D1A700000000000000000008 /* WorkspaceCreateIdempotencyRecoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A700000000000000000007 /* WorkspaceCreateIdempotencyRecoveryTests.swift */; }; + C0DE00000000000000000D38 /* WorkspaceCreateIdempotencyTombstoneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000D37 /* WorkspaceCreateIdempotencyTombstoneTests.swift */; }; + D1A70000000000000000000A /* WorkspaceCreateReviewRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A700000000000000000009 /* WorkspaceCreateReviewRegressionTests.swift */; }; + C0DE00000000000000000D3A /* WorkspaceCreateWorkingDirectoryAliasTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000D39 /* WorkspaceCreateWorkingDirectoryAliasTests.swift */; }; + C0DE71450000000000000001 /* WorkspaceCreateWorkingDirectoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE71450000000000000002 /* WorkspaceCreateWorkingDirectoryTests.swift */; }; + C0DE00000000000000000D34 /* WorkspaceCreateWorkingDirectoryValidationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000D33 /* WorkspaceCreateWorkingDirectoryValidationServiceTests.swift */; }; 281F3AEA52379AF45C5C1189 /* WorkspaceCustomSidebarPullRequestContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE74DFAC5946F9C2EEDBE577 /* WorkspaceCustomSidebarPullRequestContextTests.swift */; }; E6FA9084A1B2C3D4E5F60718 /* WorkspaceDescriptionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6FA9085A1B2C3D4E5F60718 /* WorkspaceDescriptionUITests.swift */; }; 72DB1838A0F1B710BFB45DC1 /* WorkspaceEnvironmentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B561A95C6DC21BA9C4B2BCDD /* WorkspaceEnvironmentTests.swift */; }; @@ -3021,6 +3051,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 809100000000000000000002 /* HostSettingsShortcutNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostSettingsShortcutNotificationTests.swift; sourceTree = ""; }; F1C1AA20B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InactivePaneFirstClickFocusTests.swift; sourceTree = ""; }; DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; + C0DE71450000000000000004 /* InMemoryWorkspaceCreateIdempotencyStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InMemoryWorkspaceCreateIdempotencyStore.swift; sourceTree = ""; }; C0DEA7720000000000000002 /* InternalFlagsWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalFlagsWindow.swift; sourceTree = ""; }; 610008EA5E3744DDBE05C8E2 /* InternalTabDragConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/InternalTabDragConfiguration.swift; sourceTree = ""; }; E7B100000000000000000001 /* IrohTransportDebugMenuButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrohTransportDebugMenuButtons.swift; sourceTree = ""; }; @@ -3161,6 +3192,18 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F9BC53FA4991A6EBFB930EDF /* MobilePairingView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "Pairing/MobilePairingView.swift"; sourceTree = ""; }; 5AE701F43A0B49F08835191C /* MobilePairingWindowController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "Pairing/MobilePairingWindowController.swift"; sourceTree = ""; }; 7A956C76162B79EC74AF9965 /* MobileRouteResolver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileRouteResolver.swift; sourceTree = ""; }; + D1B800000000000000000011 /* MobileTaskDirectoryListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileTaskDirectoryListItem.swift; sourceTree = ""; }; + D1B800000000000000000012 /* MobileTaskDirectoryListPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileTaskDirectoryListPage.swift; sourceTree = ""; }; + D1B800000000000000000013 /* MobileTaskDirectoryListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileTaskDirectoryListService.swift; sourceTree = ""; }; + D1B800000000000000000014 /* MobileTaskDirectoryListServiceError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileTaskDirectoryListServiceError.swift; sourceTree = ""; }; + D1B800000000000000000015 /* MobileTaskDirectoryListServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileTaskDirectoryListServiceTests.swift; sourceTree = ""; }; + D1C900000000000000000011 /* MobileTaskDirectoryMetadataQueryRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileTaskDirectoryMetadataQueryRunner.swift; sourceTree = ""; }; + D1C900000000000000000012 /* MobileTaskDirectorySearchResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileTaskDirectorySearchResult.swift; sourceTree = ""; }; + C248FC7D0000000000000001 /* MobileTaskDirectorySearchService+FuzzyMatching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MobileTaskDirectorySearchService+FuzzyMatching.swift"; sourceTree = ""; }; + D1A700000000000000000004 /* MobileTaskDirectorySearchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileTaskDirectorySearchService.swift; sourceTree = ""; }; + D1A700000000000000000006 /* MobileTaskDirectorySearchServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileTaskDirectorySearchServiceTests.swift; sourceTree = ""; }; + F5A700000000000000000012 /* MobileTaskFilesystemJobQuota.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileTaskFilesystemJobQuota.swift; sourceTree = ""; }; + F5A700000000000000000011 /* MobileTaskFilesystemJobQuotaTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileTaskFilesystemJobQuotaTests.swift; sourceTree = ""; }; 4F05EBBC2FE0D5CB2F662B8A /* MobileTerminalByteTee.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileTerminalByteTee.swift; sourceTree = ""; }; F51B54FAD18160281FC1C2B6 /* MobileTerminalRenderObserver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileTerminalRenderObserver.swift; sourceTree = ""; }; F7A110000000000000000004 /* MobileTerminalThemeEmissionDecision.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileTerminalThemeEmissionDecision.swift; sourceTree = ""; }; @@ -3752,6 +3795,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = E3B7A400000000000000000A /* TabManager+NotificationDismissalHosting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+NotificationDismissalHosting.swift"; sourceTree = ""; }; C0DE1A030000000000000002 /* TabManager+SavedLayouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+SavedLayouts.swift"; sourceTree = ""; }; C548600B0000000000000002 /* TabManager+SessionRestoreIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+SessionRestoreIdentity.swift"; sourceTree = ""; }; + A905DFE00000000000000002 /* TabManager+SessionRestorePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+SessionRestorePolicy.swift"; sourceTree = ""; }; C5B6A10000000000000000B2 /* TabManager+SidebarGitHosting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+SidebarGitHosting.swift"; sourceTree = ""; }; 604500100000000000000008 /* TabManager+WindowTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+WindowTitle.swift"; sourceTree = ""; }; 736200000000000000000003 /* TabManager+WorkspaceCustomTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+WorkspaceCustomTitle.swift"; sourceTree = ""; }; @@ -3810,6 +3854,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A7C0F0020000000000000001 /* TerminalController+MobileAttachTicket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileAttachTicket.swift"; sourceTree = ""; }; ACA7C4A7000000000000000E /* TerminalController+MobileChat.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileChat.swift"; sourceTree = ""; }; C7AF10000000000000000004 /* TerminalController+MobileChatArtifacts.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileChatArtifacts.swift"; sourceTree = ""; }; + D1B800000000000000000016 /* TerminalController+MobileDirectoryList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileDirectoryList.swift"; sourceTree = ""; }; + D1A700000000000000000005 /* TerminalController+MobileDirectorySearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileDirectorySearch.swift"; sourceTree = ""; }; D7AB00000000000000B000 /* TerminalController+MobileNotificationSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileNotificationSync.swift"; sourceTree = ""; }; D7AB00000000000000B010 /* TerminalController+MobileScrollPrefetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileScrollPrefetch.swift"; sourceTree = ""; }; C7AF20000000000000000002 /* TerminalController+MobileTerminalArtifacts.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileTerminalArtifacts.swift"; sourceTree = ""; }; @@ -3825,6 +3871,14 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C79470020000000000000002 /* TerminalController+SocketListenerRearm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+SocketListenerRearm.swift"; sourceTree = ""; }; D6212D0C00000000000000D4 /* TerminalController+WindowDockBrowserRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+WindowDockBrowserRouting.swift"; sourceTree = ""; }; C0DE00000000000000000C83 /* TerminalController+WorkspaceCreate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+WorkspaceCreate.swift"; sourceTree = ""; }; + D1A70000000000000000000B /* TerminalController+WorkspaceCreateExecutionPreparation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+WorkspaceCreateExecutionPreparation.swift"; sourceTree = ""; }; + C0DE00000000000000000E90 /* TerminalController+WorkspaceCreateIdempotencyCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+WorkspaceCreateIdempotencyCache.swift"; sourceTree = ""; }; + C0DE00000000000000000EA4 /* TerminalController+WorkspaceCreateIdempotencyDefaultsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+WorkspaceCreateIdempotencyDefaultsStore.swift"; sourceTree = ""; }; + C0DE00000000000000000EA2 /* TerminalController+WorkspaceCreateIdempotencyFileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+WorkspaceCreateIdempotencyFileStore.swift"; sourceTree = ""; }; + C0DE00000000000000000EA0 /* TerminalController+WorkspaceCreateIdempotencyPersisting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+WorkspaceCreateIdempotencyPersisting.swift"; sourceTree = ""; }; + C0DE00000000000000000D35 /* TerminalController+WorkspaceCreatePreparation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+WorkspaceCreatePreparation.swift"; sourceTree = ""; }; + C0DE00000000000000000D31 /* TerminalController+WorkspaceCreateWorkingDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+WorkspaceCreateWorkingDirectory.swift"; sourceTree = ""; }; + C0DE00000000000000000E92 /* TerminalController+WorkspaceCreateWorkingDirectoryValidationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+WorkspaceCreateWorkingDirectoryValidationService.swift"; sourceTree = ""; }; C0DE00000000000000000C8A /* TerminalController+WorkspaceGroupAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+WorkspaceGroupAction.swift"; sourceTree = ""; }; C0DE00000000000000000C88 /* TerminalController+WorkspaceMove.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+WorkspaceMove.swift"; sourceTree = ""; }; A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = ""; }; @@ -4046,6 +4100,12 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 5732B0045732B0045732B004 /* WorkspaceContentMinimalModeSafeAreaModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentMinimalModeSafeAreaModifier.swift; sourceTree = ""; }; A5001417 /* WorkspaceContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentView.swift; sourceTree = ""; }; F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = ""; }; + D1A700000000000000000007 /* WorkspaceCreateIdempotencyRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceCreateIdempotencyRecoveryTests.swift; sourceTree = ""; }; + C0DE00000000000000000D37 /* WorkspaceCreateIdempotencyTombstoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceCreateIdempotencyTombstoneTests.swift; sourceTree = ""; }; + D1A700000000000000000009 /* WorkspaceCreateReviewRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceCreateReviewRegressionTests.swift; sourceTree = ""; }; + C0DE00000000000000000D39 /* WorkspaceCreateWorkingDirectoryAliasTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceCreateWorkingDirectoryAliasTests.swift; sourceTree = ""; }; + C0DE71450000000000000002 /* WorkspaceCreateWorkingDirectoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceCreateWorkingDirectoryTests.swift; sourceTree = ""; }; + C0DE00000000000000000D33 /* WorkspaceCreateWorkingDirectoryValidationServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceCreateWorkingDirectoryValidationServiceTests.swift; sourceTree = ""; }; CE74DFAC5946F9C2EEDBE577 /* WorkspaceCustomSidebarPullRequestContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceCustomSidebarPullRequestContextTests.swift; sourceTree = ""; }; E6FA9085A1B2C3D4E5F60718 /* WorkspaceDescriptionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceDescriptionUITests.swift; sourceTree = ""; }; B561A95C6DC21BA9C4B2BCDD /* WorkspaceEnvironmentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceEnvironmentTests.swift; sourceTree = ""; }; @@ -4359,6 +4419,15 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C7A50B000000000000000015 /* MobileHostBuildIdentity.swift */, 4C1A7E10B2D34F56A8C90012 /* MobileHostStatusVerificationLimiter.swift */, C7A50B000000000000000013 /* MobileHostService+Capabilities.swift */, + D1B800000000000000000011 /* MobileTaskDirectoryListItem.swift */, + D1B800000000000000000012 /* MobileTaskDirectoryListPage.swift */, + D1B800000000000000000013 /* MobileTaskDirectoryListService.swift */, + D1B800000000000000000014 /* MobileTaskDirectoryListServiceError.swift */, + D1C900000000000000000011 /* MobileTaskDirectoryMetadataQueryRunner.swift */, + D1C900000000000000000012 /* MobileTaskDirectorySearchResult.swift */, + C248FC7D0000000000000001 /* MobileTaskDirectorySearchService+FuzzyMatching.swift */, + D1A700000000000000000004 /* MobileTaskDirectorySearchService.swift */, + F5A700000000000000000012 /* MobileTaskFilesystemJobQuota.swift */, DE71CE000000000000000007 /* MobileHostNetworkPathMonitor.swift */, 7A956C76162B79EC74AF9965 /* MobileRouteResolver.swift */, 04CE82AE8B39EA0C1395F696 /* MobilePairingModel.swift */, @@ -4767,6 +4836,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 7136F0017136F0017136F001 /* TabManager+BackgroundWorkspaceMountPolicy.swift */, C0DE1A030000000000000002 /* TabManager+SavedLayouts.swift */, C548600B0000000000000002 /* TabManager+SessionRestoreIdentity.swift */, + A905DFE00000000000000002 /* TabManager+SessionRestorePolicy.swift */, DCDC1000000000000000B016 /* TabManager+BrowserFocus.swift */, C5B6A10000000000000000B2 /* TabManager+SidebarGitHosting.swift */, 736200000000000000000003 /* TabManager+WorkspaceCustomTitle.swift */, @@ -4967,6 +5037,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 7371B0010000000000000002 /* TerminalController+ControlWorkspaceStrings.swift */, D7AB00000000000000B000 /* TerminalController+MobileNotificationSync.swift */, D7AB00000000000000B010 /* TerminalController+MobileScrollPrefetch.swift */, + D1B800000000000000000016 /* TerminalController+MobileDirectoryList.swift */, + D1A700000000000000000005 /* TerminalController+MobileDirectorySearch.swift */, C0DE00000000000000000C81 /* TerminalController+ControlBrowserPanelContext.swift */, C0DE00000000000000000C6D /* TerminalController+ControlDebugContext.swift */, C0DEBEEF0000000000000001 /* TerminalController+DebugMethodNames.swift */, @@ -4991,6 +5063,14 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 466DEB47603469A59697D418 /* TerminalController+ControlWorkspaceTodoContext.swift */, C0DE00000000000000000D83 /* TerminalController+ControlPaneDock.swift */, C0DE00000000000000000C83 /* TerminalController+WorkspaceCreate.swift */, + D1A70000000000000000000B /* TerminalController+WorkspaceCreateExecutionPreparation.swift */, + C0DE00000000000000000E90 /* TerminalController+WorkspaceCreateIdempotencyCache.swift */, + C0DE00000000000000000EA4 /* TerminalController+WorkspaceCreateIdempotencyDefaultsStore.swift */, + C0DE00000000000000000EA2 /* TerminalController+WorkspaceCreateIdempotencyFileStore.swift */, + C0DE00000000000000000EA0 /* TerminalController+WorkspaceCreateIdempotencyPersisting.swift */, + C0DE00000000000000000D31 /* TerminalController+WorkspaceCreateWorkingDirectory.swift */, + C0DE00000000000000000E92 /* TerminalController+WorkspaceCreateWorkingDirectoryValidationService.swift */, + C0DE00000000000000000D35 /* TerminalController+WorkspaceCreatePreparation.swift */, C0DE00000000000000000C8A /* TerminalController+WorkspaceGroupAction.swift */, C0DE00000000000000000C88 /* TerminalController+WorkspaceMove.swift */, C0DE00000000000000000C55 /* TerminalController+ControlPaneContext.swift */, @@ -5970,6 +6050,13 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F0ACC0DE0000000000000006 /* WorkspaceForkConversationContextMenuTests.swift */, 71F8ED91A4B55D34BE6A0668 /* WorkspaceUnitTests.swift */, CE74DFAC5946F9C2EEDBE577 /* WorkspaceCustomSidebarPullRequestContextTests.swift */, + C0DE71450000000000000002 /* WorkspaceCreateWorkingDirectoryTests.swift */, + C0DE71450000000000000004 /* InMemoryWorkspaceCreateIdempotencyStore.swift */, + C0DE00000000000000000D39 /* WorkspaceCreateWorkingDirectoryAliasTests.swift */, + C0DE00000000000000000D33 /* WorkspaceCreateWorkingDirectoryValidationServiceTests.swift */, + C0DE00000000000000000D37 /* WorkspaceCreateIdempotencyTombstoneTests.swift */, + D1A700000000000000000009 /* WorkspaceCreateReviewRegressionTests.swift */, + D1A700000000000000000007 /* WorkspaceCreateIdempotencyRecoveryTests.swift */, 71F8ED92A4B55D34BE6A0668 /* WorkspaceSplitStartupCommandTests.swift */, 6342F0C20000000000000001 /* WorkspaceTerminalFocusRecoveryTests.swift */, 6047C0DE6047C0DE60470002 /* WorkspaceTerminalTabWorkingDirectoryTests.swift */, @@ -6279,6 +6366,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = FE002010 /* FileExplorerDoubleClickActionTests.swift */, FE002003 /* FileSearchRipgrepParserTests.swift */, 9BD519CFE74530A46DF0925D /* MobileHostAuthorizationTests.swift */, + D1B800000000000000000015 /* MobileTaskDirectoryListServiceTests.swift */, + D1A700000000000000000006 /* MobileTaskDirectorySearchServiceTests.swift */, + F5A700000000000000000011 /* MobileTaskFilesystemJobQuotaTests.swift */, 7A1B2C3D4E5F60718293A4B5 /* MobileHostTerminalThemeTests.swift */, C1A071000000000000000011 /* MobileHostIrohAdmissionTests.swift */, C1A071000000000000000012 /* MobileHostConnectionEventLaneTests.swift */, @@ -7394,6 +7484,15 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A2538FE83AE79539E3BF9248 /* MobilePairingView.swift in Sources */, 18A8E29E9BD87E184983527C /* MobilePairingWindowController.swift in Sources */, 9916B44C7A9914E172D112D4 /* MobileRouteResolver.swift in Sources */, + D1B800000000000000000001 /* MobileTaskDirectoryListItem.swift in Sources */, + D1B800000000000000000002 /* MobileTaskDirectoryListPage.swift in Sources */, + D1B800000000000000000003 /* MobileTaskDirectoryListService.swift in Sources */, + D1B800000000000000000004 /* MobileTaskDirectoryListServiceError.swift in Sources */, + D1C900000000000000000001 /* MobileTaskDirectoryMetadataQueryRunner.swift in Sources */, + D1C900000000000000000002 /* MobileTaskDirectorySearchResult.swift in Sources */, + C248FC7D0000000000000002 /* MobileTaskDirectorySearchService+FuzzyMatching.swift in Sources */, + D1A700000000000000000001 /* MobileTaskDirectorySearchService.swift in Sources */, + F5A700000000000000000002 /* MobileTaskFilesystemJobQuota.swift in Sources */, AB57F0C27BECE8EDFC6B2B97 /* MobileTerminalByteTee.swift in Sources */, C9CFB398DF94D6DDEAF42D67 /* MobileTerminalRenderObserver.swift in Sources */, F7A110000000000000000003 /* MobileTerminalThemeEmissionDecision.swift in Sources */, @@ -7787,6 +7886,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = E3B7A4000000000000000009 /* TabManager+NotificationDismissalHosting.swift in Sources */, C0DE1A030000000000000001 /* TabManager+SavedLayouts.swift in Sources */, C548600B0000000000000001 /* TabManager+SessionRestoreIdentity.swift in Sources */, + A905DFE00000000000000001 /* TabManager+SessionRestorePolicy.swift in Sources */, C5B6A10000000000000000B1 /* TabManager+SidebarGitHosting.swift in Sources */, 604500100000000000000007 /* TabManager+WindowTitle.swift in Sources */, 736200000000000000000004 /* TabManager+WorkspaceCustomTitle.swift in Sources */, @@ -7834,6 +7934,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A7C0F0020000000000000002 /* TerminalController+MobileAttachTicket.swift in Sources */, ACA7C4A7000000000000000D /* TerminalController+MobileChat.swift in Sources */, C7AF10000000000000000003 /* TerminalController+MobileChatArtifacts.swift in Sources */, + D1B800000000000000000006 /* TerminalController+MobileDirectoryList.swift in Sources */, + D1A700000000000000000002 /* TerminalController+MobileDirectorySearch.swift in Sources */, D7AB00000000000000B001 /* TerminalController+MobileNotificationSync.swift in Sources */, D7AB00000000000000B011 /* TerminalController+MobileScrollPrefetch.swift in Sources */, C7AF20000000000000000001 /* TerminalController+MobileTerminalArtifacts.swift in Sources */, @@ -7849,6 +7951,14 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C79470020000000000000001 /* TerminalController+SocketListenerRearm.swift in Sources */, D6212D0C00000000000000D3 /* TerminalController+WindowDockBrowserRouting.swift in Sources */, C0DE00000000000000000C84 /* TerminalController+WorkspaceCreate.swift in Sources */, + D1A70000000000000000000C /* TerminalController+WorkspaceCreateExecutionPreparation.swift in Sources */, + C0DE00000000000000000E91 /* TerminalController+WorkspaceCreateIdempotencyCache.swift in Sources */, + C0DE00000000000000000EA5 /* TerminalController+WorkspaceCreateIdempotencyDefaultsStore.swift in Sources */, + C0DE00000000000000000EA3 /* TerminalController+WorkspaceCreateIdempotencyFileStore.swift in Sources */, + C0DE00000000000000000EA1 /* TerminalController+WorkspaceCreateIdempotencyPersisting.swift in Sources */, + C0DE00000000000000000D36 /* TerminalController+WorkspaceCreatePreparation.swift in Sources */, + C0DE00000000000000000D32 /* TerminalController+WorkspaceCreateWorkingDirectory.swift in Sources */, + C0DE00000000000000000E93 /* TerminalController+WorkspaceCreateWorkingDirectoryValidationService.swift in Sources */, C0DE00000000000000000C8B /* TerminalController+WorkspaceGroupAction.swift in Sources */, C0DE00000000000000000C89 /* TerminalController+WorkspaceMove.swift in Sources */, A5001007 /* TerminalController.swift in Sources */, @@ -8469,6 +8579,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B1F0C0040000000000000001 /* HostedInspectorDockControlScriptTests.swift in Sources */, 809100000000000000000001 /* HostSettingsShortcutNotificationTests.swift in Sources */, F1C1AA21B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift in Sources */, + C0DE71450000000000000003 /* InMemoryWorkspaceCreateIdempotencyStore.swift in Sources */, C34670050000000000000001 /* KeyboardShortcutContextSwiftTests.swift in Sources */, C34670020000000000000001 /* KeyboardShortcutContextTests.swift in Sources */, 6042A0026042A0026042A002 /* KeyboardShortcutModifierHoldHintsSettingsFileTests.swift in Sources */, @@ -8504,6 +8615,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 7A1B2C3D4E5F60718293A4B6 /* MobileHostTerminalThemeTests.swift in Sources */, C0DE73840000000000000001 /* MobileHostWorkspaceTicketAuthorizationTests.swift in Sources */, 9B08F916D7FF7626C6820727 /* MobilePairingConnectionTransitionTests.swift in Sources */, + D1B800000000000000000005 /* MobileTaskDirectoryListServiceTests.swift in Sources */, + D1A700000000000000000003 /* MobileTaskDirectorySearchServiceTests.swift in Sources */, + F5A700000000000000000001 /* MobileTaskFilesystemJobQuotaTests.swift in Sources */, F6448F377504F33956E7E03B /* MobileWorkspaceListFidelityTests.swift in Sources */, 13AB1A2E5DA6AD02DCAE971D /* MockBridgeInputCapture.swift in Sources */, 596100000000000000000001 /* NativeNotificationFallbackCommandTests.swift in Sources */, @@ -8734,6 +8848,12 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A11EAE000000000000000000 /* WorkspaceAppearanceConfigResolutionTests.swift in Sources */, D7AB3605C10DEF0000000001 /* WorkspaceCloseTabsContextMenuTests.swift in Sources */, F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */, + D1A700000000000000000008 /* WorkspaceCreateIdempotencyRecoveryTests.swift in Sources */, + C0DE00000000000000000D38 /* WorkspaceCreateIdempotencyTombstoneTests.swift in Sources */, + D1A70000000000000000000A /* WorkspaceCreateReviewRegressionTests.swift in Sources */, + C0DE00000000000000000D3A /* WorkspaceCreateWorkingDirectoryAliasTests.swift in Sources */, + C0DE71450000000000000001 /* WorkspaceCreateWorkingDirectoryTests.swift in Sources */, + C0DE00000000000000000D34 /* WorkspaceCreateWorkingDirectoryValidationServiceTests.swift in Sources */, 281F3AEA52379AF45C5C1189 /* WorkspaceCustomSidebarPullRequestContextTests.swift in Sources */, 72DB1838A0F1B710BFB45DC1 /* WorkspaceEnvironmentTests.swift in Sources */, F0ACC0DE0000000000000005 /* WorkspaceForkConversationContextMenuTests.swift in Sources */, diff --git a/cmuxTests/InMemoryWorkspaceCreateIdempotencyStore.swift b/cmuxTests/InMemoryWorkspaceCreateIdempotencyStore.swift new file mode 100644 index 000000000000..72a4a74b8fa5 --- /dev/null +++ b/cmuxTests/InMemoryWorkspaceCreateIdempotencyStore.swift @@ -0,0 +1,18 @@ +import Foundation + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class InMemoryWorkspaceCreateIdempotencyStore: + TerminalController.WorkspaceCreateIdempotencyPersisting { + private var operationIDs: [UUID] = [] + + func loadOperationIDs() -> [UUID] { operationIDs } + + func saveOperationIDs(_ operationIDs: [UUID]) { + self.operationIDs = operationIDs + } +} diff --git a/cmuxTests/MobileHostAuthorizationTests.swift b/cmuxTests/MobileHostAuthorizationTests.swift index 4a4b46f875e2..a4ee4a892a84 100644 --- a/cmuxTests/MobileHostAuthorizationTests.swift +++ b/cmuxTests/MobileHostAuthorizationTests.swift @@ -396,6 +396,24 @@ struct MobileHostAuthorizationTests { let error = MobileHostService.ticketAuthorizationError(ticket: ticket, request: request) #expect(error == nil) } + @Test func testAttachTicketAcceptsMacDirectoryListForPairedDevice() throws { + let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: "terminal") + let request = MobileHostRPCRequest( + id: "directory-list", + method: "mobile.directory.list", + params: [ + "path": "~", + "offset": 0, + "limit": 50, + ], + auth: MobileHostRPCAuth( + attachToken: ticket.authToken, + stackAccessToken: nil + ) + ) + let error = MobileHostService.ticketAuthorizationError(ticket: ticket, request: request) + #expect(error == nil) + } @Test func testTerminalScopedAttachTicketAcceptsScopedWorkspaceList() throws { let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: "terminal") let request = MobileHostRPCRequest( diff --git a/cmuxTests/MobileHostConnectionLifecycleTests.swift b/cmuxTests/MobileHostConnectionLifecycleTests.swift index 20b309b76edc..8b675a8b6dcc 100644 --- a/cmuxTests/MobileHostConnectionLifecycleTests.swift +++ b/cmuxTests/MobileHostConnectionLifecycleTests.swift @@ -357,7 +357,7 @@ extension MobileHostAuthorizationTests { #expect(capabilities.contains("workspace.close.v1")) #expect(capabilities.contains("workspace.move.v1")) #expect(capabilities.contains("workspace.group_actions.v1")) - #expect(capabilities.contains("terminal.render_grid.v1")) + #expect(Set(capabilities).isSuperset(of: ["workspace.task_create.v1", "terminal.render_grid.v1"])) } // MARK: - Mobile workspace.action sub-action gate @Test func testMobileWorkspaceActionGateAllowsOnlyPinNameAndReadStateActions() { diff --git a/cmuxTests/MobileTaskDirectoryListServiceTests.swift b/cmuxTests/MobileTaskDirectoryListServiceTests.swift new file mode 100644 index 000000000000..bf0db995c9d8 --- /dev/null +++ b/cmuxTests/MobileTaskDirectoryListServiceTests.swift @@ -0,0 +1,141 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite struct MobileTaskDirectoryListServiceTests { + @Test func advertisesAndDispatchesDirectoryBrowsing() async { + #expect(MobileHostService.mobileHostCapabilities.contains("workspace.directory_browse.v1")) + + let request = MobileHostRPCRequest( + id: "directory-list", + method: "mobile.directory.list", + params: [ + "path": "relative", + "offset": 0, + "limit": 50, + ], + auth: nil + ) + let result = await TerminalController.shared.mobileHostHandleRPC(request) + guard case let .failure(error) = result else { + return #expect(Bool(false), "A relative directory path must be rejected") + } + #expect(error.code == "invalid_params") + } + + @Test func includesHiddenPackagesAndDirectorySymlinksWithExactPagination() async throws { + let fixture = try Self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.base) } + let service = MobileTaskDirectoryListService( + fileManager: .default, + homeDirectory: fixture.root + ) + + let first = try await service.list(path: "~", offset: 0, limit: 2) + + #expect(first.currentPath == fixture.root.standardizedFileURL.path) + #expect(first.parentPath == fixture.base.standardizedFileURL.path) + #expect(first.entries.map(\.name) == [".hidden", "Bundle.app"]) + #expect(first.entries[0].isHidden) + #expect(first.entries[1].isPackage) + #expect(first.totalCount == 4) + #expect(first.nextOffset == 2) + + let second = try await service.list(path: "~", offset: 2, limit: 2) + + #expect(second.entries.map(\.name) == ["Zebra", "linked"]) + #expect(second.entries.last?.isSymbolicLink == true) + #expect(second.nextOffset == nil) + } + + @Test func clampsStaleOffsetToExactEndWithoutLosingPaginationMetadata() async throws { + let fixture = try Self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.base) } + let service = MobileTaskDirectoryListService( + fileManager: .default, + homeDirectory: fixture.root + ) + + let page = try await service.list(path: fixture.root.path, offset: 99, limit: 10) + + #expect(page.offset == page.totalCount) + #expect(page.entries.isEmpty) + #expect(page.nextOffset == nil) + } + + @Test func distinguishesMissingPathsFromFiles() async throws { + let fixture = try Self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.base) } + let service = MobileTaskDirectoryListService( + fileManager: .default, + homeDirectory: fixture.root + ) + + await #expect(throws: MobileTaskDirectoryListServiceError.notFound) { + try await service.list( + path: fixture.root.appendingPathComponent("missing").path, + offset: 0, + limit: 10 + ) + } + await #expect(throws: MobileTaskDirectoryListServiceError.notDirectory) { + try await service.list(path: fixture.file.path, offset: 0, limit: 10) + } + } + + @Test func surfacesUnreadableDirectoryInsteadOfReturningAnEmptyPage() async throws { + let fixture = try Self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.base) } + let locked = fixture.root.appendingPathComponent("locked", isDirectory: true) + try FileManager.default.createDirectory(at: locked, withIntermediateDirectories: true) + try FileManager.default.setAttributes([.posixPermissions: 0], ofItemAtPath: locked.path) + defer { + try? FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: locked.path + ) + } + let service = MobileTaskDirectoryListService( + fileManager: .default, + homeDirectory: fixture.root + ) + + do { + _ = try await service.list(path: locked.path, offset: 0, limit: 10) + #expect(Bool(false), "An unreadable directory must not look empty") + } catch let error as MobileTaskDirectoryListServiceError { + #expect(error == .unreadable || error == .permissionDenied) + } + } + + private static func makeFixture() throws -> ( + base: URL, + root: URL, + file: URL + ) { + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-directory-list-\(UUID().uuidString)", isDirectory: true) + let root = base.appendingPathComponent("home", isDirectory: true) + let external = base.appendingPathComponent("external", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: external, withIntermediateDirectories: true) + for name in [".hidden", "Bundle.app", "Zebra"] { + try FileManager.default.createDirectory( + at: root.appendingPathComponent(name, isDirectory: true), + withIntermediateDirectories: true + ) + } + try FileManager.default.createSymbolicLink( + at: root.appendingPathComponent("linked", isDirectory: true), + withDestinationURL: external + ) + let file = root.appendingPathComponent("plain.txt", isDirectory: false) + try Data("not a directory".utf8).write(to: file) + return (base, root, file) + } +} diff --git a/cmuxTests/MobileTaskDirectorySearchServiceTests.swift b/cmuxTests/MobileTaskDirectorySearchServiceTests.swift new file mode 100644 index 000000000000..aab60e93adf1 --- /dev/null +++ b/cmuxTests/MobileTaskDirectorySearchServiceTests.swift @@ -0,0 +1,181 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite struct MobileTaskDirectorySearchServiceTests { + @Test func dispatchRejectsAnEmptyDirectoryQuery() async { + #expect(MobileHostService.mobileHostCapabilities.contains("workspace.directory_search.v1")) + #expect(MobileHostService.mobileHostCapabilities.contains("workspace.directory_search.v2")) + + let request = MobileHostRPCRequest( + id: "directory-search", + method: "mobile.directory.search", + params: ["query": ""], + auth: nil + ) + + let result = await TerminalController.shared.mobileHostHandleRPC(request) + + guard case let .failure(error) = result else { + return #expect(Bool(false), "An empty directory query must be rejected") + } + #expect(error.code == "invalid_params") + } + + @Test func ranksStrictAndComponentMatchesBeforeLenientMatches() { + let paths = [ + "/Users/test/Dev/Manaflow/cmuxterm-hq", + "/Users/test/Dev/Manaflow/cmixterm-hq", + "/Users/test/Documents/cmux-notes", + ] + + let strict = MobileTaskDirectorySearchService.rank(paths: paths, query: "cmux", limit: 8) + #expect(strict.first == "/Users/test/Documents/cmux-notes") + #expect(strict.contains("/Users/test/Dev/Manaflow/cmuxterm-hq")) + + let components = MobileTaskDirectorySearchService.rank(paths: paths, query: "mana cmu", limit: 8) + #expect(components == ["/Users/test/Dev/Manaflow/cmuxterm-hq"]) + + let fuzzy = MobileTaskDirectorySearchService.rank(paths: paths, query: "manaflw", limit: 8) + #expect(fuzzy.prefix(2).contains("/Users/test/Dev/Manaflow/cmuxterm-hq")) + } + + @MainActor + @Test func metadataPredicateMatchesDirectoryTypeTreesAcrossPathTokens() { + let predicate = MobileTaskDirectoryMetadataQueryRunner.makePredicate(query: "Manaflow cmux") + + #expect(predicate.evaluate(with: [ + NSMetadataItemContentTypeTreeKey: ["public.item", "public.directory"], + NSMetadataItemFSNameKey: "cmuxterm-hq", + NSMetadataItemPathKey: "/Users/test/Dev/Manaflow/cmuxterm-hq", + ])) + #expect(!predicate.evaluate(with: [ + NSMetadataItemContentTypeTreeKey: ["public.item", "public.data"], + NSMetadataItemFSNameKey: "cmuxterm-hq", + NSMetadataItemPathKey: "/Users/test/Dev/Manaflow/cmuxterm-hq", + ])) + } + + @Test func mergesContextualAndIndexedMatchesWithoutEarlyReturningOneExactPath() async throws { + let service = MobileTaskDirectorySearchService( + homeDirectory: URL(fileURLWithPath: "/Users/test", isDirectory: true), + metadataSearchOperation: { _, _, _ in + MobileTaskDirectoryMetadataQueryRunner.Snapshot( + paths: [ + "/Users/test/Dev/cmux", + "/Volumes/External/cmux", + ], + gatheringComplete: true, + totalMatchCount: 2, + truncated: false + ) + }, + directoryExists: { _ in true } + ) + + let result = try await service.search( + query: "cmux", + seedPaths: ["/Users/test/Open/cmux"] + ) + + #expect(Set(result.directories) == Set([ + "/Users/test/Open/cmux", + "/Users/test/Dev/cmux", + "/Volumes/External/cmux", + ])) + #expect(result.scope == .allIndexedVolumes) + #expect(result.gatheringComplete) + #expect(!result.filesystemComplete) + #expect(!result.truncated) + #expect(result.indexedMatchCount == 2) + } + + @Test func reportsPartialAndTruncatedIndexedCoverage() async throws { + let service = MobileTaskDirectorySearchService( + configuration: .init(maximumMetadataResults: 3, maximumWireResults: 2), + metadataSearchOperation: { _, _, _ in + MobileTaskDirectoryMetadataQueryRunner.Snapshot( + paths: [ + "/Volumes/A/project", + "/Volumes/B/project", + "/Volumes/C/project", + ], + gatheringComplete: false, + totalMatchCount: 100, + truncated: true + ) + }, + directoryExists: { _ in true } + ) + + let result = try await service.search(query: "project", seedPaths: []) + + #expect(result.directories.count == 2) + #expect(result.scope == .allIndexedVolumes) + #expect(!result.gatheringComplete) + #expect(!result.filesystemComplete) + #expect(result.truncated) + #expect(result.indexedMatchCount == 100) + } + + @Test func metadataUnavailabilityFallsBackWithExplicitContextOnlyCoverage() async throws { + let service = MobileTaskDirectorySearchService( + homeDirectory: URL(fileURLWithPath: "/Users/test", isDirectory: true), + metadataSearchOperation: { _, _, _ in + throw MobileTaskDirectoryMetadataQueryRunner.QueryError.unavailable + }, + directoryExists: { _ in true } + ) + + let result = try await service.search( + query: "project", + seedPaths: ["/Users/test/Open/project"] + ) + + #expect(result.directories == ["/Users/test/Open/project"]) + #expect(result.scope == .contextualCandidatesOnly) + #expect(!result.gatheringComplete) + #expect(!result.filesystemComplete) + #expect(!result.truncated) + #expect(result.indexedMatchCount == 0) + } + + @Test func concurrentClientsDoNotCancelEachOthersSearches() async { + let service = MobileTaskDirectorySearchService( + metadataSearchOperation: { query, _, _ in + await Task.yield() + return MobileTaskDirectoryMetadataQueryRunner.Snapshot( + paths: ["/Volumes/Projects/\(query)"], + gatheringComplete: true, + totalMatchCount: 1, + truncated: false + ) + }, + directoryExists: { _ in true } + ) + + let results = await withTaskGroup( + of: MobileTaskDirectorySearchResult?.self, + returning: [MobileTaskDirectorySearchResult?].self + ) { group in + for index in 0..<16 { + group.addTask { + try? await service.search(query: "project-\(index)", seedPaths: []) + } + } + var values: [MobileTaskDirectorySearchResult?] = [] + for await value in group { + values.append(value) + } + return values + } + + #expect(results.count == 16) + #expect(results.allSatisfy { $0?.directories.count == 1 }) + } +} diff --git a/cmuxTests/MobileTaskFilesystemJobQuotaTests.swift b/cmuxTests/MobileTaskFilesystemJobQuotaTests.swift new file mode 100644 index 000000000000..b27133a66af3 --- /dev/null +++ b/cmuxTests/MobileTaskFilesystemJobQuotaTests.swift @@ -0,0 +1,89 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite struct MobileTaskFilesystemJobQuotaTests { + @Test func capsConcurrentCallersAndReusesReleasedCapacity() async { + let quota = MobileTaskFilesystemJobQuota(maximumConcurrentJobs: 2) + + let admissions = await withTaskGroup( + of: Bool.self, + returning: [Bool].self + ) { group in + for _ in 0..<16 { + group.addTask { + quota.acquire() + } + } + var results: [Bool] = [] + for await result in group { + results.append(result) + } + return results + } + + let admittedCount = admissions.count(where: { $0 }) + #expect(admittedCount == 2) + for _ in 0.. String? { + guard case let .err(code, _, _) = result else { return nil } + return code + } +} diff --git a/cmuxTests/WorkspaceCreateIdempotencyRecoveryTests.swift b/cmuxTests/WorkspaceCreateIdempotencyRecoveryTests.swift new file mode 100644 index 000000000000..dc906f61197c --- /dev/null +++ b/cmuxTests/WorkspaceCreateIdempotencyRecoveryTests.swift @@ -0,0 +1,129 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite struct WorkspaceCreateIdempotencyRecoveryTests { + @Test func transientInitialLoadFailureRetriesBeforeAccepting() throws { + let operationID = UUID() + let persistence = TransientLoadFailurePersistence() + let cache = TerminalController.WorkspaceCreateIdempotencyCache( + capacity: 8, + persistence: persistence + ) + + try cache.accept(operationID: operationID) + + #expect(persistence.loadCount == 2) + #expect(persistence.savedOperationIDs == [operationID]) + #expect(cache.containsCompletedOperation(operationID)) + } + + @Test func asynchronousAcceptPersistsOffTheMainThread() async throws { + let persistence = TransientLoadFailurePersistence(failFirstLoad: false) + let cache = TerminalController.WorkspaceCreateIdempotencyCache( + capacity: 8, + persistence: persistence + ) + + #expect(try await cache.acceptAsynchronously(operationID: UUID())) + #expect(persistence.saveWasOnMainThread == false) + } + + @Test func restoredRecordDuringPendingAcceptanceIsMergedAndRepersisted() async throws { + let acceptedOperationID = UUID() + let restoredOperationID = UUID() + let workspaceID = UUID() + let persistence = BlockingFirstSavePersistence() + let cache = TerminalController.WorkspaceCreateIdempotencyCache( + capacity: 8, + persistence: persistence + ) + + let acceptance = Task { + try await cache.acceptAsynchronously(operationID: acceptedOperationID) + } + await persistence.waitForFirstSaveToStart() + cache.record(operationID: restoredOperationID, workspaceID: workspaceID) + persistence.releaseFirstSave() + + #expect(try await acceptance.value) + #expect(persistence.savedOperationIDs == [restoredOperationID, acceptedOperationID]) + #expect(cache.containsCompletedOperation(restoredOperationID)) + #expect(cache.containsCompletedOperation(acceptedOperationID)) + #expect(cache.workspaceID(for: restoredOperationID) == workspaceID) + } +} + +private final class TransientLoadFailurePersistence: + TerminalController.WorkspaceCreateIdempotencyPersisting, @unchecked Sendable +{ + private let failFirstLoad: Bool + private(set) var loadCount = 0 + private(set) var savedOperationIDs: [UUID] = [] + private(set) var saveWasOnMainThread: Bool? + + init(failFirstLoad: Bool = true) { + self.failFirstLoad = failFirstLoad + } + + func loadOperationIDs() throws -> [UUID] { + loadCount += 1 + if failFirstLoad, loadCount == 1 { throw TransientLoadFailure.injected } + return [] + } + + func saveOperationIDs(_ operationIDs: [UUID]) { + saveWasOnMainThread = Thread.isMainThread + savedOperationIDs = operationIDs + } +} + +private enum TransientLoadFailure: Error { + case injected +} + +private final class BlockingFirstSavePersistence: + TerminalController.WorkspaceCreateIdempotencyPersisting, @unchecked Sendable +{ + private let firstSaveStarted = DispatchSemaphore(value: 0) + private let releaseFirstSaveSemaphore = DispatchSemaphore(value: 0) + private let savedOperationIDsLock = NSLock() + private var storedOperationIDs: [UUID] = [] + private var saveCount = 0 + + var savedOperationIDs: [UUID] { + savedOperationIDsLock.withLock { storedOperationIDs } + } + + func loadOperationIDs() -> [UUID] { [] } + + func saveOperationIDs(_ operationIDs: [UUID]) { + let shouldBlock = savedOperationIDsLock.withLock { + saveCount += 1 + return saveCount == 1 + } + if shouldBlock { + firstSaveStarted.signal() + releaseFirstSaveSemaphore.wait() + } + savedOperationIDsLock.withLock { + storedOperationIDs = operationIDs + } + } + + func waitForFirstSaveToStart() async { + await Task.detached { [firstSaveStarted] in + firstSaveStarted.wait() + }.value + } + + func releaseFirstSave() { + releaseFirstSaveSemaphore.signal() + } +} diff --git a/cmuxTests/WorkspaceCreateIdempotencyTombstoneTests.swift b/cmuxTests/WorkspaceCreateIdempotencyTombstoneTests.swift new file mode 100644 index 000000000000..8c310399c956 --- /dev/null +++ b/cmuxTests/WorkspaceCreateIdempotencyTombstoneTests.swift @@ -0,0 +1,485 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite(.serialized) struct WorkspaceCreateIdempotencyTombstoneTests { + @Test func mobileRetryAfterClosedWorkspaceReturnsCompletedErrorWithoutCreatingOrLaunching() async throws { + let defaults = Self.makeDefaults() + defer { defaults.removePersistentDomain(forName: Self.defaultsSuiteName(defaults)) } + let cache = Self.cache(defaults: defaults) + let manager = TabManager() + let baselineIDs = Set(manager.tabs.map(\.id)) + let operationID = UUID() + + let initial = await TerminalController.shared.v2MobileWorkspaceCreate( + params: ["operation_id": operationID.uuidString], + tabManager: manager, + idempotencyCache: cache + ) + let createdID = try #require(UUID(uuidString: try Self.decode(initial).createdWorkspaceID ?? "")) + manager.closeWorkspace(try #require(manager.tabs.first { $0.id == createdID })) + + let retry = await TerminalController.shared.v2MobileWorkspaceCreate( + params: [ + "operation_id": operationID.uuidString, + "initial_command": "must-not-launch", + ], + tabManager: manager, + idempotencyCache: cache + ) + #expect(Self.errorCode(retry) == "already_completed") + #expect(Set(manager.tabs.map(\.id)) == baselineIDs) + #expect(Self.containsInitialCommand("must-not-launch", in: manager) == false) + } + + @Test func mobileRestartRetryWithoutWorkspaceDoesNotRerunAcceptedTask() async throws { + let defaults = Self.makeDefaults() + defer { defaults.removePersistentDomain(forName: Self.defaultsSuiteName(defaults)) } + let operationID = UUID() + let manager = TabManager() + let initialCache = Self.cache(defaults: defaults) + + let initial = await TerminalController.shared.v2MobileWorkspaceCreate( + params: ["operation_id": operationID.uuidString], + tabManager: manager, + idempotencyCache: initialCache + ) + let createdID = try #require(UUID(uuidString: try Self.decode(initial).createdWorkspaceID ?? "")) + manager.closeWorkspace(try #require(manager.tabs.first { $0.id == createdID })) + + let restoredCache = Self.cache(defaults: defaults) + let baselineIDs = Set(manager.tabs.map(\.id)) + let retry = await TerminalController.shared.v2MobileWorkspaceCreate( + params: [ + "operation_id": operationID.uuidString, + "initial_command": "must-not-launch-after-restart", + ], + tabManager: manager, + idempotencyCache: restoredCache + ) + + // A crash before the session snapshot can leave no workspace to recover. + // At-most-once startup work is stricter: the accepted operation stays complete. + #expect(Self.errorCode(retry) == "already_completed") + #expect(Set(manager.tabs.map(\.id)) == baselineIDs) + #expect(Self.containsInitialCommand("must-not-launch-after-restart", in: manager) == false) + } + + @Test func synchronousRestartRetryWithoutWorkspaceReturnsCompletedWithoutRerun() throws { + let defaults = Self.makeDefaults() + defer { defaults.removePersistentDomain(forName: Self.defaultsSuiteName(defaults)) } + let operationID = UUID() + let manager = TabManager() + + let initial = TerminalController.shared.v2WorkspaceCreate( + params: ["operation_id": operationID.uuidString], + tabManager: manager, + idempotencyCache: Self.cache(defaults: defaults) + ) + let createdID = try #require(Self.workspaceID(initial)) + manager.closeWorkspace(try #require(manager.tabs.first { $0.id == createdID })) + let baselineIDs = Set(manager.tabs.map(\.id)) + + let retry = TerminalController.shared.v2WorkspaceCreate( + params: [ + "operation_id": operationID.uuidString, + "initial_command": "must-not-launch-sync-after-restart", + ], + tabManager: manager, + idempotencyCache: Self.cache(defaults: defaults) + ) + + #expect(Self.errorCode(retry) == "already_completed") + #expect(Set(manager.tabs.map(\.id)) == baselineIDs) + #expect(Self.containsInitialCommand("must-not-launch-sync-after-restart", in: manager) == false) + } + + @Test func synchronousRetryAfterClosedWorkspaceReturnsStableCompletedError() throws { + let defaults = Self.makeDefaults() + defer { defaults.removePersistentDomain(forName: Self.defaultsSuiteName(defaults)) } + let cache = Self.cache(defaults: defaults) + let manager = TabManager() + let baselineIDs = Set(manager.tabs.map(\.id)) + let operationID = UUID() + + let initial = TerminalController.shared.v2WorkspaceCreate( + params: ["operation_id": operationID.uuidString], + tabManager: manager, + idempotencyCache: cache + ) + let createdID = try #require(Self.workspaceID(initial)) + manager.closeWorkspace(try #require(manager.tabs.first { $0.id == createdID })) + let retry = TerminalController.shared.v2WorkspaceCreate( + params: [ + "operation_id": operationID.uuidString, + "initial_command": "must-not-launch", + ], + tabManager: manager, + idempotencyCache: cache + ) + + #expect(Self.errorCode(retry) == "already_completed") + #expect(Set(manager.tabs.map(\.id)) == baselineIDs) + #expect(Self.containsInitialCommand("must-not-launch", in: manager) == false) + } + + @Test func acceptedOperationIsDurableBeforeWorkspaceConstruction() throws { + let suiteName = "WorkspaceCreateIdempotencyTombstoneTests.\(UUID().uuidString)" + let defaults = try #require(ObservingUserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let cache = Self.cache(defaults: defaults) + let manager = TabManager() + let baselineIDs = Set(manager.tabs.map(\.id)) + let operationID = UUID() + var persistedBeforeConstruction = false + var retryWasCompleted = false + defaults.onSet = { key in + guard key == "tests.completed" else { return } + defaults.onSet = nil + persistedBeforeConstruction = Set(manager.tabs.map(\.id)) == baselineIDs + && !Self.containsInitialCommand("launch-once", in: manager) + let retry = TerminalController.shared.v2WorkspaceCreate( + params: [ + "operation_id": operationID.uuidString, + "initial_command": "must-not-launch-after-reservation", + ], + tabManager: manager, + idempotencyCache: Self.cache(defaults: defaults) + ) + retryWasCompleted = Self.errorCode(retry) == "already_completed" + } + + _ = TerminalController.shared.v2WorkspaceCreate( + params: [ + "operation_id": operationID.uuidString, + "initial_command": "launch-once", + ], + tabManager: manager, + idempotencyCache: cache + ) + + #expect(persistedBeforeConstruction) + #expect(retryWasCompleted) + #expect(Self.containsInitialCommand("must-not-launch-after-reservation", in: manager) == false) + } + + @Test func persistenceFailureAbortsBeforeWorkspaceConstruction() throws { + let suiteName = "WorkspaceCreateIdempotencyTombstoneTests.\(UUID().uuidString)" + let defaults = try #require(DroppingUserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let manager = TabManager() + let baselineIDs = Set(manager.tabs.map(\.id)) + + let result = TerminalController.shared.v2WorkspaceCreate( + params: [ + "operation_id": UUID().uuidString, + "initial_command": "must-not-launch-without-tombstone", + ], + tabManager: manager, + idempotencyCache: Self.cache(defaults: defaults) + ) + + #expect(Self.errorCode(result) == "persistence_failed") + #expect(Set(manager.tabs.map(\.id)) == baselineIDs) + #expect(Self.containsInitialCommand("must-not-launch-without-tombstone", in: manager) == false) + } + + @Test func restoredLiveWorkspaceResolvesBeforeDurableTombstone() async throws { + let defaults = Self.makeDefaults() + defer { defaults.removePersistentDomain(forName: Self.defaultsSuiteName(defaults)) } + let operationID = UUID() + let sourceManager = TabManager() + let sourceCache = Self.cache(defaults: defaults) + + _ = await TerminalController.shared.v2MobileWorkspaceCreate( + params: ["operation_id": operationID.uuidString], + tabManager: sourceManager, + idempotencyCache: sourceCache + ) + let snapshot = sourceManager.sessionSnapshot(includeScrollback: false) + let restoredCache = Self.cache(defaults: defaults) + let restoredManager = TabManager() + restoredManager.restoreSessionSnapshot( + snapshot, + workspaceCreateIdempotencyCache: restoredCache + ) + let restoredWorkspace = try #require( + restoredManager.tabs.first { $0.taskCreateOperationID == operationID } + ) + let baselineIDs = Set(restoredManager.tabs.map(\.id)) + + let liveRetry = await TerminalController.shared.v2MobileWorkspaceCreate( + params: [ + "operation_id": operationID.uuidString, + "initial_command": "must-not-launch-over-restored-workspace", + ], + tabManager: restoredManager, + idempotencyCache: restoredCache + ) + + #expect(try Self.decode(liveRetry).createdWorkspaceID == restoredWorkspace.id.uuidString) + #expect(Set(restoredManager.tabs.map(\.id)) == baselineIDs) + #expect(Self.containsInitialCommand("must-not-launch-over-restored-workspace", in: restoredManager) == false) + + restoredManager.closeWorkspace(restoredWorkspace) + let postCloseIDs = Set(restoredManager.tabs.map(\.id)) + + let retry = await TerminalController.shared.v2MobileWorkspaceCreate( + params: [ + "operation_id": operationID.uuidString, + "initial_command": "must-not-launch-after-restore", + ], + tabManager: restoredManager, + idempotencyCache: restoredCache + ) + + #expect(Self.errorCode(retry) == "already_completed") + #expect(Set(restoredManager.tabs.map(\.id)) == postCloseIDs) + #expect(Self.containsInitialCommand("must-not-launch-after-restore", in: restoredManager) == false) + } + + @Test func tombstoneFIFOIsBoundedAndPersistsAcrossCacheInstances() { + let defaults = Self.makeDefaults() + defer { defaults.removePersistentDomain(forName: Self.defaultsSuiteName(defaults)) } + let key = "tests.completed.\(UUID().uuidString)" + let first = UUID() + let second = UUID() + let third = UUID() + let cache = TerminalController.WorkspaceCreateIdempotencyCache( + capacity: 2, + defaults: defaults, + persistenceKey: key + ) + + cache.record(operationID: first, workspaceID: UUID()) + cache.record(operationID: second, workspaceID: UUID()) + cache.record(operationID: third, workspaceID: UUID()) + + #expect(cache.containsCompletedOperation(first) == false) + #expect(cache.containsCompletedOperation(second)) + #expect(cache.containsCompletedOperation(third)) + let restored = TerminalController.WorkspaceCreateIdempotencyCache( + capacity: 2, + defaults: defaults, + persistenceKey: key + ) + #expect(restored.containsCompletedOperation(first) == false) + #expect(restored.containsCompletedOperation(second)) + #expect(restored.containsCompletedOperation(third)) + + let manager = TabManager() + let forgottenRetry = TerminalController.shared.v2WorkspaceCreate( + params: [ + "operation_id": first.uuidString, + "initial_command": "launch-after-bounded-eviction", + ], + tabManager: manager, + idempotencyCache: restored + ) + #expect(Self.workspaceID(forgottenRetry) != nil) + #expect(Self.containsInitialCommand("launch-after-bounded-eviction", in: manager)) + } + + @Test func fileStoreReloadsAcceptedTombstoneBeforeWorkspaceConstruction() throws { + let fixture = try Self.makeFileStore() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + let operationID = UUID() + try TerminalController.WorkspaceCreateIdempotencyCache( + capacity: 2, + persistence: fixture.store + ).accept(operationID: operationID) + + let reloaded = TerminalController.WorkspaceCreateIdempotencyCache( + capacity: 2, + persistence: TerminalController.WorkspaceCreateIdempotencyFileStore(fileURL: fixture.store.fileURL) + ) + #expect(reloaded.containsCompletedOperation(operationID)) + } + + @Test func corruptOrUnknownFileStoreFailsClosed() throws { + for data in [ + Data("{".utf8), + Data("{\"version\":99,\"operationIDs\":[]}".utf8), + ] { + let fixture = try Self.makeFileStore() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + try data.write(to: fixture.store.fileURL) + let cache = TerminalController.WorkspaceCreateIdempotencyCache( + capacity: 2, + persistence: fixture.store + ) + #expect(throws: (any Error).self) { + try cache.accept(operationID: UUID()) + } + } + } + + @Test func interruptedAtomicWritePreservesOldSnapshotAndCleansTemporaryFile() throws { + let fixture = try Self.makeFileStore() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + let original = UUID() + try fixture.store.saveOperationIDs([original]) + let failing = TerminalController.WorkspaceCreateIdempotencyFileStore( + fileURL: fixture.store.fileURL, + beforeRename: { throw TombstonePersistenceTestError.injectedFailure } + ) + + #expect(throws: (any Error).self) { + try failing.saveOperationIDs([UUID()]) + } + #expect(try fixture.store.loadOperationIDs() == [original]) + #expect(try FileManager.default.contentsOfDirectory(atPath: fixture.directory.path).count == 1) + } + + @Test func loadRemovesStaleTemporaryFile() throws { + let fixture = try Self.makeFileStore() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + let stale = fixture.directory.appendingPathComponent( + ".\(fixture.store.fileURL.lastPathComponent).stale.tmp" + ) + try Data("partial".utf8).write(to: stale) + + #expect(try fixture.store.loadOperationIDs().isEmpty) + #expect(FileManager.default.fileExists(atPath: stale.path) == false) + } + + @Test func legacyMigrationRetriesWithNextAcceptedOperation() throws { + let fixture = try Self.makeFileStore() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + let defaults = Self.makeDefaults() + defer { defaults.removePersistentDomain(forName: Self.defaultsSuiteName(defaults)) } + let key = "legacy.\(UUID().uuidString)" + let legacyID = UUID() + let nextID = UUID() + defaults.set([legacyID.uuidString], forKey: key) + var rejectWrite = true + let store = TerminalController.WorkspaceCreateIdempotencyFileStore( + fileURL: fixture.store.fileURL, + beforeRename: { + if rejectWrite { throw TombstonePersistenceTestError.injectedFailure } + } + ) + let cache = TerminalController.WorkspaceCreateIdempotencyCache( + capacity: 2, + persistence: store, + legacyDefaults: defaults, + legacyPersistenceKey: key + ) + #expect(cache.containsCompletedOperation(legacyID)) + #expect(defaults.stringArray(forKey: key) != nil) + + rejectWrite = false + try cache.accept(operationID: nextID) + #expect(try fixture.store.loadOperationIDs() == [legacyID, nextID]) + #expect(defaults.stringArray(forKey: key) == nil) + } + + @Test func fileStoreFIFOIsBoundedAndDeduplicated() throws { + let fixture = try Self.makeFileStore() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + let ids = [UUID(), UUID(), UUID()] + let cache = TerminalController.WorkspaceCreateIdempotencyCache( + capacity: 2, + persistence: fixture.store + ) + try cache.accept(operationID: ids[0]) + try cache.accept(operationID: ids[1]) + try cache.accept(operationID: ids[1]) + try cache.accept(operationID: ids[2]) + + #expect(try fixture.store.loadOperationIDs() == [ids[1], ids[2]]) + } + + private static func makeDefaults() -> UserDefaults { + UserDefaults(suiteName: "WorkspaceCreateIdempotencyTombstoneTests.\(UUID().uuidString)")! + } + + private static func makeFileStore() throws -> ( + directory: URL, + store: TerminalController.WorkspaceCreateIdempotencyFileStore + ) { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-tombstone-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return ( + directory, + TerminalController.WorkspaceCreateIdempotencyFileStore( + fileURL: directory.appendingPathComponent("tombstones.json") + ) + ) + } + + private static func cache(defaults: UserDefaults) -> TerminalController.WorkspaceCreateIdempotencyCache { + TerminalController.WorkspaceCreateIdempotencyCache( + capacity: 256, + defaults: defaults, + persistenceKey: "tests.completed" + ) + } + + private static func defaultsSuiteName(_ defaults: UserDefaults) -> String { + defaults.volatileDomainNames.first { $0.hasPrefix("WorkspaceCreateIdempotencyTombstoneTests.") } ?? "" + } + + private static func containsInitialCommand(_ command: String, in manager: TabManager) -> Bool { + manager.tabs.contains { workspace in + workspace.panels.values.compactMap { $0 as? TerminalPanel } + .contains { $0.surface.debugInitialCommand() == command } + } + } + + private static func workspaceID(_ result: TerminalController.V2CallResult) -> UUID? { + guard case let .ok(rawPayload) = result, + let payload = rawPayload as? [String: Any], + let rawID = payload["workspace_id"] as? String else { return nil } + return UUID(uuidString: rawID) + } + + private static func errorCode(_ result: TerminalController.V2CallResult) -> String? { + guard case let .err(code, _, _) = result else { return nil } + return code + } + + private static func decode(_ result: TerminalController.V2CallResult) throws -> TombstoneWorkspaceList { + guard case let .ok(payload) = result else { throw TombstoneDecodeError.notSuccess } + return try JSONDecoder().decode( + TombstoneWorkspaceList.self, + from: JSONSerialization.data(withJSONObject: payload) + ) + } +} + +private struct TombstoneWorkspaceList: Decodable { + let createdWorkspaceID: String? + + private enum CodingKeys: String, CodingKey { + case createdWorkspaceID = "created_workspace_id" + } +} + +private enum TombstoneDecodeError: Error { + case notSuccess +} + +private enum TombstonePersistenceTestError: Error { + case injectedFailure +} + +private final class ObservingUserDefaults: UserDefaults, @unchecked Sendable { + var onSet: ((String) -> Void)? + + override func set(_ value: Any?, forKey defaultName: String) { + super.set(value, forKey: defaultName) + onSet?(defaultName) + } +} + +private final class DroppingUserDefaults: UserDefaults, @unchecked Sendable { + override func set(_ value: Any?, forKey defaultName: String) {} +} diff --git a/cmuxTests/WorkspaceCreateReviewRegressionTests.swift b/cmuxTests/WorkspaceCreateReviewRegressionTests.swift new file mode 100644 index 000000000000..5feb6d789262 --- /dev/null +++ b/cmuxTests/WorkspaceCreateReviewRegressionTests.swift @@ -0,0 +1,176 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite(.serialized) struct WorkspaceCreateReviewRegressionTests { + @Test func oversizedWorkingDirectoryIsRejectedBeforeClassification() async { + let classifierCalls = LockedInvocationCount() + let service = TerminalController.WorkspaceCreateWorkingDirectoryValidationService( + timeout: .seconds(1), + localCapacity: 1, + externalCapacity: 1, + maximumPendingWaiters: 4, + laneClassifier: { _ in + classifierCalls.increment() + return .local + }, + probe: { _, _ in true }, + sleepUntilDeadline: { _ in } + ) + + let result = await service.validate( + rawValue: "/" + String(repeating: "a", count: 4_097), + isProvided: true + ) + + #expect(result == .invalid) + #expect(classifierCalls.value == 0) + } + + @Test func blockedClassificationTimesOutWithoutBlockingServiceActor() async { + let classifier = ControlledPathClassifier() + let deadlines = ControlledReviewDeadlines() + let service = TerminalController.WorkspaceCreateWorkingDirectoryValidationService( + timeout: .seconds(1), + localCapacity: 1, + externalCapacity: 1, + classificationCapacity: 1, + maximumPendingWaiters: 4, + laneClassifier: { path in await classifier.classify(path) }, + probe: { _, _ in true }, + sleepUntilDeadline: { _ in await deadlines.suspendUntilFired() } + ) + let first = Task { await service.validate(rawValue: "/tmp/first", isProvided: true) } + await classifier.waitForCount(1) + await deadlines.waitForCount(1) + await deadlines.fireAll() + #expect(await first.value == .timedOut) + + let second = Task { await service.validate(rawValue: "/tmp/second", isProvided: true) } + await deadlines.waitForCount(2) + await deadlines.fireAll() + #expect(await second.value == .timedOut) + #expect(await classifier.count == 1) + await classifier.complete() + } + + @Test func invalidMobileRequestDoesNotConsumeOperationID() async { + let suiteName = "WorkspaceCreateReviewRegressionTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let cache = TerminalController.WorkspaceCreateIdempotencyCache( + capacity: 16, + defaults: defaults, + persistenceKey: "completed" + ) + let manager = TabManager() + let baselineCount = manager.tabs.count + let operationID = UUID() + + let invalid = await TerminalController.shared.v2MobileWorkspaceCreate( + params: [ + "operation_id": operationID.uuidString, + "layout": Date(), + ], + tabManager: manager, + idempotencyCache: cache + ) + + #expect(Self.errorCode(invalid) == "invalid_params") + #expect(cache.containsCompletedOperation(operationID) == false) + #expect(manager.tabs.count == baselineCount) + + let retry = await TerminalController.shared.v2MobileWorkspaceCreate( + params: ["operation_id": operationID.uuidString], + tabManager: manager, + idempotencyCache: cache + ) + + #expect(Self.errorCode(retry) == nil) + #expect(cache.containsCompletedOperation(operationID)) + #expect(manager.tabs.count == baselineCount + 1) + } + + private static func errorCode(_ result: TerminalController.V2CallResult) -> String? { + guard case let .err(code, _, _) = result else { return nil } + return code + } +} + +private actor ControlledPathClassifier { + private(set) var count = 0 + private var continuation: CheckedContinuation< + TerminalController.WorkspaceCreateWorkingDirectoryValidationService.ProbeLane, + Never + >? + private var countWaiters: [(Int, CheckedContinuation)] = [] + + func classify( + _ path: String + ) async -> TerminalController.WorkspaceCreateWorkingDirectoryValidationService.ProbeLane { + _ = path + count += 1 + let ready = countWaiters.filter { count >= $0.0 } + countWaiters.removeAll { count >= $0.0 } + for waiter in ready { waiter.1.resume() } + return await withCheckedContinuation { continuation = $0 } + } + + func waitForCount(_ expected: Int) async { + if count >= expected { return } + await withCheckedContinuation { countWaiters.append((expected, $0)) } + } + + func complete() { + continuation?.resume(returning: .local) + continuation = nil + } +} + +private actor ControlledReviewDeadlines { + private var count = 0 + private var continuations: [CheckedContinuation] = [] + private var countWaiters: [(Int, CheckedContinuation)] = [] + + func suspendUntilFired() async { + count += 1 + let ready = countWaiters.filter { count >= $0.0 } + countWaiters.removeAll { count >= $0.0 } + for waiter in ready { waiter.1.resume() } + await withCheckedContinuation { continuations.append($0) } + } + + func waitForCount(_ expected: Int) async { + if count >= expected { return } + await withCheckedContinuation { countWaiters.append((expected, $0)) } + } + + func fireAll() { + let pending = continuations + continuations = [] + for continuation in pending { continuation.resume() } + } +} + +private final class LockedInvocationCount: @unchecked Sendable { + private let lock = NSLock() + private var storage = 0 + + var value: Int { + lock.lock() + defer { lock.unlock() } + return storage + } + + func increment() { + lock.lock() + storage += 1 + lock.unlock() + } +} diff --git a/cmuxTests/WorkspaceCreateWorkingDirectoryAliasTests.swift b/cmuxTests/WorkspaceCreateWorkingDirectoryAliasTests.swift new file mode 100644 index 000000000000..c416699e32c5 --- /dev/null +++ b/cmuxTests/WorkspaceCreateWorkingDirectoryAliasTests.swift @@ -0,0 +1,278 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite(.serialized) struct WorkspaceCreateWorkingDirectoryAliasTests { + @Test func missingDotDotExistingPathIsRejectedBeforeProbe() async throws { + let root = Self.nonSymlinkedTemporaryDirectory + .appendingPathComponent("cmux-dot-component-\(UUID().uuidString)", isDirectory: true) + let existing = root.appendingPathComponent("existing", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: existing, withIntermediateDirectories: true) + let requestedPath = root.path + "/missing/../existing" + let probe = ImmediateAliasDirectoryProbe() + let deadlines = ControlledAliasValidationDeadlines() + let service = Self.productionClassifierService(probe: probe, deadlines: deadlines) + + let result = await service.validate(rawValue: requestedPath, isProvided: true) + + #expect(result == .invalid) + #expect(await probe.paths.isEmpty) + } + + @Test func symlinkDotDotDirectoryIsRejectedWithoutUsingLocalLane() async throws { + let root = Self.nonSymlinkedTemporaryDirectory + .appendingPathComponent("cmux-symlink-dot-component-\(UUID().uuidString)", isDirectory: true) + let localDirectory = root.appendingPathComponent("directory", isDirectory: true) + let redirectedParent = root.appendingPathComponent("redirected", isDirectory: true) + let linkTarget = redirectedParent.appendingPathComponent("target", isDirectory: true) + let link = root.appendingPathComponent("link", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: localDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: linkTarget, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: linkTarget) + let requestedPath = link.path + "/../directory" + let probe = ImmediateAliasDirectoryProbe() + let deadlines = ControlledAliasValidationDeadlines() + let service = Self.productionClassifierService(probe: probe, deadlines: deadlines) + + let result = await service.validate(rawValue: requestedPath, isProvided: true) + + #expect(TerminalController.v2WorkingDirectoryProbeLane(requestedPath) == .external) + #expect(result == .invalid) + #expect(await probe.paths.isEmpty) + } + + @Test func repeatedAndTrailingSeparatorsPreserveExactValidatedPath() async throws { + let root = Self.nonSymlinkedTemporaryDirectory + .appendingPathComponent("cmux-repeated-separator-\(UUID().uuidString)", isDirectory: true) + let directory = root.appendingPathComponent("directory", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let requestedPath = root.path + "//directory//" + let probe = ImmediateAliasDirectoryProbe() + let deadlines = ControlledAliasValidationDeadlines() + let service = Self.productionClassifierService(probe: probe, deadlines: deadlines) + + let result = await service.validate(rawValue: requestedPath, isProvided: true) + + #expect(result == .valid(requestedPath)) + #expect(await probe.paths == [requestedPath]) + } + + @Test func caseFoldedMountMatchingIsConservativelyExternal() { + let root = TerminalController.WorkspaceCreateMountEntry(path: "/", isLocal: true) + let externalVolume = TerminalController.WorkspaceCreateMountEntry( + path: "/Volumes/External", + isLocal: false + ) + + #expect(TerminalController.v2WorkingDirectoryMountIsLocal( + path: "/volumes/external/project", + mounts: [root, externalVolume] + ) == false) + + let local = TerminalController.WorkspaceCreateMountEntry( + path: "/Volumes/Shared", + isLocal: true + ) + let externalShared = TerminalController.WorkspaceCreateMountEntry( + path: "/volumes/shared", + isLocal: false + ) + for mounts in [[local, externalShared], [externalShared, local]] { + #expect(TerminalController.v2WorkingDirectoryMountIsLocal( + path: "/VOLUMES/SHARED/project", + mounts: mounts + ) == false) + } + } + + @Test func mobileCreateAcceptsExistingLegacyCwdAlias() async throws { + let manager = TabManager() + let baselineIDs = Set(manager.tabs.map(\.id)) + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("mobile-task-cwd-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let result = await TerminalController.shared.v2MobileWorkspaceCreate( + params: ["cwd": directory.path], + tabManager: manager + ) + + let created = try #require(manager.tabs.first { !baselineIDs.contains($0.id) }) + #expect(Self.errorCode(from: result) == nil) + #expect(created.currentDirectory == directory.path) + } + + @Test func mobileCreateRejectsDotComponentInLegacyCwdAlias() async { + let manager = TabManager() + let baselineIDs = Set(manager.tabs.map(\.id)) + + let result = await TerminalController.shared.v2MobileWorkspaceCreate( + params: ["cwd": "/tmp/../tmp"], + tabManager: manager + ) + + #expect(Self.errorCode(from: result) == "invalid_working_directory") + #expect(Set(manager.tabs.map(\.id)) == baselineIDs) + } + + @Test func mobileCreateRoutesLegacyCwdSymlinkThroughValidator() async throws { + let manager = TabManager() + let baselineIDs = Set(manager.tabs.map(\.id)) + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("mobile-task-cwd-link-\(UUID().uuidString)", isDirectory: true) + let target = root.appendingPathComponent("target", isDirectory: true) + let link = root.appendingPathComponent("link", isDirectory: true) + try FileManager.default.createDirectory(at: target, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target) + defer { try? FileManager.default.removeItem(at: root) } + + let result = await TerminalController.shared.v2MobileWorkspaceCreate( + params: ["cwd": link.path], + workingDirectoryValidator: { rawValue, isProvided in + guard isProvided, rawValue == link.path else { return .notProvided } + return .invalid + }, + tabManager: manager + ) + + #expect(Self.errorCode(from: result) == "invalid_working_directory") + #expect(Set(manager.tabs.map(\.id)) == baselineIDs) + } + + @Test func mobileCreatePropagatesLegacyCwdValidationTimeout() async { + let manager = TabManager() + let baselineIDs = Set(manager.tabs.map(\.id)) + let cwd = "/external/wedged-cwd" + + let result = await TerminalController.shared.v2MobileWorkspaceCreate( + params: ["cwd": cwd], + workingDirectoryValidator: { rawValue, isProvided in + guard isProvided, rawValue == cwd else { return .notProvided } + return .timedOut + }, + tabManager: manager + ) + + #expect(Self.errorCode(from: result) == "request_timeout") + #expect(Set(manager.tabs.map(\.id)) == baselineIDs) + } + + @Test func mobileCreateRejectsBusyWorkingDirectoryValidationWithoutCreatingWorkspace() async { + let manager = TabManager() + let baselineIDs = Set(manager.tabs.map(\.id)) + + let result = await TerminalController.shared.v2MobileWorkspaceCreate( + params: ["working_directory": "/tmp/overloaded"], + workingDirectoryValidator: { _, _ in .busy }, + tabManager: manager + ) + + #expect(Self.errorCode(from: result) == "busy") + #expect(Set(manager.tabs.map(\.id)) == baselineIDs) + } + + @Test func mobileWorkingDirectoryTakesPrecedenceOverInvalidCwdAlias() async throws { + let manager = TabManager() + let baselineIDs = Set(manager.tabs.map(\.id)) + let preferred = "/validated/preferred" + + let result = await TerminalController.shared.v2MobileWorkspaceCreate( + params: [ + "working_directory": preferred, + "cwd": 42, + ], + workingDirectoryValidator: { rawValue, isProvided in + guard isProvided, rawValue == preferred else { return .invalid } + return .valid(preferred) + }, + tabManager: manager + ) + + let created = try #require(manager.tabs.first { !baselineIDs.contains($0.id) }) + #expect(Self.errorCode(from: result) == nil) + #expect(created.currentDirectory == preferred) + } + + @Test func mobileCreatePreservesNonStringCwdTypeError() async { + let manager = TabManager() + let baselineIDs = Set(manager.tabs.map(\.id)) + + let result = await TerminalController.shared.v2MobileWorkspaceCreate( + params: ["cwd": 42], + tabManager: manager + ) + + #expect(Self.errorCode(from: result) == "invalid_params") + #expect(Set(manager.tabs.map(\.id)) == baselineIDs) + } + + private static func productionClassifierService( + probe: ImmediateAliasDirectoryProbe, + deadlines: ControlledAliasValidationDeadlines + ) -> TerminalController.WorkspaceCreateWorkingDirectoryValidationService { + TerminalController.WorkspaceCreateWorkingDirectoryValidationService( + timeout: .seconds(1), + localCapacity: 1, + externalCapacity: 2, + maximumPendingWaiters: 64, + laneClassifier: { TerminalController.v2WorkingDirectoryProbeLane($0) }, + probe: { path, lane in await probe.run(path: path, lane: lane) }, + sleepUntilDeadline: { _ in await deadlines.suspendUntilFired() } + ) + } + + private static func errorCode(from result: TerminalController.V2CallResult) -> String? { + guard case let .err(code, _, _) = result else { return nil } + return code + } + + private static var nonSymlinkedTemporaryDirectory: URL { + let temporaryPath = FileManager.default.temporaryDirectory.path + if temporaryPath == "/var" || temporaryPath.hasPrefix("/var/") { + return URL(fileURLWithPath: "/private\(temporaryPath)", isDirectory: true) + } + if temporaryPath == "/tmp" || temporaryPath.hasPrefix("/tmp/") { + return URL(fileURLWithPath: "/private\(temporaryPath)", isDirectory: true) + } + return URL(fileURLWithPath: temporaryPath, isDirectory: true) + } +} + +private actor ImmediateAliasDirectoryProbe { + private(set) var paths: [String] = [] + + func run( + path: String, + lane _: TerminalController.WorkspaceCreateWorkingDirectoryValidationService.ProbeLane + ) -> Bool { + paths.append(path) + return true + } +} + +private actor ControlledAliasValidationDeadlines { + private var suspended: [UUID: CheckedContinuation] = [:] + + func suspendUntilFired() async { + let id = UUID() + await withTaskCancellationHandler { + await withCheckedContinuation { suspended[id] = $0 } + } onCancel: { + Task { await self.cancel(id: id) } + } + } + + private func cancel(id: UUID) { + suspended.removeValue(forKey: id)?.resume() + } +} diff --git a/cmuxTests/WorkspaceCreateWorkingDirectoryTests.swift b/cmuxTests/WorkspaceCreateWorkingDirectoryTests.swift new file mode 100644 index 000000000000..a31f8b7f01c0 --- /dev/null +++ b/cmuxTests/WorkspaceCreateWorkingDirectoryTests.swift @@ -0,0 +1,536 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite(.serialized) struct WorkspaceCreateWorkingDirectoryTests { + @Test func expandsHomeDirectory() { + #expect(TerminalController.v2ExpandedWorkingDirectory("~") == NSHomeDirectory()) + } + + @Test func expandsHomeSubdirectory() { + #expect(TerminalController.v2ExpandedWorkingDirectory("~/sub/dir") == "\(NSHomeDirectory())/sub/dir") + } + + @Test func absolutePathPassesThrough() { + #expect(TerminalController.v2ExpandedWorkingDirectory("/tmp/project") == "/tmp/project") + } + + @Test func nilAndEmptyReturnNil() { + #expect(TerminalController.v2ExpandedWorkingDirectory(nil) == nil) + #expect(TerminalController.v2ExpandedWorkingDirectory(" \n ") == nil) + } + + @Test func sameOperationIDCreatesOneWorkspaceWithOneInitialAgentCommand() throws { + let manager = TabManager() + let initialWorkspaceIDs = Set(manager.tabs.map(\.id)) + let operationID = UUID() + let params: [String: Any] = [ + "operation_id": operationID.uuidString, + "title": "Idempotent Task", + "initial_command": "codex \"${CMUX_TASK_PROMPT}\"", + "initial_env": ["CMUX_TASK_PROMPT": "Fix the composer"], + ] + let cache = Self.makeCache() + + let first = TerminalController.shared.v2WorkspaceCreate( + params: params, + tabManager: manager, + idempotencyCache: cache + ) + let retry = TerminalController.shared.v2WorkspaceCreate( + params: params, + tabManager: manager, + idempotencyCache: cache + ) + let created = try #require(manager.tabs.first { !initialWorkspaceIDs.contains($0.id) }) + let createdPanels = created.panels.values.compactMap { $0 as? TerminalPanel } + + #expect(manager.tabs.count == initialWorkspaceIDs.count + 1) + #expect(createdPanels.count == 1) + #expect(createdPanels.first?.surface.debugInitialCommand() == "codex \"${CMUX_TASK_PROMPT}\"") + #expect(Self.workspaceID(from: first) == created.id) + #expect(Self.workspaceID(from: retry) == created.id) + } + + @Test func initialAgentCommandPreservesSurroundingWhitespaceThroughTerminalStartup() throws { + let manager = TabManager() + let initialWorkspaceIDs = Set(manager.tabs.map(\.id)) + let initialCommand = " \nprintf ' preserved '\n " + + _ = TerminalController.shared.v2WorkspaceCreate(params: [ + "initial_command": initialCommand, + ], tabManager: manager) + + let created = try #require(manager.tabs.first { !initialWorkspaceIDs.contains($0.id) }) + let panel = try #require(created.panels.values.compactMap { $0 as? TerminalPanel }.first) + #expect(panel.surface.debugInitialCommand() == initialCommand) + } + + @Test func whitespaceOnlyInitialAgentCommandStartsPlainShell() throws { + let manager = TabManager() + let initialWorkspaceIDs = Set(manager.tabs.map(\.id)) + + _ = TerminalController.shared.v2WorkspaceCreate(params: [ + "initial_command": " \n\t ", + ], tabManager: manager) + + let created = try #require(manager.tabs.first { !initialWorkspaceIDs.contains($0.id) }) + let panel = try #require(created.panels.values.compactMap { $0 as? TerminalPanel }.first) + #expect(panel.surface.debugInitialCommand() == nil) + } + + @Test func initialEnvironmentRejectsCStringTruncationAndPreservesEmptyPrompt() throws { + let manager = TabManager() + let initialWorkspaceIDs = Set(manager.tabs.map(\.id)) + + _ = TerminalController.shared.v2WorkspaceCreate(params: [ + "initial_env": [ + "CMUX_SOCKET_PATH\u{0}x": "spoofed", + "BAD=KEY": "value", + "NUL_VALUE": "a\u{0}b", + "CMUX_TASK_PROMPT": "", + "GOOD": "value", + ], + ], tabManager: manager) + + let created = try #require(manager.tabs.first { !initialWorkspaceIDs.contains($0.id) }) + let panel = try #require(created.panels.values.compactMap { $0 as? TerminalPanel }.first) + #expect(panel.surface.respawnInitialEnvironmentOverrides == [ + "CMUX_TASK_PROMPT": "", + "GOOD": "value", + ]) + } + + @Test func taskCreateOperationIDSurvivesSnapshotRestoreWithFreshRuntimeWorkspaceID() throws { + let operationID = UUID() + let original = Workspace() + original.taskCreateOperationID = operationID + + let snapshot = original.sessionSnapshot(includeScrollback: false) + let restored = Workspace() + _ = restored.restoreSessionSnapshot(snapshot) + + #expect(snapshot.taskCreateOperationID == operationID) + #expect(restored.taskCreateOperationID == operationID) + #expect(restored.id != original.id) + } + + @Test func retryFindsRestoredWorkspaceBeforeFreshCacheWithoutLaunchingCommand() throws { + let operationID = UUID() + let sourceManager = TabManager() + let sourceWorkspace = try #require(sourceManager.selectedWorkspace) + sourceWorkspace.taskCreateOperationID = operationID + let snapshot = sourceManager.sessionSnapshot(includeScrollback: false) + let manager = TabManager() + manager.restoreSessionSnapshot(snapshot) + let restored = try #require(manager.selectedWorkspace) + let initialIDs = Set(manager.tabs.map(\.id)) + let cache = Self.makeCache() + + let result = TerminalController.shared.v2WorkspaceCreate(params: [ + "operation_id": operationID.uuidString, + "initial_command": "must-not-launch", + ], tabManager: manager, idempotencyCache: cache) + + #expect(Set(manager.tabs.map(\.id)) == initialIDs) + #expect(restored.id != sourceWorkspace.id) + #expect(restored.taskCreateOperationID == operationID) + #expect(restored.panels.values.compactMap { $0 as? TerminalPanel } + .allSatisfy { $0.surface.debugInitialCommand() != "must-not-launch" }) + #expect(Self.workspaceID(from: result) == restored.id) + } + + @Test func retryFromAnotherManagerReturnsOwningWindowWithoutCreatingWorkspaceOrCommand() throws { + let operationID = UUID() + let currentManager = TabManager() + let ownerManager = TabManager() + let ownerWindowID = UUID() + let ownerWorkspace = try #require(ownerManager.selectedWorkspace) + ownerWorkspace.taskCreateOperationID = operationID + let cache = Self.makeCache() + cache.record( + operationID: operationID, + workspaceID: ownerWorkspace.id + ) + let currentIDs = Set(currentManager.tabs.map(\.id)) + let ownerIDs = Set(ownerManager.tabs.map(\.id)) + + let result = TerminalController.shared.v2WorkspaceCreate( + params: [ + "operation_id": operationID.uuidString, + "initial_command": "must-not-launch", + ], + tabManager: currentManager, + taskCreateCandidates: [ + .init(tabManager: currentManager, windowID: UUID()), + .init(tabManager: ownerManager, windowID: ownerWindowID), + ], + idempotencyCache: cache + ) + + #expect(Set(currentManager.tabs.map(\.id)) == currentIDs) + #expect(Set(ownerManager.tabs.map(\.id)) == ownerIDs) + #expect(ownerWorkspace.panels.values.compactMap { $0 as? TerminalPanel } + .allSatisfy { $0.surface.debugInitialCommand() != "must-not-launch" }) + #expect(Self.workspaceID(from: result) == ownerWorkspace.id) + #expect(Self.windowID(from: result) == ownerWindowID) + } + + @Test func synchronousControlCreateKeepsWorkingDirectoryAsCwdWithoutFilesystemValidation() throws { + let manager = TabManager() + let baselineIDs = Set(manager.tabs.map(\.id)) + let requestedDirectory = "/missing/control-cwd-\(UUID().uuidString)" + + let result = TerminalController.shared.v2WorkspaceCreate(params: [ + "working_directory": requestedDirectory, + ], tabManager: manager) + let createdID = try #require(Self.workspaceID(from: result)) + let created = try #require(manager.tabs.first { $0.id == createdID }) + + #expect(Set(manager.tabs.map(\.id)).subtracting(baselineIDs) == [createdID]) + #expect(created.currentDirectory == requestedDirectory) + } + + @Test func legacyCwdRemainsCompatible() { + let manager = TabManager() + + let legacyResult = TerminalController.shared.v2WorkspaceCreate(params: [ + "cwd": "relative/legacy-path", + ], tabManager: manager) + + #expect(Self.workspaceID(from: legacyResult) != nil) + } + + @Test func mobileHandlerAcceptsExistingWorkingDirectory() async throws { + let previousManager = TerminalController.shared.activeTabManagerForCallerNotification() + let manager = TabManager() + TerminalController.shared.setActiveTabManager(manager) + defer { TerminalController.shared.setActiveTabManager(previousManager) } + let baselineIDs = Set(manager.tabs.map(\.id)) + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("mobile-task-dir-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let result = await TerminalController.shared.mobileHostHandleRPC( + MobileHostRPCRequest( + id: "mobile-task-valid-directory", + method: "workspace.create", + params: ["working_directory": directory.path], + auth: nil + ) + ) + + guard case .ok = result else { + return #expect(Bool(false), "existing absolute directory should be accepted") + } + #expect(Set(manager.tabs.map(\.id)).subtracting(baselineIDs).count == 1) + } + + @Test func mobileHandlerRejectsRelativeFileAndMissingWorkingDirectories() async throws { + let previousManager = TerminalController.shared.activeTabManagerForCallerNotification() + let manager = TabManager() + TerminalController.shared.setActiveTabManager(manager) + defer { TerminalController.shared.setActiveTabManager(previousManager) } + let baselineIDs = Set(manager.tabs.map(\.id)) + let missing = FileManager.default.temporaryDirectory + .appendingPathComponent("missing-mobile-task-dir-\(UUID().uuidString)").path + let regularFile = FileManager.default.temporaryDirectory + .appendingPathComponent("mobile-task-file-\(UUID().uuidString)") + try Data().write(to: regularFile) + defer { try? FileManager.default.removeItem(at: regularFile) } + + for invalidPath in ["relative/path", regularFile.path, missing] { + let result = await TerminalController.shared.mobileHostHandleRPC( + MobileHostRPCRequest( + id: "mobile-task-invalid-directory", + method: "workspace.create", + params: ["working_directory": invalidPath], + auth: nil + ) + ) + guard case let .failure(error) = result else { + return #expect(Bool(false), "invalid directory should be rejected") + } + #expect(error.code == "invalid_working_directory") + #expect(error.message == "working_directory must be an absolute existing directory") + #expect((error.data as? [String: String])?["field"] == "working_directory") + } + #expect(Set(manager.tabs.map(\.id)) == baselineIDs) + } + + @Test func cancelledMobileValidationCreatesNoWorkspace() async { + let manager = TabManager() + let baselineIDs = Set(manager.tabs.map(\.id)) + let gate = WorkspaceCreateValidationGate() + let create = Task { @MainActor in + await TerminalController.shared.v2MobileWorkspaceCreate( + params: ["working_directory": "/tmp"], + workingDirectoryValidator: { rawValue, isProvided in + await gate.validate(rawValue: rawValue, isProvided: isProvided) + }, + tabManager: manager + ) + } + await gate.waitUntilValidationStarts() + + create.cancel() + await gate.release() + _ = await create.value + + #expect(Set(manager.tabs.map(\.id)) == baselineIDs) + } + + @Test func initialAndRetryMobileResponsesBothDecodeAsWorkspaceLists() async throws { + let manager = TabManager() + let operationID = UUID() + let params: [String: Any] = [ + "operation_id": operationID.uuidString, + "title": "Retry Shape", + ] + let cache = Self.makeCache() + + let initial = await TerminalController.shared.v2MobileWorkspaceCreate( + params: params, + tabManager: manager, + idempotencyCache: cache + ) + let retry = await TerminalController.shared.v2MobileWorkspaceCreate( + params: params, + tabManager: manager, + idempotencyCache: cache + ) + let initialResponse = try Self.decodeMobileWorkspaceList(initial) + let retryResponse = try Self.decodeMobileWorkspaceList(retry) + + #expect(initialResponse.createdWorkspaceID != nil) + #expect(retryResponse.createdWorkspaceID == initialResponse.createdWorkspaceID) + #expect(retryResponse.workspaces.map(\.id) == initialResponse.workspaces.map(\.id)) + } + + @Test func concurrentMobileRequestsWithSameOperationCreateExactlyOnce() async throws { + let manager = TabManager() + let baselineIDs = Set(manager.tabs.map(\.id)) + let operationID = UUID() + let gate = ConcurrentWorkspaceCreateValidationGate() + let params: [String: Any] = [ + "operation_id": operationID.uuidString, + "working_directory": "/tmp", + "initial_command": "must-run-once", + ] + let cache = Self.makeCache() + let first = Task { @MainActor in + await TerminalController.shared.v2MobileWorkspaceCreate( + params: params, + workingDirectoryValidator: { rawValue, _ in await gate.validate(rawValue) }, + tabManager: manager, + idempotencyCache: cache + ) + } + let second = Task { @MainActor in + await TerminalController.shared.v2MobileWorkspaceCreate( + params: params, + workingDirectoryValidator: { rawValue, _ in await gate.validate(rawValue) }, + tabManager: manager, + idempotencyCache: cache + ) + } + await gate.waitForStarts(2) + + await gate.releaseAll() + let concurrentResults = [await first.value, await second.value] + let successfulResponses = concurrentResults.compactMap { try? Self.decodeMobileWorkspaceList($0) } + let completedErrors = concurrentResults.compactMap(Self.errorCode(from:)) + let created = try #require(manager.tabs.first { !baselineIDs.contains($0.id) }) + let taskPanel = try #require(created.panels.values.compactMap { $0 as? TerminalPanel }.first) + let initialCommands = created.panels.values.compactMap { ($0 as? TerminalPanel)?.surface.debugInitialCommand() } + let startupDeadline = ContinuousClock.now + .seconds(1) + while taskPanel.surface.debugRuntimeSurfaceCreateAttemptCountForTesting() == 0, + ContinuousClock.now < startupDeadline { + try await Task.sleep(for: .milliseconds(10)) + } + let attemptsAfterCreation = taskPanel.surface.debugRuntimeSurfaceCreateAttemptCountForTesting() + + #expect(Set(manager.tabs.map(\.id)).subtracting(baselineIDs).count == 1) + #expect(initialCommands.filter { $0 == "must-run-once" }.count == 1) + #expect(attemptsAfterCreation > 0) + #expect(!successfulResponses.isEmpty) + #expect(successfulResponses.count + completedErrors.count == concurrentResults.count) + #expect(successfulResponses.allSatisfy { $0.createdWorkspaceID == created.id.uuidString }) + #expect(completedErrors.allSatisfy { $0 == "already_completed" }) + + let retry = await TerminalController.shared.v2MobileWorkspaceCreate( + params: params, + tabManager: manager, + idempotencyCache: cache + ) + let retryResponse = try? Self.decodeMobileWorkspaceList(retry) + let retryError = Self.errorCode(from: retry) + + #expect( + retryResponse?.createdWorkspaceID == created.id.uuidString + || retryError == "already_completed" + ) + #expect(taskPanel.surface.debugRuntimeSurfaceCreateAttemptCountForTesting() == attemptsAfterCreation) + } + + @Test func emptyMobileWorkspaceCreateStaysMetadataOnly() async throws { + let manager = TabManager() + let baselineIDs = Set(manager.tabs.map(\.id)) + + let response = try Self.decodeMobileWorkspaceList( + await TerminalController.shared.v2MobileWorkspaceCreate( + params: ["title": "Lazy Mobile Workspace"], + tabManager: manager, + idempotencyCache: Self.makeCache() + ) + ) + let created = try #require(manager.tabs.first { !baselineIDs.contains($0.id) }) + let panel = try #require(created.panels.values.compactMap { $0 as? TerminalPanel }.first) + + #expect(response.createdWorkspaceID == created.id.uuidString) + #expect(!panel.surface.debugBackgroundSurfaceStartQueuedForTesting()) + #expect(panel.surface.debugRuntimeSurfaceCreateAttemptCountForTesting() == 0) + } + + @Test func idempotencyCacheEvictsSuccessfulResultsInFIFOOrder() { + let cache = Self.makeCache(capacity: 2) + let firstID = UUID() + let secondID = UUID() + let thirdID = UUID() + let firstWorkspaceID = UUID() + let secondWorkspaceID = UUID() + let thirdWorkspaceID = UUID() + + cache.record(operationID: firstID, workspaceID: firstWorkspaceID) + cache.record(operationID: secondID, workspaceID: secondWorkspaceID) + #expect(cache.workspaceID(for: firstID) == firstWorkspaceID) + cache.record(operationID: thirdID, workspaceID: thirdWorkspaceID) + + #expect(cache.workspaceID(for: firstID) == nil) + #expect(cache.workspaceID(for: secondID) == secondWorkspaceID) + #expect(cache.workspaceID(for: thirdID) == thirdWorkspaceID) + } + + private static func workspaceID(from result: TerminalController.V2CallResult) -> UUID? { + guard case .ok(let rawPayload) = result, + let payload = rawPayload as? [String: Any], + let rawID = payload["workspace_id"] as? String else { + return nil + } + return UUID(uuidString: rawID) + } + + private static func makeCache(capacity: Int = 256) -> TerminalController.WorkspaceCreateIdempotencyCache { + TerminalController.WorkspaceCreateIdempotencyCache( + capacity: capacity, + persistence: InMemoryWorkspaceCreateIdempotencyStore() + ) + } + + private static func errorCode(from result: TerminalController.V2CallResult) -> String? { + guard case .err(let code, _, _) = result else { return nil } + return code + } + + private static func windowID(from result: TerminalController.V2CallResult) -> UUID? { + guard case .ok(let rawPayload) = result, + let payload = rawPayload as? [String: Any], + let rawID = payload["window_id"] as? String else { + return nil + } + return UUID(uuidString: rawID) + } + + private static func decodeMobileWorkspaceList( + _ result: TerminalController.V2CallResult + ) throws -> DecodedMobileWorkspaceListResponse { + guard case let .ok(payload) = result else { + throw MobileWorkspaceListDecodeError.notSuccess + } + let data = try JSONSerialization.data(withJSONObject: payload) + return try JSONDecoder().decode(DecodedMobileWorkspaceListResponse.self, from: data) + } +} + +private struct DecodedMobileWorkspaceListResponse: Decodable { + struct Workspace: Decodable { + let id: String + } + + let workspaces: [Workspace] + let createdWorkspaceID: String? + + private enum CodingKeys: String, CodingKey { + case workspaces + case createdWorkspaceID = "created_workspace_id" + } +} + +private enum MobileWorkspaceListDecodeError: Error { + case notSuccess +} + +private actor WorkspaceCreateValidationGate { + private var started = false + private var startWaiters: [CheckedContinuation] = [] + private var releaseContinuation: CheckedContinuation? + + func validate( + rawValue: String?, + isProvided: Bool + ) async -> TerminalController.WorkspaceCreateWorkingDirectoryValidation { + started = true + let waiters = startWaiters + startWaiters = [] + for waiter in waiters { waiter.resume() } + await withCheckedContinuation { releaseContinuation = $0 } + return .valid(rawValue ?? "/tmp") + } + + func waitUntilValidationStarts() async { + if started { return } + await withCheckedContinuation { startWaiters.append($0) } + } + + func release() { + releaseContinuation?.resume() + releaseContinuation = nil + } +} + +private actor ConcurrentWorkspaceCreateValidationGate { + private var starts = 0 + private var startWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + func validate(_ rawValue: String?) async -> TerminalController.WorkspaceCreateWorkingDirectoryValidation { + starts += 1 + resumeStartWaiters() + await withCheckedContinuation { releaseWaiters.append($0) } + return .valid(rawValue ?? "/tmp") + } + + func waitForStarts(_ count: Int) async { + if starts >= count { return } + await withCheckedContinuation { startWaiters.append((count, $0)) } + } + + func releaseAll() { + let waiters = releaseWaiters + releaseWaiters = [] + for waiter in waiters { waiter.resume() } + } + + private func resumeStartWaiters() { + let ready = startWaiters.filter { starts >= $0.count } + startWaiters.removeAll { starts >= $0.count } + for waiter in ready { waiter.continuation.resume() } + } +} diff --git a/cmuxTests/WorkspaceCreateWorkingDirectoryValidationServiceTests.swift b/cmuxTests/WorkspaceCreateWorkingDirectoryValidationServiceTests.swift new file mode 100644 index 000000000000..a794d02276d9 --- /dev/null +++ b/cmuxTests/WorkspaceCreateWorkingDirectoryValidationServiceTests.swift @@ -0,0 +1,499 @@ +import Darwin +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite(.serialized) struct WorkspaceCreateWorkingDirectoryValidationServiceTests { + @Test func samePathWaitersShareOneProbe() async { + let probe = ControlledDirectoryProbe() + let deadlines = ControlledValidationDeadlines() + let service = Self.service(probe: probe, deadlines: deadlines) + let first = Task { await service.validate(rawValue: "/tmp/shared", isProvided: true) } + let second = Task { await service.validate(rawValue: "/tmp/shared", isProvided: true) } + await probe.waitForCount(1) + + #expect(await probe.count == 1) + await probe.complete(path: "/tmp/shared", isDirectory: true) + #expect(await first.value == .valid("/tmp/shared")) + #expect(await second.value == .valid("/tmp/shared")) + } + @Test func canonicallyEquivalentPathsUseDistinctByteExactProbes() async { + let composed = "/tmp/caf\u{00E9}" + let decomposed = "/tmp/cafe\u{0301}" + #expect(composed == decomposed && !Data(composed.utf8).elementsEqual(Data(decomposed.utf8))) + let probe = ControlledDirectoryProbe() + let deadlines = ControlledValidationDeadlines() + let service = TerminalController.WorkspaceCreateWorkingDirectoryValidationService( + timeout: .seconds(1), + localCapacity: 2, + externalCapacity: 1, + maximumPendingWaiters: 64, + laneClassifier: { _ in .local }, + probe: { path, lane in await probe.run(path: path, lane: lane) }, + sleepUntilDeadline: { _ in await deadlines.suspendUntilFired() } + ) + let first = Task { await service.validate(rawValue: composed, isProvided: true) } + let second = Task { await service.validate(rawValue: decomposed, isProvided: true) } + await probe.waitForCount(2) + await probe.complete(path: composed, isDirectory: true) + await probe.complete(path: decomposed, isDirectory: false) + #expect(await first.value == .valid(composed)) + #expect(await second.value == .invalid) + } + @Test func pendingLimitRejectsNewPathsAndRecoversAfterAWaiterFinishes() async { + let probe = ControlledDirectoryProbe() + let deadlines = ControlledValidationDeadlines() + let service = TerminalController.WorkspaceCreateWorkingDirectoryValidationService( + timeout: .seconds(1), + localCapacity: 1, + externalCapacity: 1, + maximumPendingWaiters: 2, + laneClassifier: { _ in .local }, + probe: { path, lane in await probe.run(path: path, lane: lane) }, + sleepUntilDeadline: { _ in await deadlines.suspendUntilFired() } + ) + let active = Task { await service.validate(rawValue: "/tmp/active", isProvided: true) } + await probe.waitForCount(1) + let queued = Task { await service.validate(rawValue: "/tmp/queued", isProvided: true) } + await deadlines.waitForCount(2) + + #expect(await service.validate(rawValue: "/tmp/rejected", isProvided: true) == .busy) + #expect(await deadlines.count == 2) + #expect(await probe.count == 1) + + await probe.complete(path: "/tmp/active", isDirectory: true) + #expect(await active.value == .valid("/tmp/active")) + await probe.waitForCount(2) + await probe.complete(path: "/tmp/queued", isDirectory: true) + #expect(await queued.value == .valid("/tmp/queued")) + + let recovered = Task { await service.validate(rawValue: "/tmp/recovered", isProvided: true) } + await probe.waitForCount(3) + await probe.complete(path: "/tmp/recovered", isDirectory: true) + #expect(await recovered.value == .valid("/tmp/recovered")) + } + + @Test func pendingLimitAlsoBoundsSamePathWaiters() async { + let probe = ControlledDirectoryProbe() + let deadlines = ControlledValidationDeadlines() + let service = TerminalController.WorkspaceCreateWorkingDirectoryValidationService( + timeout: .seconds(1), + localCapacity: 1, + externalCapacity: 1, + maximumPendingWaiters: 2, + laneClassifier: { _ in .local }, + probe: { path, lane in await probe.run(path: path, lane: lane) }, + sleepUntilDeadline: { _ in await deadlines.suspendUntilFired() } + ) + let first = Task { await service.validate(rawValue: "/tmp/shared", isProvided: true) } + let second = Task { await service.validate(rawValue: "/tmp/shared", isProvided: true) } + await probe.waitForCount(1) + await deadlines.waitForCount(2) + + #expect(await service.validate(rawValue: "/tmp/shared", isProvided: true) == .busy) + #expect(await deadlines.count == 2) + #expect(await probe.count == 1) + + await probe.complete(path: "/tmp/shared", isDirectory: true) + #expect(await first.value == .valid("/tmp/shared")) + #expect(await second.value == .valid("/tmp/shared")) + } + + @Test func validationTimeoutCreatesNoWorkspaceAndMapsToRequestTimeout() async { + let probe = ControlledDirectoryProbe() + let deadlines = ControlledValidationDeadlines() + let service = Self.service(probe: probe, deadlines: deadlines) + let manager = TabManager() + let baselineIDs = Set(manager.tabs.map(\.id)) + let create = Task { @MainActor in + await TerminalController.shared.v2MobileWorkspaceCreate( + params: ["working_directory": "/tmp/wedged"], + workingDirectoryValidator: { rawValue, isProvided in + await service.validate(rawValue: rawValue, isProvided: isProvided) + }, + tabManager: manager + ) + } + await probe.waitForCount(1) + await deadlines.waitForCount(1) + + await deadlines.fireAll() + let result = await create.value + + #expect(Self.errorCode(from: result) == "request_timeout") + #expect(Set(manager.tabs.map(\.id)) == baselineIDs) + await probe.complete(path: "/tmp/wedged", isDirectory: true) + } + + @Test func oneWedgedProbeLeavesSecondSlotAvailableAndSamePathRemainsCoalesced() async { + let probe = ControlledDirectoryProbe() + let deadlines = ControlledValidationDeadlines() + let service = Self.service(probe: probe, deadlines: deadlines) + let first = Task { await service.validate(rawValue: "/external/wedged", isProvided: true) } + await probe.waitForCount(1) + await deadlines.waitForCount(1) + await deadlines.fireAll() + #expect(await first.value == .timedOut) + + let samePath = Task { await service.validate(rawValue: "/external/wedged", isProvided: true) } + let differentPath = Task { await service.validate(rawValue: "/external/different", isProvided: true) } + await deadlines.waitForCount(3) + await probe.waitForCount(2) + #expect(await probe.count == 2) + await probe.complete(path: "/external/different", isDirectory: true) + #expect(await differentPath.value == .valid("/external/different")) + await deadlines.fireAll() + #expect(await samePath.value == .timedOut) + #expect(await probe.count == 2) + + await probe.complete(path: "/external/wedged", isDirectory: true) + } + + @Test func twoWedgedExternalProbesPreserveLocalLaneAndEnforceExternalCap() async { + let probe = ControlledDirectoryProbe() + let deadlines = ControlledValidationDeadlines() + let service = Self.service(probe: probe, deadlines: deadlines) + let first = Task { await service.validate(rawValue: "/external/first-wedge", isProvided: true) } + let second = Task { await service.validate(rawValue: "/external/second-wedge", isProvided: true) } + await probe.waitForCount(2) + await deadlines.waitForCount(2) + await deadlines.fireAll() + #expect(await first.value == .timedOut) + #expect(await second.value == .timedOut) + + let third = Task { await service.validate(rawValue: "/external/third", isProvided: true) } + let local = Task { await service.validate(rawValue: "/Users/test/project", isProvided: true) } + await deadlines.waitForCount(4) + await probe.waitForCount(3) + #expect(await probe.count == 3) + await probe.complete(path: "/Users/test/project", isDirectory: true) + #expect(await local.value == .valid("/Users/test/project")) + await deadlines.fireAll() + #expect(await third.value == .timedOut) + + let repeated = Task { await service.validate(rawValue: "/external/first-wedge", isProvided: true) } + await deadlines.waitForCount(5) + #expect(await probe.count == 3) + await deadlines.fireAll() + #expect(await repeated.value == .timedOut) + #expect(await probe.count == 3) + + await probe.complete(path: "/external/first-wedge", isDirectory: true) + let recovered = Task { await service.validate(rawValue: "/external/recovered", isProvided: true) } + await probe.waitForCount(4) + #expect(await probe.count == 4) + await probe.complete(path: "/external/recovered", isDirectory: true) + #expect(await recovered.value == .valid("/external/recovered")) + await probe.complete(path: "/external/second-wedge", isDirectory: true) + } + + @Test func anySymlinkedComponentUsesExternalProbeLane() throws { + let root = Self.nonSymlinkedTemporaryDirectory + .appendingPathComponent("cmux-symlink-lane-\(UUID().uuidString)", isDirectory: true) + let target = root.appendingPathComponent("target", isDirectory: true) + let link = root.appendingPathComponent("link", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: target, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target) + + let linkedChild = link.appendingPathComponent("child", isDirectory: true).path + + #expect(TerminalController.v2WorkingDirectoryProbeLane(linkedChild) == .external) + } + + @Test func symlinkAndExternalWedgesCannotOccupyReservedLocalLane() async throws { + let root = Self.nonSymlinkedTemporaryDirectory + .appendingPathComponent("cmux-symlink-capacity-\(UUID().uuidString)", isDirectory: true) + let target = root.appendingPathComponent("target", isDirectory: true) + let local = root.appendingPathComponent("local", isDirectory: true) + let link = root.appendingPathComponent("link", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: target, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: local, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target) + let linkedPath = link.appendingPathComponent("wedged", isDirectory: true).path + let externalPath = "/external/second-wedge" + let linkedLane = TerminalController.v2WorkingDirectoryProbeLane(linkedPath) + guard linkedLane == .external else { + return #expect(Bool(false), "a symlinked component must not enter the local lane") + } + guard TerminalController.v2WorkingDirectoryProbeLane(local.path) == .local else { + return #expect(Bool(false), "the real local fixture must use the reserved local lane") + } + + let probe = ControlledDirectoryProbe() + let deadlines = ControlledValidationDeadlines() + let service = TerminalController.WorkspaceCreateWorkingDirectoryValidationService( + timeout: .seconds(1), + localCapacity: 1, + externalCapacity: 2, + maximumPendingWaiters: 64, + laneClassifier: { path in + path == externalPath + ? .external + : TerminalController.v2WorkingDirectoryProbeLane(path) + }, + probe: { path, lane in await probe.run(path: path, lane: lane) }, + sleepUntilDeadline: { _ in await deadlines.suspendUntilFired() } + ) + let linked = Task { await service.validate(rawValue: linkedPath, isProvided: true) } + let external = Task { await service.validate(rawValue: externalPath, isProvided: true) } + await probe.waitForCount(2) + await deadlines.waitForCount(2) + await deadlines.fireAll() + #expect(await linked.value == .timedOut) + #expect(await external.value == .timedOut) + + let localValidation = Task { await service.validate(rawValue: local.path, isProvided: true) } + await probe.waitForCount(3) + #expect(await probe.lane(for: linkedPath) == .external) + #expect(await probe.lane(for: externalPath) == .external) + #expect(await probe.lane(for: local.path) == .local) + await probe.complete(path: local.path, isDirectory: true) + #expect(await localValidation.value == .valid(local.path)) + await probe.complete(path: linkedPath, isDirectory: true) + await probe.complete(path: externalPath, isDirectory: true) + } + + @Test func localProbeDoesNotFollowSymlinkCreatedAfterClassification() async throws { + let root = Self.nonSymlinkedTemporaryDirectory + .appendingPathComponent("cmux-symlink-race-\(UUID().uuidString)", isDirectory: true) + let candidate = root.appendingPathComponent("candidate", isDirectory: true) + let target = root.appendingPathComponent("target", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: candidate, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: target, withIntermediateDirectories: true) + #expect(TerminalController.v2WorkingDirectoryProbeLane(candidate.path) == .local) + + let deadlines = ControlledValidationDeadlines() + let lanes = ChosenProbeLaneRecorder() + let service = TerminalController.WorkspaceCreateWorkingDirectoryValidationService( + timeout: .seconds(1), + localCapacity: 1, + externalCapacity: 2, + maximumPendingWaiters: 64, + laneClassifier: { path in + let lane = TerminalController.v2WorkingDirectoryProbeLane(path) + _ = path.withCString { Darwin.rmdir($0) } + _ = target.path.withCString { destination in + path.withCString { Darwin.symlink(destination, $0) } + } + return lane + }, + probe: { path, lane in + await lanes.record(lane) + return await TerminalController.v2ProbeWorkingDirectory(path, lane: lane) + }, + sleepUntilDeadline: { _ in await deadlines.suspendUntilFired() } + ) + + let result = await service.validate(rawValue: candidate.path, isProvided: true) + + #expect(result == .invalid) + #expect(await lanes.values == [.local]) + #expect(TerminalController.v2WorkingDirectoryProbeLane(candidate.path) == .external) + } + + @Test func concurrentClassificationsAndLocalProbesRemainConsistent() async throws { + let root = Self.nonSymlinkedTemporaryDirectory + .appendingPathComponent("cmux-mount-snapshot-\(UUID().uuidString)", isDirectory: true) + let paths = (0..<8).map { + root.appendingPathComponent("directory-\($0)", isDirectory: true).path + } + defer { try? FileManager.default.removeItem(at: root) } + for path in paths { + try FileManager.default.createDirectory( + at: URL(fileURLWithPath: path, isDirectory: true), + withIntermediateDirectories: true + ) + } + + let failureCount = await withTaskGroup(of: Bool.self, returning: Int.self) { group in + for _ in 0..<64 { + for path in paths { + group.addTask { + let lane = TerminalController.v2WorkingDirectoryProbeLane(path) + let isDirectory = await TerminalController.v2ProbeWorkingDirectory( + path, + lane: .local + ) + return lane != .local || !isDirectory + } + } + } + return await group.reduce(into: 0) { failures, failed in + failures += failed ? 1 : 0 + } + } + + #expect(failureCount == 0) + } + + @Test func cancellingOneCoalescedWaiterDoesNotCancelProbeOrOtherWaiter() async { + let probe = ControlledDirectoryProbe() + let deadlines = ControlledValidationDeadlines() + let service = Self.service(probe: probe, deadlines: deadlines) + let cancelled = Task { await service.validate(rawValue: "/tmp/shared", isProvided: true) } + let remaining = Task { await service.validate(rawValue: "/tmp/shared", isProvided: true) } + await probe.waitForCount(1) + await deadlines.waitForCount(2) + + cancelled.cancel() + #expect(await cancelled.value == .cancelled) + #expect(await probe.count == 1) + await probe.complete(path: "/tmp/shared", isDirectory: true) + #expect(await remaining.value == .valid("/tmp/shared")) + + let next = Task { await service.validate(rawValue: "/tmp/next", isProvided: true) } + await probe.waitForCount(2) + await probe.complete(path: "/tmp/next", isDirectory: true) + #expect(await next.value == .valid("/tmp/next")) + } + + @Test func cancellationRacingDeadlineResumesWaiterExactlyOnceAndCleansUp() async { + let probe = ControlledDirectoryProbe() + let deadlines = ControlledValidationDeadlines() + let service = Self.service(probe: probe, deadlines: deadlines) + let validation = Task { await service.validate(rawValue: "/tmp/race", isProvided: true) } + await probe.waitForCount(1) + await deadlines.waitForCount(1) + + validation.cancel() + await deadlines.fireAll() + let result = await validation.value + + #expect(result == .cancelled || result == .timedOut) + await probe.complete(path: "/tmp/race", isDirectory: true) + + let next = Task { await service.validate(rawValue: "/tmp/after-race", isProvided: true) } + await probe.waitForCount(2) + await probe.complete(path: "/tmp/after-race", isDirectory: true) + #expect(await next.value == .valid("/tmp/after-race")) + } + + private static func service( + probe: ControlledDirectoryProbe, + deadlines: ControlledValidationDeadlines + ) -> TerminalController.WorkspaceCreateWorkingDirectoryValidationService { + TerminalController.WorkspaceCreateWorkingDirectoryValidationService( + timeout: .seconds(1), + localCapacity: 1, + externalCapacity: 2, + maximumPendingWaiters: 64, + laneClassifier: { path in path.hasPrefix("/external/") ? .external : .local }, + probe: { path, lane in await probe.run(path: path, lane: lane) }, + sleepUntilDeadline: { _ in await deadlines.suspendUntilFired() } + ) + } + + private static var nonSymlinkedTemporaryDirectory: URL { + let temporaryPath = FileManager.default.temporaryDirectory.path + if temporaryPath == "/var" || temporaryPath.hasPrefix("/var/") { + return URL(fileURLWithPath: "/private\(temporaryPath)", isDirectory: true) + } + if temporaryPath == "/tmp" || temporaryPath.hasPrefix("/tmp/") { + return URL(fileURLWithPath: "/private\(temporaryPath)", isDirectory: true) + } + return URL(fileURLWithPath: temporaryPath, isDirectory: true) + } + + private static func errorCode(from result: TerminalController.V2CallResult) -> String? { + guard case let .err(code, _, _) = result else { return nil } + return code + } +} + +private actor ControlledDirectoryProbe { + private(set) var count = 0 + private var lanesByPath: [Data: TerminalController.WorkspaceCreateWorkingDirectoryValidationService.ProbeLane] = [:] + private var activeContinuations: [Data: CheckedContinuation] = [:] + private var countWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + + func run( + path: String, + lane: TerminalController.WorkspaceCreateWorkingDirectoryValidationService.ProbeLane + ) async -> Bool { + count += 1 + let pathID = Data(path.utf8) + lanesByPath[pathID] = lane + resumeCountWaiters() + return await withCheckedContinuation { activeContinuations[pathID] = $0 } + } + + func lane( + for path: String + ) -> TerminalController.WorkspaceCreateWorkingDirectoryValidationService.ProbeLane? { + lanesByPath[Data(path.utf8)] + } + + func waitForCount(_ expected: Int) async { + if count >= expected { return } + await withCheckedContinuation { countWaiters.append((expected, $0)) } + } + + func complete(path: String, isDirectory: Bool) { + activeContinuations.removeValue(forKey: Data(path.utf8))?.resume(returning: isDirectory) + } + + private func resumeCountWaiters() { + let ready = countWaiters.filter { count >= $0.count } + countWaiters.removeAll { count >= $0.count } + for waiter in ready { waiter.continuation.resume() } + } +} + +private actor ChosenProbeLaneRecorder { + private(set) var values: [TerminalController.WorkspaceCreateWorkingDirectoryValidationService.ProbeLane] = [] + + func record(_ lane: TerminalController.WorkspaceCreateWorkingDirectoryValidationService.ProbeLane) { + values.append(lane) + } +} + +private actor ControlledValidationDeadlines { + private var totalCount = 0 + private var suspended: [UUID: CheckedContinuation] = [:] + private var countWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + + var count: Int { totalCount } + + func suspendUntilFired() async { + let id = UUID() + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + totalCount += 1 + suspended[id] = continuation + resumeCountWaiters() + } + } onCancel: { + Task { await self.cancel(id: id) } + } + } + + func waitForCount(_ expected: Int) async { + if totalCount >= expected { return } + await withCheckedContinuation { countWaiters.append((expected, $0)) } + } + + func fireAll() { + let continuations = Array(suspended.values) + suspended.removeAll() + for continuation in continuations { continuation.resume() } + } + + private func cancel(id: UUID) { + suspended.removeValue(forKey: id)?.resume() + } + + private func resumeCountWaiters() { + let ready = countWaiters.filter { totalCount >= $0.count } + countWaiters.removeAll { totalCount >= $0.count } + for waiter in ready { waiter.continuation.resume() } + } +} diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index cafb386d19d0..4b1a16df3dbf 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -4812,6 +4812,23 @@ } } }, + "mobile.settings.cmuxLabs": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "CMUX Labs" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "CMUX ラボ" + } + } + } + }, "mobile.settings.developer": { "extractionState": "manual", "localizations": { @@ -5305,6 +5322,74 @@ } } }, + "mobile.settings.shellIconLab": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shell Icon Lab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "シェルアイコンラボ" + } + } + } + }, + "mobile.settings.shellIconLab.footer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The first value is icon size. Stroke weights are 400 regular, 500 medium, and 600 semibold. α changes contrast; ○ changes circle size. Choose a variant, then reopen New Task." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最初の値はアイコンサイズです。線の太さは 400 がレギュラー、500 がミディアム、600 がセミボールドです。α はコントラスト、○ は円のサイズを変更します。バリエーションを選び、もう一度「新規タスク」を開いてください。" + } + } + } + }, + "mobile.settings.shellIconLab.selected": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Selected Shell Card" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択中のシェルカード" + } + } + } + }, + "mobile.settings.shellIconLab.variants": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Variants" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バリエーション" + } + } + } + }, "mobile.settings.unreadIndicatorLeftness": { "extractionState": "manual", "localizations": { @@ -5373,6 +5458,23 @@ } } }, + "mobile.settings.taskComposer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Task Composer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規タスク作成" + } + } + } + }, "mobile.settings.wrapTitles": { "extractionState": "manual", "localizations": { @@ -6444,1039 +6546,2612 @@ } } }, - "mobile.terminal.action.command": { + "mobile.terminal.action.command": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "⌘" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "⌘" + } + } + } + }, + "mobile.terminal.action.command.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Command" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Command" + } + } + } + }, + "mobile.terminal.action.control": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ctrl" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Ctrl" + } + } + } + }, + "mobile.terminal.action.control.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Control" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Control" + } + } + } + }, + "mobile.terminal.action.ctrlC": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "^C" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "^C" + } + } + } + }, + "mobile.terminal.action.ctrlC.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Control C" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Control C" + } + } + } + }, + "mobile.terminal.action.ctrlD": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "^D" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "^D" + } + } + } + }, + "mobile.terminal.action.ctrlD.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Control D" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Control D" + } + } + } + }, + "mobile.terminal.action.ctrlL": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "^L" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "^L" + } + } + } + }, + "mobile.terminal.action.ctrlL.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Control L" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Control L" + } + } + } + }, + "mobile.terminal.action.ctrlZ": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "^Z" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "^Z" + } + } + } + }, + "mobile.terminal.action.ctrlZ.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Control Z" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Control Z" + } + } + } + }, + "mobile.terminal.action.down": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "↓" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "↓" + } + } + } + }, + "mobile.terminal.action.down.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Down Arrow" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "下矢印" + } + } + } + }, + "mobile.terminal.action.end": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "End" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "End" + } + } + } + }, + "mobile.terminal.action.end.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "End" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "End" + } + } + } + }, + "mobile.terminal.action.escape": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Esc" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Esc" + } + } + } + }, + "mobile.terminal.action.escape.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Escape" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Escape" + } + } + } + }, + "mobile.terminal.action.hideKeyboard.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hide Keyboard" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キーボードを隠す" + } + } + } + }, + "mobile.terminal.action.home": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Home" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ホーム" + } + } + } + }, + "mobile.terminal.action.home.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Home" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ホーム" + } + } + } + }, + "mobile.terminal.action.left": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "←" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "←" + } + } + } + }, + "mobile.terminal.action.left.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Left Arrow" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "左矢印" + } + } + } + }, + "mobile.terminal.action.pageDown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "PgDn" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "PgDn" + } + } + } + }, + "mobile.terminal.action.pageDown.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Page Down" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Page Down" + } + } + } + }, + "mobile.terminal.action.pageUp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "PgUp" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "PgUp" + } + } + } + }, + "mobile.terminal.action.pageUp.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Page Up" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Page Up" + } + } + } + }, + "mobile.terminal.action.pipe": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "|" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "|" + } + } + } + }, + "mobile.terminal.action.pipe.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pipe" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パイプ" + } + } + } + }, + "mobile.terminal.action.return": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Return" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Return" + } + } + } + }, + "mobile.terminal.action.return.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Return" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Return" + } + } + } + }, + "mobile.terminal.action.right": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "→" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "→" + } + } + } + }, + "mobile.terminal.action.right.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Right Arrow" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右矢印" + } + } + } + }, + "mobile.terminal.action.shift": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "⇧" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "⇧" + } + } + } + }, + "mobile.terminal.action.shift.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shift" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Shift" + } + } + } + }, + "mobile.terminal.action.tab": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Tab" + } + } + } + }, + "mobile.terminal.action.tab.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Tab" + } + } + } + }, + "mobile.terminal.action.tilde": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "~" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "~" + } + } + } + }, + "mobile.terminal.action.tilde.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tilde" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チルダ" + } + } + } + }, + "mobile.terminal.action.up": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "↑" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "↑" + } + } + } + }, + "mobile.terminal.action.up.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Up Arrow" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "上矢印" + } + } + } + }, + "mobile.terminal.action.zoomIn.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Zoom In" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "拡大" + } + } + } + }, + "mobile.terminal.action.zoomOut.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Zoom Out" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "縮小" + } + } + } + }, + "mobile.terminal.arrowPad.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Arrow pad" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "矢印パッド" + } + } + } + }, + "mobile.terminal.inputPlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send to terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルに送信" + } + } + } + }, + "mobile.terminal.inputProxy.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal input" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナル入力" + } + } + } + }, + "mobile.terminal.inputQueueFull": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The terminal can't accept more input right now. Wait a moment and retry, or reopen the terminal if it stays unavailable." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルは現在これ以上の入力を受け付けられません。しばらくしてから再試行するか、利用できない状態が続く場合はターミナルを開き直してください。" + } + } + } + }, + "mobile.terminal.modifier.stickyFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ sticky" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ 固定" + } + } + } + }, + "mobile.terminal.new": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規ターミナル" + } + } + } + }, + "mobile.terminal.picker.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminals" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナル" + } + } + } + }, + "mobile.terminal.select": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナル" + } + } + } + }, + "mobile.terminal.send": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "送信" + } + } + } + }, + "mobile.terminal.surfaceNotReady": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal surface is still starting." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナル画面を起動しています。" + } + } + } + }, + "mobile.terminal.rendererFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal renderer failed to start." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルレンダラーを起動できませんでした。" + } + } + } + }, + "mobile.terminal.viewAsText": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "View as Text" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "テキストとして表示" + } + } + } + }, + "mobile.textSheet.copied": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copied" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コピーしました" + } + } + } + }, + "mobile.textSheet.copyAll": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy All" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべてコピー" + } + } + } + }, + "mobile.textSheet.done": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Done" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完了" + } + } + } + }, + "mobile.textSheet.empty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No terminal text available" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "表示できるターミナルテキストがありません" + } + } + } + }, + "mobile.textSheet.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal Text" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルのテキスト" + } + } + } + }, + "mobile.textSheet.truncated": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Showing the last %d lines." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最新の%d行を表示しています。" + } + } + } + }, + "mobile.taskComposer.action.caption": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Creates a workspace and sends your prompt immediately." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースを作成し、プロンプトをすぐに送信します。" + } + } + } + }, + "mobile.taskComposer.action.promptRequiredFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add a prompt to put %@ to work." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロンプトを入力すると%@を起動できます。" + } + } + } + }, + "mobile.taskComposer.action.routeCaptionFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New workspace on %1$@ in %2$@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいワークスペースを%1$@の%2$@に作成します。" + } + } + } + }, + "mobile.taskComposer.action.shellCaption": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Opens a workspace with an interactive shell." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "対話型シェルでワークスペースを開きます。" + } + } + } + }, + "mobile.taskComposer.agent": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Agent" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェント" + } + } + } + }, + "mobile.taskComposer.agent.edit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit Agents" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントを編集" + } + } + } + }, + "mobile.taskComposer.button.accessibilityHint": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Opens the task composer." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タスク作成画面を開きます。" + } + } + } + }, + "mobile.taskComposer.button.accessibilityLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Task" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規タスク" + } + } + } + }, + "mobile.taskComposer.button.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New task" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいタスク" + } + } + } + }, + "mobile.taskComposer.create": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "作成" + } + } + } + }, + "mobile.taskComposer.create.accessibilityHint": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Starts this task in a new workspace on the selected Mac." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択したMacの新しいワークスペースでこのタスクを開始します。" + } + } + } + }, + "mobile.taskComposer.creating": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Creating Task" } }, + "ja": { "stringUnit": { "state": "translated", "value": "タスクを作成中" } } + } + }, + "mobile.taskComposer.directory": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Directory" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ディレクトリ" + } + } + } + }, + "mobile.taskComposer.debug.draftMissing": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"missing"}},"ja":{"stringUnit":{"state":"translated","value":"未保存"}}}}, + "mobile.taskComposer.debug.draftPersisted": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"persisted"}},"ja":{"stringUnit":{"state":"translated","value":"保存済み"}}}}, + "mobile.taskComposer.directoryPicker.browse.computer": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Computer"}},"ja":{"stringUnit":{"state":"translated","value":"コンピュータ"}}}}, + "mobile.taskComposer.directoryPicker.browse.empty.message": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"You can still use this folder for the new workspace."}},"ja":{"stringUnit":{"state":"translated","value":"このフォルダは新しいワークスペースにそのまま使用できます。"}}}}, + "mobile.taskComposer.directoryPicker.browse.empty.title": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"No Subfolders"}},"ja":{"stringUnit":{"state":"translated","value":"サブフォルダはありません"}}}}, + "mobile.taskComposer.directoryPicker.browse.failure.access.title": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Folder Access Needed"}},"ja":{"stringUnit":{"state":"translated","value":"フォルダへのアクセスが必要です"}}}}, + "mobile.taskComposer.directoryPicker.browse.failure.generic": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"The Mac could not list this folder. Try again."}},"ja":{"stringUnit":{"state":"translated","value":"Macでこのフォルダの一覧を取得できませんでした。もう一度お試しください。"}}}}, + "mobile.taskComposer.directoryPicker.browse.failure.invalid": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Choose another location and try again."}},"ja":{"stringUnit":{"state":"translated","value":"別の場所を選択して、もう一度お試しください。"}}}}, + "mobile.taskComposer.directoryPicker.browse.failure.missing": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"This folder moved or no longer exists. Choose another location."}},"ja":{"stringUnit":{"state":"translated","value":"このフォルダは移動されたか、存在しません。別の場所を選択してください。"}}}}, + "mobile.taskComposer.directoryPicker.browse.failure.permission": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Allow cmux to access this location on the Mac, then retry."}},"ja":{"stringUnit":{"state":"translated","value":"Macでcmuxにこの場所へのアクセスを許可してから、もう一度お試しください。"}}}}, + "mobile.taskComposer.directoryPicker.browse.failure.timeout": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"This folder took too long to load. Check the Mac or network volume, then retry."}},"ja":{"stringUnit":{"state":"translated","value":"このフォルダの読み込みに時間がかかりすぎました。Macまたはネットワークボリュームを確認して、もう一度お試しください。"}}}}, + "mobile.taskComposer.directoryPicker.browse.failure.title": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Couldn’t Open Folder"}},"ja":{"stringUnit":{"state":"translated","value":"フォルダを開けませんでした"}}}}, + "mobile.taskComposer.directoryPicker.browse.failure.unreadable": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"The Mac can see this folder but cannot read its contents."}},"ja":{"stringUnit":{"state":"translated","value":"Macはこのフォルダを認識していますが、内容を読み取れません。"}}}}, + "mobile.taskComposer.directoryPicker.browse.failure.update.message": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Install the latest cmux on the Mac to browse every accessible folder."}},"ja":{"stringUnit":{"state":"translated","value":"Macのcmuxを最新版に更新すると、アクセス可能なすべてのフォルダを参照できます。"}}}}, + "mobile.taskComposer.directoryPicker.browse.failure.update.title": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Update cmux on This Mac"}},"ja":{"stringUnit":{"state":"translated","value":"このMacのcmuxを更新してください"}}}}, + "mobile.taskComposer.directoryPicker.browse.home": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Home"}},"ja":{"stringUnit":{"state":"translated","value":"ホーム"}}}}, + "mobile.taskComposer.directoryPicker.browse.loading": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Loading folders…"}},"ja":{"stringUnit":{"state":"translated","value":"フォルダを読み込み中…"}}}}, + "mobile.taskComposer.directoryPicker.browse.more": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Show More"}},"ja":{"stringUnit":{"state":"translated","value":"さらに表示"}}}}, + "mobile.taskComposer.directoryPicker.browse.more.failure.title": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Couldn’t Load More Folders"}},"ja":{"stringUnit":{"state":"translated","value":"追加のフォルダを読み込めませんでした"}}}}, + "mobile.taskComposer.directoryPicker.browse.open.hint": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Shows the folders inside this folder."}},"ja":{"stringUnit":{"state":"translated","value":"このフォルダ内のフォルダを表示します。"}}}}, + "mobile.taskComposer.directoryPicker.browse.parent": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Parent Folder"}},"ja":{"stringUnit":{"state":"translated","value":"親フォルダ"}}}}, + "mobile.taskComposer.directoryPicker.browse.useFormat": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Use “%@”"}},"ja":{"stringUnit":{"state":"translated","value":"“%@”を使用"}}}}, + "mobile.taskComposer.directoryPicker.search.coverage": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Search checks indexed folders across mounted volumes. Browse to reach unindexed or restricted locations."}},"ja":{"stringUnit":{"state":"translated","value":"検索では、マウントされたボリューム内のインデックス登録済みフォルダを確認します。インデックス未登録またはアクセス制限のある場所は、参照して探してください。"}}}}, + "mobile.taskComposer.directoryPicker.search.empty.message": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Browse the Mac to reach folders outside its search index."}},"ja":{"stringUnit":{"state":"translated","value":"Macを参照すると、検索インデックス外のフォルダに移動できます。"}}}}, + "mobile.taskComposer.directoryPicker.search.empty.title": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"No Indexed Matches"}},"ja":{"stringUnit":{"state":"translated","value":"インデックス内に一致する項目はありません"}}}}, + "mobile.taskComposer.directoryPicker.search.limited": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"This Mac returned limited search results. Browse to reach every accessible folder."}},"ja":{"stringUnit":{"state":"translated","value":"このMacから返された検索結果は限定されています。アクセス可能なすべてのフォルダは参照して探せます。"}}}}, + "mobile.taskComposer.directoryPicker.search.partial": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"The Mac search index did not finish in time. Refine your search or retry."}},"ja":{"stringUnit":{"state":"translated","value":"Macの検索インデックスが時間内に完了しませんでした。検索語を絞るか、もう一度お試しください。"}}}}, + "mobile.taskComposer.directoryPicker.search.truncated": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"More indexed folders match. Refine your search to see them."}},"ja":{"stringUnit":{"state":"translated","value":"一致するインデックス登録済みフォルダがさらにあります。検索語を絞り込んでください。"}}}}, + "mobile.taskComposer.directoryPicker.empty.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Try a folder name from another workspace or a recent task." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "別のワークスペースまたは最近のタスクのフォルダ名を入力してください。" + } + } + } + }, + "mobile.taskComposer.directoryPicker.empty.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No Matching Folders" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一致するフォルダがありません" + } + } + } + }, + "mobile.taskComposer.directoryPicker.failure.authorization": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Sign in again on this device and Mac, then retry." } }, + "ja": { "stringUnit": { "state": "translated", "value": "このデバイスとMacで再度サインインしてから、もう一度お試しください。" } } + } + }, + "mobile.taskComposer.directoryPicker.failure.generic": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The folder search failed. Try again." } }, + "ja": { "stringUnit": { "state": "translated", "value": "フォルダの検索に失敗しました。もう一度お試しください。" } } + } + }, + "mobile.taskComposer.directoryPicker.failure.timeout": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "This Mac took too long to search. Try again." } }, + "ja": { "stringUnit": { "state": "translated", "value": "このMacの検索がタイムアウトしました。もう一度お試しください。" } } + } + }, + "mobile.taskComposer.directoryPicker.failure.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Couldn’t Search Folders" } }, + "ja": { "stringUnit": { "state": "translated", "value": "フォルダを検索できませんでした" } } + } + }, + "mobile.taskComposer.directoryPicker.failure.unavailable": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Reconnect to this Mac, then try again." } }, + "ja": { "stringUnit": { "state": "translated", "value": "このMacに再接続してから、もう一度お試しください。" } } + } + }, + "mobile.taskComposer.directoryPicker.failure.unsupported": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Update cmux on this Mac to search its folders. You can still choose a recent location."}},"ja":{"stringUnit":{"state":"translated","value":"このMacのフォルダを検索するにはcmuxを更新してください。最近使った場所は引き続き選べます。"}}}}, + "mobile.taskComposer.directoryPicker.hint": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browses and searches folders on this Mac." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このMac上のフォルダを参照、検索します。" + } + } + } + }, + "mobile.taskComposer.directoryPicker.result.hint": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Uses this folder for the new workspace." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいワークスペースでこのフォルダを使用します。" + } + } + } + }, + "mobile.taskComposer.directoryPicker.search": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search folders" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダを検索" + } + } + } + }, + "mobile.taskComposer.directoryPicker.searching": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Searching this Mac…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このMacを検索中…" + } + } + } + }, + "mobile.taskComposer.directoryPicker.source.activeTerminal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Focused terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォーカス中のターミナル" + } + } + } + }, + "mobile.taskComposer.directoryPicker.source.activeWorkspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Current workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のワークスペース" + } + } + } + }, + "mobile.taskComposer.directoryPicker.source.filesystem": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "On this Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このMac上" + } + } + } + }, + "mobile.taskComposer.directoryPicker.source.home": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Home folder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ホームフォルダ" + } + } + } + }, + "mobile.taskComposer.directoryPicker.source.last": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "⌘" + "value": "Last used" } }, "ja": { "stringUnit": { "state": "translated", - "value": "⌘" + "value": "前回使用" } } } }, - "mobile.terminal.action.command.label": { + "mobile.taskComposer.directoryPicker.source.open": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Command" + "value": "Open on this Mac" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Command" + "value": "このMacで開いています" } } } }, - "mobile.terminal.action.control": { + "mobile.taskComposer.directoryPicker.source.recent": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Ctrl" + "value": "Recent task" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Ctrl" + "value": "最近のタスク" } } } }, - "mobile.terminal.action.control.label": { + "mobile.taskComposer.directoryPicker.source.template": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Control" + "value": "Template default" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Control" + "value": "テンプレートのデフォルト" } } } }, - "mobile.terminal.action.ctrlC": { + "mobile.taskComposer.directoryPicker.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "^C" + "value": "Choose Folder" } }, "ja": { "stringUnit": { "state": "translated", - "value": "^C" + "value": "フォルダを選択" } } } }, - "mobile.terminal.action.ctrlC.label": { + "mobile.taskComposer.failure.alreadyCompleted": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Control C" + "value": "The Mac already accepted this task. Refresh workspaces before trying again." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Control C" + "value": "Macはこのタスクをすでに受け付けています。ワークスペースを更新してから、もう一度お試しください。" } } } }, - "mobile.terminal.action.ctrlD": { + "mobile.taskComposer.failure.authorization": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "^D" + "value": "That Mac did not authorize the request." } }, "ja": { "stringUnit": { "state": "translated", - "value": "^D" + "value": "そのMacはリクエストを許可しませんでした。" } } } }, - "mobile.terminal.action.ctrlD.label": { + "mobile.taskComposer.failure.busy": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Control D" + "value": "Another workspace action is still finishing." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Control D" + "value": "別のワークスペース操作がまだ完了していません。" } } } }, - "mobile.terminal.action.ctrlL": { + "mobile.taskComposer.failure.draftPersistence": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "^L" + "value": "cmux couldn’t save this draft safely. Reopen the composer and try again." } }, "ja": { "stringUnit": { "state": "translated", - "value": "^L" + "value": "この下書きを安全に保存できませんでした。タスク作成画面を開き直して、もう一度お試しください。" } } } }, - "mobile.terminal.action.ctrlL.label": { + "mobile.taskComposer.failure.invalidWorkingDirectory": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Control L" + "value": "Choose an existing folder on that Mac." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Control L" + "value": "そのMac上の既存のフォルダを選択してください。" } } } }, - "mobile.terminal.action.ctrlZ": { + "mobile.taskComposer.failure.notConnected": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "^Z" + "value": "That Mac is not connected." } }, "ja": { "stringUnit": { "state": "translated", - "value": "^Z" + "value": "そのMacに接続されていません。" } } } }, - "mobile.terminal.action.ctrlZ.label": { + "mobile.taskComposer.failure.persistence": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Control Z" + "value": "The Mac could not safely reserve this task." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Control Z" + "value": "Macでこのタスクを安全に予約できませんでした。" } } } }, - "mobile.terminal.action.down": { + "mobile.taskComposer.failure.rejected": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "↓" + "value": "The Mac rejected the task." } }, "ja": { "stringUnit": { "state": "translated", - "value": "↓" + "value": "Macがタスクを拒否しました。" } } } }, - "mobile.terminal.action.down.label": { + "mobile.taskComposer.failure.timedOut": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Down Arrow" + "value": "The Mac did not respond in time." } }, "ja": { "stringUnit": { "state": "translated", - "value": "下矢印" + "value": "Macから時間内に応答がありませんでした。" } } } }, - "mobile.terminal.action.end": { + "mobile.taskComposer.failure.unsupported": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "End" + "value": "That Mac does not support this action." } }, "ja": { "stringUnit": { "state": "translated", - "value": "End" + "value": "そのMacはこの操作に対応していません。" } } } }, - "mobile.terminal.action.end.label": { + "mobile.taskComposer.hero.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "End" + "value": "Describe the outcome. cmux opens a workspace and puts your agent to work." } }, "ja": { "stringUnit": { "state": "translated", - "value": "End" + "value": "実現したいことを入力すると、cmuxがワークスペースを開き、エージェントを起動します。" } } } }, - "mobile.terminal.action.escape": { + "mobile.taskComposer.hero.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Esc" + "value": "Start an agent" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Esc" + "value": "エージェントを起動" } } } }, - "mobile.terminal.action.escape.label": { + "mobile.taskComposer.machine": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Escape" + "value": "Machine" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Escape" + "value": "マシン" } } } }, - "mobile.terminal.action.hideKeyboard.label": { + "mobile.taskComposer.machine.accessibilityHint": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Hide Keyboard" + "value": "Chooses the Mac that will run this task." } }, "ja": { "stringUnit": { "state": "translated", - "value": "キーボードを隠す" + "value": "このタスクを実行するMacを選択します。" } } } }, - "mobile.terminal.action.home": { + "mobile.taskComposer.machine.none": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "No paired Macs" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ペアリング済みのMacがありません" } } + } + }, + "mobile.taskComposer.openingShell": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Home" + "value": "Opening Shell…" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ホーム" + "value": "シェルを開いています…" } } } }, - "mobile.terminal.action.home.label": { + "mobile.taskComposer.openShell": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Home" + "value": "Open Shell" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ホーム" + "value": "シェルを開く" } } } }, - "mobile.terminal.action.left": { + "mobile.taskComposer.preparingWorkspace": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "←" + "value": "Preparing workspace…" } }, "ja": { "stringUnit": { "state": "translated", - "value": "←" + "value": "ワークスペースを準備中…" } } } }, - "mobile.terminal.action.left.label": { + "mobile.taskComposer.prompt": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Left Arrow" + "value": "Prompt" } }, "ja": { "stringUnit": { "state": "translated", - "value": "左矢印" + "value": "プロンプト" } } } }, - "mobile.terminal.action.pageDown": { + "mobile.taskComposer.promptPlaceholder": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "PgDn" + "value": "Describe what you want to accomplish" } }, "ja": { "stringUnit": { "state": "translated", - "value": "PgDn" + "value": "実現したいことを入力" } } } }, - "mobile.terminal.action.pageDown.label": { + "mobile.taskComposer.promptPlaceholder.agentFormat": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Page Down" + "value": "Tell %@ what to build, fix, or investigate" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Page Down" + "value": "%@にビルド、修正、調査してほしい内容を入力" } } } }, - "mobile.terminal.action.pageUp": { + "mobile.taskComposer.promptPlaceholder.shell": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "PgUp" + "value": "Describe what you want to run" } }, "ja": { "stringUnit": { "state": "translated", - "value": "PgUp" + "value": "実行したい内容を入力" } } } }, - "mobile.terminal.action.pageUp.label": { + "mobile.taskComposer.promptTitle.agentFormat": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Page Up" + "value": "Ask %@" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Page Up" + "value": "%@に依頼" } } } }, - "mobile.terminal.action.pipe": { + "mobile.taskComposer.promptTitle.shell": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "|" + "value": "Shell command" } }, "ja": { "stringUnit": { "state": "translated", - "value": "|" + "value": "シェルコマンド" } } } }, - "mobile.terminal.action.pipe.label": { + "mobile.taskComposer.recovery.refresh": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Pipe" + "value": "Refresh Workspaces" } }, "ja": { "stringUnit": { "state": "translated", - "value": "パイプ" + "value": "ワークスペースを更新" } } } }, - "mobile.terminal.action.return": { + "mobile.taskComposer.recovery.refresh.accessibilityHint": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Return" + "value": "Checks the Mac for the task that was already accepted." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Return" + "value": "Macで、すでに受け付けたタスクを確認します。" } } } }, - "mobile.terminal.action.return.label": { + "mobile.taskComposer.recovery.refreshAgain": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Return" + "value": "Refresh Again" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Return" + "value": "もう一度更新" } } } }, - "mobile.terminal.action.right": { + "mobile.taskComposer.recovery.startAgain": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "→" + "value": "Start Again" } }, "ja": { "stringUnit": { "state": "translated", - "value": "→" + "value": "新しいタスクとして開始" } } } }, - "mobile.terminal.action.right.label": { + "mobile.taskComposer.recovery.startAgain.accessibilityHint": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Right Arrow" + "value": "Starts the same draft as a new task after confirmation." } }, "ja": { "stringUnit": { "state": "translated", - "value": "右矢印" + "value": "確認後、同じ下書きを新しいタスクとして開始します。" } } } }, - "mobile.terminal.action.shift": { + "mobile.taskComposer.recovery.startAgain.message": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "⇧" + "value": "Only continue if the task is not present. Starting again may create a duplicate." } }, "ja": { "stringUnit": { "state": "translated", - "value": "⇧" + "value": "タスクが見つからない場合のみ続行してください。再度開始すると重複する可能性があります。" } } } }, - "mobile.terminal.action.shift.label": { + "mobile.taskComposer.recovery.startAgain.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Shift" + "value": "Start this task again?" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Shift" + "value": "このタスクを新しく開始しますか?" } } } }, - "mobile.terminal.action.tab": { + "mobile.taskComposer.recovery.stillMissing": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Tab" + "value": "The task is still missing. Refresh again or start it as a new task." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Tab" + "value": "タスクがまだ見つかりません。もう一度更新するか、新しいタスクとして開始してください。" } } } }, - "mobile.terminal.action.tab.label": { + "mobile.taskComposer.template": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Tab" + "value": "Template" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Tab" + "value": "テンプレート" } } } }, - "mobile.terminal.action.tilde": { + "mobile.taskComposer.template.accessibilityHint": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "~" + "value": "Selects this agent or command for the task." } }, "ja": { "stringUnit": { "state": "translated", - "value": "~" + "value": "このタスクで使うエージェントまたはコマンドを選択します。" } } } }, - "mobile.terminal.action.tilde.label": { + "mobile.taskComposer.template.add": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Tilde" + "value": "Add Template" } }, "ja": { "stringUnit": { "state": "translated", - "value": "チルダ" + "value": "テンプレートを追加" } } } }, - "mobile.terminal.action.up": { + "mobile.taskComposer.template.addTitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "↑" + "value": "Add Template" } }, "ja": { "stringUnit": { "state": "translated", - "value": "↑" + "value": "テンプレートを追加" } } } }, - "mobile.terminal.action.up.label": { + "mobile.taskComposer.template.command": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Up Arrow" + "value": "Command" } }, "ja": { "stringUnit": { "state": "translated", - "value": "上矢印" + "value": "コマンド" } } } }, - "mobile.terminal.action.zoomIn.label": { + "mobile.taskComposer.template.commandPlaceholder": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Zoom In" + "value": "claude -- \"$CMUX_TASK_PROMPT\"" } }, "ja": { "stringUnit": { "state": "translated", - "value": "拡大" + "value": "claude -- \"$CMUX_TASK_PROMPT\"" } } } }, - "mobile.terminal.action.zoomOut.label": { + "mobile.taskComposer.template.details": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Zoom Out" + "value": "Details" } }, "ja": { "stringUnit": { "state": "translated", - "value": "縮小" + "value": "詳細" } } } }, - "mobile.terminal.arrowPad.label": { + "mobile.taskComposer.template.editTitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Arrow pad" + "value": "Edit Template" } }, "ja": { "stringUnit": { "state": "translated", - "value": "矢印パッド" + "value": "テンプレートを編集" } } } }, - "mobile.terminal.inputPlaceholder": { + "mobile.taskComposer.template.hint": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Send to terminal" + "value": "The task prompt is available to the command as $CMUX_TASK_PROMPT. Example: claude -- \"$CMUX_TASK_PROMPT\"" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ターミナルに送信" + "value": "タスクプロンプトは $CMUX_TASK_PROMPT としてコマンドで使用できます。例: claude -- \"$CMUX_TASK_PROMPT\"" } } } }, - "mobile.terminal.inputProxy.label": { + "mobile.taskComposer.template.iconEmoji": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Terminal input" + "value": "Custom emoji" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ターミナル入力" + "value": "カスタム絵文字" } } } }, - "mobile.terminal.inputQueueFull": { + "mobile.taskComposer.template.icon.accessibilityHint": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "The terminal can't accept more input right now. Wait a moment and retry, or reopen the terminal if it stays unavailable." + "value": "Selects this icon for the template." } }, "ja": { "stringUnit": { "state": "translated", - "value": "ターミナルは現在これ以上の入力を受け付けられません。しばらくしてから再試行するか、利用できない状態が続く場合はターミナルを開き直してください。" + "value": "テンプレートに使用するアイコンを選択します。" } } } }, - "mobile.terminal.modifier.stickyFormat": { + "mobile.taskComposer.template.icon.bolt": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Bolt" } }, + "ja": { "stringUnit": { "state": "translated", "value": "稲妻" } } + } + }, + "mobile.taskComposer.template.icon.bug": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Bug" } }, + "ja": { "stringUnit": { "state": "translated", "value": "バグ" } } + } + }, + "mobile.taskComposer.template.icon.document": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Document" } }, + "ja": { "stringUnit": { "state": "translated", "value": "書類" } } + } + }, + "mobile.taskComposer.template.icon.folder": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Folder" } }, + "ja": { "stringUnit": { "state": "translated", "value": "フォルダ" } } + } + }, + "mobile.taskComposer.template.icon.globe": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Globe" } }, + "ja": { "stringUnit": { "state": "translated", "value": "地球" } } + } + }, + "mobile.taskComposer.template.icon.hammer": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Hammer" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ハンマー" } } + } + }, + "mobile.taskComposer.template.icon.package": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Package" } }, + "ja": { "stringUnit": { "state": "translated", "value": "パッケージ" } } + } + }, + "mobile.taskComposer.template.icon.terminal": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Terminal" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ターミナル" } } + } + }, + "mobile.taskComposer.template.icon.test": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Test" } }, + "ja": { "stringUnit": { "state": "translated", "value": "テスト" } } + } + }, + "mobile.taskComposer.template.icon.tools": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Tools" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ツール" } } + } + }, + "mobile.taskComposer.template.name": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "%@ sticky" + "value": "Name" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@ 固定" + "value": "名前" } } } }, - "mobile.terminal.new": { + "mobile.taskComposer.template.plainShell": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "New Terminal" + "value": "Plain shell" } }, "ja": { "stringUnit": { "state": "translated", - "value": "新規ターミナル" + "value": "通常のシェル" } } } }, - "mobile.terminal.picker.title": { + "mobile.taskComposer.startAgentFormat": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Terminals" + "value": "Start %@" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ターミナル" + "value": "%@を起動" } } } }, - "mobile.terminal.select": { + "mobile.taskComposer.startingAgentFormat": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Terminal" + "value": "Starting %@…" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ターミナル" + "value": "%@を起動中…" } } } }, - "mobile.terminal.send": { + "mobile.taskComposer.startingTask": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Send" + "value": "Starting Task…" } }, "ja": { "stringUnit": { "state": "translated", - "value": "送信" + "value": "タスクを開始中…" } } } }, - "mobile.terminal.surfaceNotReady": { + "mobile.taskComposer.startTask": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Terminal surface is still starting." + "value": "Start Task" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ターミナル画面を起動しています。" + "value": "タスクを開始" } } } }, - "mobile.terminal.rendererFailed": { + "mobile.taskComposer.template.seed.claude": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Terminal renderer failed to start." + "value": "Claude" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ターミナルレンダラーを起動できませんでした。" + "value": "Claude" } } } }, - "mobile.terminal.viewAsText": { + "mobile.taskComposer.template.seed.codex": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "View as Text" + "value": "Codex" } }, "ja": { "stringUnit": { "state": "translated", - "value": "テキストとして表示" + "value": "Codex" } } } }, - "mobile.textSheet.copied": { + "mobile.taskComposer.template.seed.opencode": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Copied" + "value": "OpenCode" } }, "ja": { "stringUnit": { "state": "translated", - "value": "コピーしました" + "value": "OpenCode" } } } }, - "mobile.textSheet.copyAll": { + "mobile.taskComposer.template.seed.shell": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Copy All" + "value": "Shell" } }, "ja": { "stringUnit": { "state": "translated", - "value": "すべてコピー" + "value": "シェル" } } } }, - "mobile.textSheet.done": { + "mobile.taskComposer.templates.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Done" + "value": "Task Templates" } }, "ja": { "stringUnit": { "state": "translated", - "value": "完了" + "value": "タスクテンプレート" } } } }, - "mobile.textSheet.empty": { + "mobile.taskComposer.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No terminal text available" + "value": "New Task" } }, "ja": { "stringUnit": { "state": "translated", - "value": "表示できるターミナルテキストがありません" + "value": "新規タスク" } } } }, - "mobile.textSheet.title": { + "mobile.taskComposer.tryAgain": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Terminal Text" + "value": "Try Again" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ターミナルのテキスト" + "value": "もう一度試す" } } } }, - "mobile.textSheet.truncated": { + "mobile.taskComposer.validation.machine": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Pair a Mac before starting a task." } }, + "ja": { "stringUnit": { "state": "translated", "value": "タスクを開始する前にMacをペアリングしてください。" } } + } + }, + "mobile.taskComposer.validation.template": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Add an agent before starting a task." } }, + "ja": { "stringUnit": { "state": "translated", "value": "エージェントを追加してからタスクを開始してください。" } } + } + }, + "mobile.taskComposer.workspace": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Showing the last %d lines." + "value": "Workspace" } }, "ja": { "stringUnit": { "state": "translated", - "value": "最新の%d行を表示しています。" + "value": "ワークスペース" } } } @@ -10286,6 +11961,27 @@ } } }, + "mobile.workspaceAction.failure.reason.invalidWorkingDirectory": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "the working directory isn't available on your Mac; choose another directory" } }, + "ja": { "stringUnit": { "state": "translated", "value": "Mac で作業ディレクトリを使用できません。別のディレクトリを選択してください" } } + } + }, + "mobile.workspaceAction.failure.reason.alreadyCompleted": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "your Mac already accepted the request; refresh workspaces before trying again" } }, + "ja": { "stringUnit": { "state": "translated", "value": "Macはリクエストをすでに受け付けています。ワークスペースを更新してから、もう一度お試しください" } } + } + }, + "mobile.workspaceAction.failure.reason.persistence": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "your Mac could not safely reserve the request" } }, + "ja": { "stringUnit": { "state": "translated", "value": "Macでリクエストを安全に予約できませんでした" } } + } + }, "mobile.workspaceAction.failure.reason.unsupported.host": { "extractionState": "manual", "localizations": { diff --git a/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift b/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift index 6623717efad4..25333fc4241a 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift @@ -141,6 +141,15 @@ public struct CMUXMobileRootScene: View { private static func openPairedMacStore() -> (any MobilePairedMacStoring)? { do { + #if DEBUG + if UITestConfig.mockDataEnabled { + let databaseURL = FileManager.default.temporaryDirectory + .appendingPathComponent( + "cmux-uitest-paired-macs-\(UUID().uuidString).sqlite3" + ) + return try MobilePairedMacStore(databaseURL: databaseURL) + } + #endif return try MobilePairedMacStore() } catch { mobileRootSceneLog.error( @@ -285,7 +294,9 @@ public struct CMUXMobileRootScene: View { private var content: some View { #if os(iOS) #if DEBUG - if UITestConfig.workspaceListLayoutPreviewEnabled { + if UITestConfig.taskComposerPreviewEnabled { + TaskComposerAccessibilityPreviewView() + } else if UITestConfig.workspaceListLayoutPreviewEnabled { WorkspaceListLayoutPreviewView() } else if let recoveryStress = MobileRecoveryStressConfiguration.parse(arguments: ProcessInfo.processInfo.arguments) { MobileRecoveryStressView(configuration: recoveryStress) @@ -351,7 +362,8 @@ public struct CMUXMobileRootScene: View { diagnosticLog: diagnosticLog, feedbackEmailSubmitter: feedbackEmailSubmitter, feedbackStampProvider: feedbackStampProvider, - draftStore: draftStore + draftStore: draftStore, + taskTemplateStore: UserDefaultsMobileTaskTemplateStore(defaults: .standard) ) } } diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index 882354fa804e..368cfb4f7f91 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -903,6 +903,54 @@ final class TerminalOutputCollector { } } +@MainActor +@Test func manualHostPairingRefreshesPairedMacPresentationImmediately() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let pairedMacStore = try MobilePairedMacStore( + databaseURL: directory.appendingPathComponent("paired-macs.sqlite3") + ) + let attachRoute = try hostPortRoute( + kind: .debugLoopback, + host: "127.0.0.1", + port: CmxMobileDefaults.defaultHostPort + ) + let responses = ScriptedTransportResponses([ + try rpcAttachTicketFrame(route: attachRoute, workspaceID: "local-workspace"), + try rpcWorkspaceListFrame(workspaceID: "local-workspace", title: "Local Workspace"), + ]) + let runtime = testRuntime( + supportedRouteKinds: [.debugLoopback], + transportFactory: ScriptedTransportFactory(responses: responses) + ) + let store = CMUXMobileShellStore( + runtime: runtime, + workspaces: PreviewMobileHost.workspaces, + pairedMacStore: pairedMacStore, + identityProvider: TestIdentityProvider( + currentUserIDValue: "phone-user", + currentUserEmailValue: "phone@example.com" + ) + ) + + store.signIn() + #expect(store.pairedMacs.isEmpty) + + await store.connectManualHost( + name: "", + host: "127.0.0.1", + port: CmxMobileDefaults.defaultHostPort + ) + + #expect(store.connectionState == .connected) + #expect(store.pairedMacs.map(\.macDeviceID) == ["test-mac"]) +} + @MainActor @Test func manualHostPairingToLoopbackStillDialsWhileOffline() async throws { // Loopback needs no external network path (simulator/dev pairing to diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index 16bb31d40633..18a358bed67d 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -8,6 +8,22 @@ final class cmuxUITests: XCTestCase { continueAfterFailure = false } + func testMockHostInstanceTagFollowsTargetBuildScope() { + XCTAssertEqual( + mockHostInstanceTag( + testBundleIdentifier: "dev.cmux.ios.spark" + ), + "spark" + ) + XCTAssertEqual( + mockHostInstanceTag( + testBundleIdentifier: "dev.cmux.ios.uitests" + ), + "dev" + ) + XCTAssertNotEqual(mockHostInstanceTag(), "uitests") + } + @MainActor func testStackAuthEntryUsesStableIdentifiers() throws { let app = launchApp(mockData: false, clearAuth: true) @@ -121,6 +137,807 @@ final class cmuxUITests: XCTestCase { add(attachment) } + /// Regression: every task-composer action must remain discoverable through + /// the accessibility hierarchy, and its exposed activation frame must meet + /// Apple's 44-point minimum on both compact and regular-width layouts. + @MainActor + func testTaskComposerExposesPrimaryActionAndMinimumControlTargets() throws { + let app = launchApp(mockData: false, environment: [ + "CMUX_UITEST_TASK_COMPOSER_PREVIEW": "1", + ]) + defer { app.terminate() } + + XCTAssertTrue(app.textFields["MobileTaskComposerPrompt"].waitForExistence(timeout: 8)) + + let create = app.buttons["MobileTaskComposerCreateButton"] + XCTAssertTrue( + create.waitForExistence(timeout: 4), + "The visible launch action must be present in the accessibility hierarchy" + ) + XCTAssertEqual(create.label, "Start Claude") + XCTAssertGreaterThanOrEqual( + create.frame.height, + 44, + "The launch action must expose at least a 44-point activation frame" + ) + + let agentMenu = app.buttons["MobileTaskComposerAgentMenu"] + XCTAssertTrue(agentMenu.waitForExistence(timeout: 2)) + XCTAssertGreaterThanOrEqual( + agentMenu.frame.height, + 44, + "The selected agent menu must expose at least a 44-point activation frame" + ) + tap(agentMenu, in: app) + for name in ["Claude", "Codex", "OpenCode", "Shell"] { + let template = app.buttons[name] + XCTAssertTrue( + template.waitForExistence(timeout: 2), + "The \(name) template must be present in the accessibility hierarchy" + ) + } + XCTAssertTrue(app.buttons["MobileTaskComposerEditTemplatesButton"].exists) + tapMenuItem(app.buttons["Claude"], in: app) + + for identifier in [ + "MobileTaskComposerMachineMenu", + "MobileTaskComposerDirectory", + ] { + let control = app.buttons[identifier] + XCTAssertTrue(control.waitForExistence(timeout: 2)) + XCTAssertGreaterThanOrEqual(control.frame.height, 44) + } + } + + /// The composer keeps the launch route visible while the prompt receives + /// focus, so the user can verify the agent, Mac, and folder while typing. + @MainActor + func testTaskComposerOpensFocusedWithPersonalizedLaunchAction() throws { + let app = launchApp(mockData: false, environment: [ + "CMUX_UITEST_TASK_COMPOSER_PREVIEW": "1", + ]) + defer { app.terminate() } + + XCTAssertTrue(app.textFields["MobileTaskComposerPrompt"].waitForExistence(timeout: 8)) + let keyboard = app.keyboards.firstMatch + XCTAssertTrue(keyboard.waitForExistence(timeout: 3)) + let prompt = app.textFields["MobileTaskComposerPrompt"] + let machine = app.buttons["MobileTaskComposerMachineMenu"] + let directory = app.buttons["MobileTaskComposerDirectory"] + XCTAssertTrue(machine.exists) + XCTAssertTrue(directory.exists) + XCTAssertGreaterThanOrEqual( + prompt.frame.height, + 100, + "The prompt must remain the dominant keyboard-up surface" + ) + XCTAssertLessThanOrEqual(prompt.frame.maxY, machine.frame.minY) + XCTAssertLessThanOrEqual(prompt.frame.maxY, directory.frame.minY) + XCTAssertLessThanOrEqual(machine.frame.maxY, keyboard.frame.minY) + XCTAssertLessThanOrEqual(directory.frame.maxY, keyboard.frame.minY) + XCTAssertEqual(app.buttons["MobileTaskComposerCreateButton"].label, "Start Claude") + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "task-composer-prompt-first" + attachment.lifetime = .keepAlways + add(attachment) + } + + /// Accessibility text sizes must preserve a useful prompt canvas instead + /// of allowing the persistent action to consume most of the visible sheet. + @MainActor + func testTaskComposerAccessibilityXXXLKeepsPrimaryActionCompact() throws { + let app = launchApp( + mockData: false, + environment: [ + "CMUX_UITEST_TASK_COMPOSER_PREVIEW": "1", + ], + launchArguments: [ + "-UIPreferredContentSizeCategoryName", + "UICTContentSizeCategoryAccessibilityXXXL", + ] + ) + defer { app.terminate() } + + let prompt = app.textFields["MobileTaskComposerPrompt"] + XCTAssertTrue(prompt.waitForExistence(timeout: 8)) + let agentMenu = app.buttons["MobileTaskComposerAgentMenu"] + XCTAssertTrue(agentMenu.waitForExistence(timeout: 3)) + XCTAssertLessThanOrEqual( + agentMenu.frame.height, + 80, + "The selected agent control must stay compact at accessibility sizes" + ) + let machine = app.buttons["MobileTaskComposerMachineMenu"] + let directory = app.buttons["MobileTaskComposerDirectory"] + XCTAssertTrue(machine.waitForExistence(timeout: 3)) + XCTAssertTrue(directory.waitForExistence(timeout: 3)) + XCTAssertGreaterThanOrEqual( + prompt.frame.minY, + app.navigationBars["New Task"].frame.maxY, + "Prompt focus must not scroll the prompt behind the navigation title" + ) + XCTAssertLessThanOrEqual(prompt.frame.maxY, machine.frame.minY) + XCTAssertLessThanOrEqual(prompt.frame.maxY, directory.frame.minY) + let keyboard = app.keyboards.firstMatch + XCTAssertTrue(keyboard.waitForExistence(timeout: 3)) + let create = app.buttons["MobileTaskComposerCreateButton"] + XCTAssertTrue(create.waitForExistence(timeout: 3)) + XCTAssertLessThanOrEqual( + create.frame.height, + 80, + "The persistent action must not become a multi-line panel at Accessibility XXXL" + ) + XCTAssertLessThanOrEqual( + create.frame.maxY, + keyboard.frame.minY, + "The primary action must remain fully visible above the keyboard" + ) + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "task-composer-accessibility-xxxl" + attachment.lifetime = .keepAlways + add(attachment) + } + + /// Agent templates need an instruction before launch, while the plain + /// shell remains a useful zero-prompt workspace shortcut. + @MainActor + func testTaskComposerRequiresAgentPromptButAllowsEmptyShell() throws { + let app = launchApp(mockData: false, environment: [ + "CMUX_UITEST_TASK_COMPOSER_PREVIEW": "1", + ]) + defer { app.terminate() } + + let prompt = app.textFields["MobileTaskComposerPrompt"] + XCTAssertTrue(prompt.waitForExistence(timeout: 8)) + let create = app.buttons["MobileTaskComposerCreateButton"] + XCTAssertTrue(create.waitForExistence(timeout: 3)) + XCTAssertEqual(create.label, "Start Claude") + XCTAssertFalse(create.isEnabled) + XCTAssertEqual( + app.staticTexts["MobileTaskComposerActionCaption"].label, + "Add a prompt to put Claude to work." + ) + + selectTaskComposerAgent(named: "Shell", in: app) + let shellReady = NSPredicate(format: "label == %@ AND enabled == true", "Open Shell") + expectation(for: shellReady, evaluatedWith: create) + waitForExpectations(timeout: 3) + XCTAssertEqual( + app.staticTexts["MobileTaskComposerActionCaption"].label, + "New workspace on Preview Mac in ~." + ) + + selectTaskComposerAgent(named: "Claude", in: app) + let agentNeedsPrompt = NSPredicate(format: "label == %@ AND enabled == false", "Start Claude") + expectation(for: agentNeedsPrompt, evaluatedWith: create) + waitForExpectations(timeout: 3) + + tap(prompt, in: app) + prompt.typeText("Fix the race") + let agentReady = NSPredicate(format: "label == %@ AND enabled == true", "Start Claude") + expectation(for: agentReady, evaluatedWith: create) + waitForExpectations(timeout: 3) + XCTAssertEqual( + app.staticTexts["MobileTaskComposerActionCaption"].label, + "New workspace on Preview Mac in ~." + ) + + let templates = [ + (name: "Claude", action: "Start Claude"), + (name: "Codex", action: "Start Codex"), + (name: "OpenCode", action: "Start OpenCode"), + (name: "Shell", action: "Open Shell"), + ] + for template in templates { + selectTaskComposerAgent(named: template.name, in: app) + let selectedAction = NSPredicate( + format: "label == %@ AND enabled == true", + template.action + ) + expectation(for: selectedAction, evaluatedWith: create) + waitForExpectations(timeout: 3) + XCTAssertEqual( + app.buttons["MobileTaskComposerAgentMenu"].value as? String, + template.name + ) + } + } + + /// Selecting another paired Mac must cross the production composer submit + /// boundary with that Mac's stable device identity. + @MainActor + func testTaskComposerSubmitsToSelectedPairedMac() throws { + let app = launchApp(mockData: false, environment: [ + "CMUX_UITEST_TASK_COMPOSER_PREVIEW": "1", + ]) + defer { app.terminate() } + + let prompt = app.textFields["MobileTaskComposerPrompt"] + XCTAssertTrue(prompt.waitForExistence(timeout: 8)) + let machineMenu = app.buttons["MobileTaskComposerMachineMenu"] + revealTaskComposerControl(machineMenu, in: app) + machineMenu.tap() + tapMenuItem(app.buttons["Backup Preview Mac"], in: app) + XCTAssertEqual(machineMenu.value as? String, "Backup Preview Mac") + + try typeText("Route this task to the backup Mac", into: prompt, in: app) + tap(app.buttons["MobileTaskComposerCreateButton"], in: app) + + let submittedMac = app.staticTexts["MobileTaskComposerSubmittedMacDeviceID"] + XCTAssertTrue(submittedMac.waitForExistence(timeout: 4)) + XCTAssertEqual(submittedMac.label, "task-composer-backup-preview-mac") + } + + /// The debug-only lab must expose every Shell treatment, apply the + /// selected variant immediately, and preserve it across an app relaunch. + @MainActor + func testShellIconLabSelectsAndPersistsVariant() throws { + var app = launchApp(mockData: false, environment: [ + "CMUX_UITEST_WORKSPACE_LIST_PREVIEW": "1", + ]) + + func openShellIconLab(in app: XCUIApplication) { + let settings = app.buttons["MobileWorkspaceSettingsMenu"] + XCTAssertTrue(settings.waitForExistence(timeout: 8)) + tap(settings, in: app) + + let link = app.descendants(matching: .any)["MobileSettingsShellIconLab"] + for _ in 0..<6 where !link.exists || !link.isHittable { + app.swipeUp(velocity: .slow) + } + XCTAssertTrue(link.waitForExistence(timeout: 4)) + XCTAssertTrue(link.isHittable) + tap(link, in: app) + XCTAssertTrue( + app.descendants(matching: .any)["MobileShellIconLab"].waitForExistence(timeout: 4) + ) + } + + openShellIconLab(in: app) + XCTAssertTrue(app.buttons["MobileShellIconVariant-A"].exists) + XCTAssertTrue(app.buttons["MobileShellIconVariant-B"].exists) + XCTAssertTrue(app.buttons["MobileShellIconVariant-C"].exists) + + let medium86 = app.buttons["MobileShellIconVariant-G"] + for _ in 0..<3 where !medium86.isHittable { + app.swipeUp(velocity: .slow) + } + XCTAssertTrue(medium86.isHittable) + tap(medium86, in: app) + XCTAssertTrue(medium86.isSelected) + + let selectedScreenshot = XCTAttachment(screenshot: app.screenshot()) + selectedScreenshot.name = "shell-icon-lab-medium-86" + selectedScreenshot.lifetime = .keepAlways + add(selectedScreenshot) + + app.terminate() + app = launchApp(mockData: false, environment: [ + "CMUX_UITEST_WORKSPACE_LIST_PREVIEW": "1", + ]) + openShellIconLab(in: app) + let persistedMedium86 = app.buttons["MobileShellIconVariant-G"] + for _ in 0..<3 where !persistedMedium86.isHittable { + app.swipeUp(velocity: .slow) + } + XCTAssertTrue(persistedMedium86.isHittable) + XCTAssertTrue(persistedMedium86.isSelected) + + let baseline = app.buttons["MobileShellIconVariant-A"] + for _ in 0..<4 where !baseline.isHittable { + app.swipeDown(velocity: .slow) + } + XCTAssertTrue(baseline.isHittable) + tap(baseline, in: app) + XCTAssertTrue(baseline.isSelected) + app.terminate() + } + + /// The production composer must preserve each built-in template's exact + /// startup parameters when it crosses the submit boundary. + @MainActor + func testTaskComposerSubmitsEveryBuiltInTemplateWithExactWorkspaceSpec() throws { + let prompt = "Inspect the task composer" + let templates: [(name: String, action: String, command: String?, prompt: String?)] = [ + ("Claude", "Start Claude", "claude -- \"$CMUX_TASK_PROMPT\"", prompt), + ("Codex", "Start Codex", "codex -- \"$CMUX_TASK_PROMPT\"", prompt), + ("OpenCode", "Start OpenCode", "opencode --prompt \"$CMUX_TASK_PROMPT\"", prompt), + ("Shell", "Open Shell", nil, nil), + ] + + for template in templates { + let app = launchApp(mockData: false, environment: [ + "CMUX_UITEST_TASK_COMPOSER_PREVIEW": "1", + ]) + let promptField = app.textFields["MobileTaskComposerPrompt"] + XCTAssertTrue(promptField.waitForExistence(timeout: 8)) + selectTaskComposerAgent(named: template.name, in: app) + if let templatePrompt = template.prompt { + try typeText(templatePrompt, into: promptField, in: app) + } + + let create = app.buttons["MobileTaskComposerCreateButton"] + let ready = NSPredicate(format: "label == %@ AND enabled == true", template.action) + expectation(for: ready, evaluatedWith: create) + waitForExpectations(timeout: 4) + tap(create, in: app) + + let submittedCommand = app.staticTexts["MobileTaskComposerSubmittedInitialCommand"] + XCTAssertTrue(submittedCommand.waitForExistence(timeout: 4)) + XCTAssertEqual(submittedCommand.label, template.command ?? "") + XCTAssertEqual( + app.staticTexts["MobileTaskComposerSubmittedPrompt"].label, + template.prompt ?? "" + ) + XCTAssertEqual( + app.staticTexts["MobileTaskComposerSubmittedWorkingDirectory"].label, + "~" + ) + XCTAssertNotEqual( + app.staticTexts["MobileTaskComposerSubmittedOperationID"].label, + "" + ) + app.terminate() + } + } + + /// Folder search must rank an exact project root before descendants and + /// reflect the selected exact path from the production picker. + @MainActor + func testTaskComposerDirectorySearchOrdersAndSelectsRemoteFolder() throws { + let app = launchApp(mockData: false, environment: [ + "CMUX_UITEST_TASK_COMPOSER_PREVIEW": "1", + "CMUX_UITEST_TASK_DIRECTORY_PICKER_PREVIEW": "1", + ]) + defer { app.terminate() } + + let search = app.searchFields["Search folders"] + XCTAssertTrue(search.waitForExistence(timeout: 8)) + try typeText("mobile-root", into: search, in: app) + XCTAssertTrue( + app.staticTexts[ + "Search checks indexed folders across mounted volumes. Browse to reach unindexed or restricted locations." + ].waitForExistence(timeout: 3) + ) + let root = app.buttons["mobile-root"] + let sources = app.buttons["Sources"] + XCTAssertTrue(root.waitForExistence(timeout: 5)) + XCTAssertTrue(sources.waitForExistence(timeout: 5)) + XCTAssertLessThan(root.frame.minY, sources.frame.minY) + XCTAssertTrue((root.value as? String)?.contains("/Users/ui/mobile-root") == true) + XCTAssertTrue((root.value as? String)?.contains("On this Mac") == true) + XCTAssertTrue((sources.value as? String)?.contains("/Users/ui/mobile-root/Sources") == true) + + tap(sources, in: app) + let selectedPath = app.staticTexts["MobileTaskComposerSelectedDirectory"] + XCTAssertTrue(selectedPath.waitForExistence(timeout: 4)) + XCTAssertEqual(selectedPath.label, "/Users/ui/mobile-root/Sources") + } + + /// Empty search is a real filesystem browser. Hidden folders, packages, + /// symlinked directories, root navigation, and current-folder selection + /// must all remain available instead of collapsing to recent suggestions. + @MainActor + func testTaskComposerDirectoryBrowserShowsEveryDirectoryKindAndSelectsCurrentFolder() throws { + let app = launchApp(mockData: false, environment: [ + "CMUX_UITEST_TASK_COMPOSER_PREVIEW": "1", + "CMUX_UITEST_TASK_DIRECTORY_PICKER_PREVIEW": "1", + ]) + defer { app.terminate() } + + XCTAssertTrue( + app.descendants(matching: .any)["MobileTaskDirectoryBrowseCurrent"] + .waitForExistence(timeout: 8) + ) + XCTAssertTrue(app.buttons["MobileTaskDirectoryBrowseComputer"].exists) + XCTAssertTrue(app.buttons[".hidden"].exists) + XCTAssertTrue(app.buttons["Projects.app"].exists) + XCTAssertTrue(app.buttons["mobile-link"].exists) + + tap(app.buttons["mobile-root"], in: app) + XCTAssertTrue(app.buttons["Sources"].waitForExistence(timeout: 4)) + tap(app.buttons["MobileTaskDirectoryBrowseUseCurrent"], in: app) + + let selectedPath = app.staticTexts["MobileTaskComposerSelectedDirectory"] + XCTAssertTrue(selectedPath.waitForExistence(timeout: 4)) + XCTAssertEqual(selectedPath.label, "/Users/ui/mobile-root") + } + + /// Regression: scrolling a full directory page must not trap SwiftUI's + /// lazy layout on the main thread or make the picker impossible to dismiss. + @MainActor + func testTaskComposerDirectoryBrowserScrollsAndRemainsResponsive() throws { + let app = launchApp(mockData: false, environment: [ + "CMUX_UITEST_TASK_COMPOSER_PREVIEW": "1", + "CMUX_UITEST_TASK_DIRECTORY_SCROLL_STRESS": "1", + ]) + defer { app.terminate() } + + let directory = app.buttons["MobileTaskComposerDirectory"] + XCTAssertTrue(directory.waitForExistence(timeout: 8)) + tap(directory, in: app) + + let firstFolder = app.buttons["folder-00"] + let lastFolder = app.buttons["folder-49"] + let parentFolder = app.buttons["MobileTaskDirectoryBrowseParent"] + XCTAssertTrue(firstFolder.waitForExistence(timeout: 8)) + XCTAssertTrue(parentFolder.isHittable) + + for _ in 0..<8 where !lastFolder.isHittable { + app.swipeUp(velocity: .fast) + } + XCTAssertTrue(lastFolder.isHittable) + XCTAssertTrue(parentFolder.isHittable) + + let cancel = app.buttons["MobileTaskDirectoryPickerCancel"] + XCTAssertTrue(cancel.isHittable) + tap(cancel, in: app) + XCTAssertFalse(cancel.waitForExistence(timeout: 3)) + } + + /// A failed append must leave page 1 interactive, and retry must request + /// the exact failed page without replacing the successful snapshot. + @MainActor + func testTaskComposerDirectoryPaginationRecoveryPreservesPageOneAndRetriesPageTwo() throws { + let app = launchApp(mockData: false, environment: [ + "CMUX_UITEST_TASK_COMPOSER_PREVIEW": "1", + "CMUX_UITEST_TASK_DIRECTORY_PAGINATION_RECOVERY_PREVIEW": "1", + ]) + defer { app.terminate() } + + let pageOneFolder = app.buttons["first-page-folder"] + let unreadableFolder = app.buttons["unreadable-page-one"] + XCTAssertTrue(pageOneFolder.waitForExistence(timeout: 8)) + XCTAssertTrue(unreadableFolder.exists) + XCTAssertFalse(unreadableFolder.isEnabled) + + let showMore = app.buttons["MobileTaskDirectoryBrowseMore"] + XCTAssertTrue(showMore.waitForExistence(timeout: 3)) + XCTAssertTrue(showMore.isHittable) + let showMoreFrame = showMore.frame + XCTAssertGreaterThanOrEqual(showMoreFrame.height, 44) + tap(showMore, in: app) + + let retry = app.buttons["TaskComposerDirectoryBrowseRetry"] + XCTAssertTrue(retry.waitForExistence(timeout: 4)) + XCTAssertTrue(pageOneFolder.exists) + XCTAssertTrue(unreadableFolder.exists) + XCTAssertFalse(unreadableFolder.isEnabled) + XCTAssertFalse(app.buttons["z-second-page-folder"].exists) + + XCTAssertTrue(retry.isHittable) + let retryFrame = retry.frame + XCTAssertGreaterThanOrEqual(retryFrame.height, 44) + let hasAppendFailureTitle = app.staticTexts["Couldn’t Load More Folders"].exists + tap(retry, in: app) + XCTAssertTrue(app.buttons["z-second-page-folder"].waitForExistence(timeout: 4)) + XCTAssertTrue(pageOneFolder.exists) + XCTAssertFalse(retry.waitForExistence(timeout: 1)) + XCTAssertTrue( + hasAppendFailureTitle, + "Expected the page-2 failure title. Show More frame: \(showMoreFrame); Retry frame: \(retryFrame)" + ) + } + + /// The production editor must persist add, edit, and delete mutations in + /// one isolated composer session. + @MainActor + func testTaskComposerTemplateEditorAddsEditsAndDeletesTemplate() throws { + let app = launchApp(mockData: false, environment: [ + "CMUX_UITEST_TASK_COMPOSER_PREVIEW": "1", + ]) + defer { app.terminate() } + + XCTAssertTrue(app.textFields["MobileTaskComposerPrompt"].waitForExistence(timeout: 8)) + tap(app.buttons["MobileTaskComposerAgentMenu"], in: app) + tapMenuItem(app.buttons["MobileTaskComposerEditTemplatesButton"], in: app) + XCTAssertTrue(app.navigationBars["Task Templates"].waitForExistence(timeout: 4)) + tap(app.buttons["Add Template"], in: app) + XCTAssertTrue(app.navigationBars["Add Template"].waitForExistence(timeout: 4)) + + let originalName = "Spark Custom" + let editedName = "Spark Review" + try typeText(originalName, into: app.textFields["Name"], in: app) + tap(app.buttons["Save"], in: app) + + let originalRow = taskTemplateEditorRow(named: originalName, in: app) + XCTAssertTrue(originalRow.waitForExistence(timeout: 4)) + tap(originalRow, in: app) + XCTAssertTrue(app.navigationBars["Edit Template"].waitForExistence(timeout: 4)) + try replaceText(editedName, in: app.textFields["Name"], app: app) + tap(app.buttons["Save"], in: app) + + let editedRow = taskTemplateEditorRow(named: editedName, in: app) + XCTAssertTrue(editedRow.waitForExistence(timeout: 4)) + XCTAssertFalse(originalRow.exists) + editedRow.swipeLeft() + tap(app.buttons["Delete"], in: app) + XCTAssertFalse(editedRow.waitForExistence(timeout: 2)) + + tap(app.buttons["Done"], in: app) + XCTAssertFalse(app.buttons[editedName].exists) + } + + /// Regression: the standalone preview must not inherit editable task state + /// from the app's production UserDefaults store. + @MainActor + func testTaskComposerPreviewIgnoresProductionTemplateDefaults() throws { + let app = XCUIApplication() + app.launchArguments += [ + "-AppleLanguages", "(en)", + "-AppleLocale", "en_US", + "-cmux.mobile.taskTemplates.seeded.v3", "YES", + "-cmux.mobile.taskTemplates.v3", "invalid-production-template-data", + ] + app.launchEnvironment["CMUX_UITEST_MOCK_DATA"] = "0" + app.launchEnvironment["CMUX_UITEST_TASK_COMPOSER_PREVIEW"] = "1" + app.launch() + defer { app.terminate() } + + XCTAssertTrue(app.textFields["MobileTaskComposerPrompt"].waitForExistence(timeout: 8)) + for name in ["Claude", "Codex", "OpenCode", "Shell"] { + XCTAssertTrue( + app.buttons[name].waitForExistence(timeout: 2), + "The deterministic preview must ignore production defaults and expose \(name)" + ) + } + } + + /// Regression: the template form's default-directory field must identify + /// itself as Directory instead of exposing only its "~" placeholder. + @MainActor + func testTaskTemplateDirectoryHasMeaningfulAccessibilityLabel() throws { + let app = launchApp(mockData: false, environment: [ + "CMUX_UITEST_TASK_COMPOSER_PREVIEW": "1", + "CMUX_UITEST_TASK_TEMPLATE_FORM_PREVIEW": "1", + ]) + defer { app.terminate() } + + XCTAssertTrue( + app.textFields["Directory"].waitForExistence(timeout: 8), + "The template directory field must expose the localized Directory label" + ) + } + + /// Regression: a failed submission must stay visible in the persistent + /// action area while the prompt keyboard remains presented. + @MainActor + func testTaskComposerFailureRemainsVisibleAboveKeyboard() throws { + let app = launchApp(mockData: false, environment: [ + "CMUX_UITEST_TASK_COMPOSER_PREVIEW": "1", + "CMUX_UITEST_TASK_COMPOSER_FAILURE": "1", + ]) + defer { app.terminate() } + + let prompt = app.textFields["MobileTaskComposerPrompt"] + XCTAssertTrue(prompt.waitForExistence(timeout: 8)) + tap(prompt, in: app) + let keyboard = app.keyboards.firstMatch + XCTAssertTrue(keyboard.waitForExistence(timeout: 3)) + prompt.typeText("Exercise failure recovery") + let create = app.buttons["MobileTaskComposerCreateButton"] + XCTAssertTrue(create.waitForExistence(timeout: 3)) + + tap(create, in: app) + + let failure = app.staticTexts["MobileTaskComposerFailure"] + XCTAssertTrue(failure.waitForExistence(timeout: 3)) + XCTAssertTrue(keyboard.exists, "Submission failure must not require dismissing the keyboard") + XCTAssertLessThanOrEqual( + failure.frame.maxY, + create.frame.minY, + "Failure guidance must remain in the persistent area immediately above Create" + ) + XCTAssertLessThanOrEqual( + create.frame.maxY, + keyboard.frame.minY, + "The persistent failure and Create action must remain above the keyboard" + ) + } + + /// Regression: an ordinary remote failure must preserve the prompt and + /// retry identity, then clear its guidance before the successful retry. + @MainActor + func testTaskComposerRetriesOrdinaryFailureWithSameOperationID() throws { + let app = launchApp(mockData: false, environment: [ + "CMUX_UITEST_TASK_COMPOSER_PREVIEW": "1", + "CMUX_UITEST_TASK_COMPOSER_FAIL_ONCE": "1", + ]) + defer { app.terminate() } + + let expectedPrompt = "Retry this exact task" + let prompt = app.textFields["MobileTaskComposerPrompt"] + XCTAssertTrue(prompt.waitForExistence(timeout: 8)) + try typeText(expectedPrompt, into: prompt, in: app) + + let create = app.buttons["MobileTaskComposerCreateButton"] + XCTAssertTrue(create.waitForExistence(timeout: 3)) + tap(create, in: app) + + let failure = app.staticTexts["MobileTaskComposerFailure"] + XCTAssertTrue(failure.waitForExistence(timeout: 3)) + let retryReady = NSPredicate(format: "label == %@ AND enabled == true", "Try Again") + expectation(for: retryReady, evaluatedWith: create) + waitForExpectations(timeout: 3) + XCTAssertEqual(prompt.value as? String, expectedPrompt) + + let firstOperationID = app.staticTexts["MobileTaskComposerSubmittedOperationID-1"] + let firstPrompt = app.staticTexts["MobileTaskComposerSubmittedPrompt-1"] + XCTAssertTrue(firstOperationID.waitForExistence(timeout: 3)) + XCTAssertTrue(firstPrompt.waitForExistence(timeout: 3)) + XCTAssertNotEqual(firstOperationID.label, "") + XCTAssertEqual(firstPrompt.label, expectedPrompt) + + tap(create, in: app) + + XCTAssertTrue( + failure.waitForNonExistence(timeout: 0.5), + "Retry must clear stale failure guidance before the request finishes" + ) + XCTAssertTrue(prompt.exists, "The composer must remain presented while retrying") + XCTAssertEqual(prompt.value as? String, expectedPrompt) + + let secondOperationID = app.staticTexts["MobileTaskComposerSubmittedOperationID-2"] + let secondPrompt = app.staticTexts["MobileTaskComposerSubmittedPrompt-2"] + XCTAssertTrue(secondOperationID.waitForExistence(timeout: 1)) + XCTAssertTrue(secondPrompt.waitForExistence(timeout: 1)) + XCTAssertEqual(secondOperationID.label, firstOperationID.label) + XCTAssertEqual(secondPrompt.label, expectedPrompt) + + XCTAssertTrue( + prompt.waitForNonExistence(timeout: 4), + "The composer must dismiss after the retry succeeds" + ) + } + + /// Regression: the production composer must route a Claude task through + /// the connected host and select the exact workspace returned by that RPC. + @MainActor + func testTaskComposerCreatesAndSelectsConnectedWorkspace() async throws { + let server = try MobileSyncMockHostServer( + supportsManualAttachTicket: true, + workspaceCreateSelectsCreatedWorkspace: false + ) + let port = try await server.start() + defer { server.stop() } + + let app = try launchConnectedAppViaManualPairing(port: port) + defer { app.terminate() } + + let backButton = app.buttons["MobileWorkspaceBackButton"] + XCTAssertTrue(backButton.waitForExistence(timeout: 4)) + tap(backButton, in: app) + XCTAssertTrue( + app.descendants(matching: .any)["MobileWorkspaceRow-workspace-main"] + .waitForExistence(timeout: 4) + ) + + let composerButton = app.buttons["MobileTaskComposerButton"] + XCTAssertTrue(composerButton.waitForExistence(timeout: 4)) + tap(composerButton, in: app) + + let promptText = "Connected Claude task \(UUID().uuidString)" + let prompt = app.textFields["MobileTaskComposerPrompt"] + XCTAssertTrue(prompt.waitForExistence(timeout: 4)) + XCTAssertTrue( + waitForKeyboardFocus(of: prompt, timeout: 3), + "Opening New Task from the production workspace must focus the prompt without an extra tap." + ) + XCTAssertTrue( + waitForSoftwareKeyboardKeyPlane(in: app, minimumOverlap: 120, timeout: 3) != nil, + "Opening New Task must raise the software keyboard without an extra tap." + ) + prompt.typeText(promptText) + + tap(app.buttons["MobileTaskComposerDirectory"], in: app) + let directorySearch = app.searchFields["Search folders"] + XCTAssertTrue(directorySearch.waitForExistence(timeout: 4)) + try typeText("cmux", into: directorySearch, in: app) + let projectDirectory = app.buttons.matching( + NSPredicate(format: "label == %@ AND value CONTAINS %@", "cmux", "~/cmux") + ).firstMatch + XCTAssertTrue(projectDirectory.waitForExistence(timeout: 4)) + tap(projectDirectory, in: app) + let selectedDirectory = app.buttons["MobileTaskComposerDirectory"] + XCTAssertTrue(selectedDirectory.waitForExistence(timeout: 4)) + XCTAssertEqual(selectedDirectory.value as? String, "~/cmux") + + let create = app.buttons["MobileTaskComposerCreateButton"] + let ready = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "label == %@ AND enabled == true", "Start Claude"), + object: create + ) + XCTAssertEqual(XCTWaiter.wait(for: [ready], timeout: 4), .completed) + tap(create, in: app) + + XCTAssertTrue( + prompt.waitForNonExistence(timeout: 8), + "A successful connected create must dismiss the task composer" + ) + + guard let request = await server.waitForWorkspaceCreateRequest(timeout: 8) else { + XCTFail("The connected host never received workspace.create") + return + } + XCTAssertEqual(request.title, promptText) + XCTAssertEqual(request.workingDirectory, "~/cmux") + XCTAssertEqual(request.initialCommand, "claude -- \"$CMUX_TASK_PROMPT\"") + XCTAssertEqual(request.initialEnvironment, ["CMUX_TASK_PROMPT": promptText]) + guard let operationID = request.operationID else { + XCTFail("workspace.create must carry an operation ID") + return + } + XCTAssertFalse(operationID.isEmpty) + XCTAssertNotNil(UUID(uuidString: operationID)) + + await assertHostSelection( + workspaceID: "workspace-3", + terminalID: "workspace-3-terminal-1", + server: server + ) + let title = workspaceTitleElement(in: app) + let selectedTitle = XCTNSPredicateExpectation( + predicate: NSPredicate( + format: "label == %@ OR label BEGINSWITH %@", + promptText, + "\(promptText)," + ), + object: title + ) + XCTAssertEqual( + XCTWaiter.wait(for: [selectedTitle], timeout: 8), + .completed, + "The selected workspace must show the exact title returned by workspace.create" + ) + XCTAssertTrue(app.otherElements["MobileTerminalSurface"].waitForExistence(timeout: 8)) + + let terminalDropdown = app.buttons["MobileTerminalDropdown"] + XCTAssertTrue(terminalDropdown.waitForExistence(timeout: 4)) + XCTAssertEqual(terminalDropdown.value as? String, "Terminal 1") + tap(terminalDropdown, in: app) + assertTerminalMenuItemExists("workspace-3-terminal-1", in: app) + } + + /// Regression: preparation must durably save the exact retry identity + /// before routing starts, while Cancel remains available until the create + /// boundary is committed. + @MainActor + func testTaskComposerPersistsDraftAndAllowsCancelDuringPreparation() throws { + let app = launchApp(mockData: false, environment: [ + "CMUX_UITEST_TASK_COMPOSER_PREVIEW": "1", + "CMUX_UITEST_TASK_COMPOSER_HOLD_PREPARATION": "1", + ]) + defer { app.terminate() } + + let prompt = app.textFields["MobileTaskComposerPrompt"] + XCTAssertTrue(prompt.waitForExistence(timeout: 8)) + prompt.typeText("Persist this task") + let create = app.buttons["MobileTaskComposerCreateButton"] + XCTAssertTrue(create.waitForExistence(timeout: 3)) + XCTAssertEqual(create.label, "Start Claude") + tap(create, in: app) + + let startingPredicate = NSPredicate(format: "label == %@", "Preparing workspace…") + expectation(for: startingPredicate, evaluatedWith: create) + waitForExpectations(timeout: 3) + + let draftState = app.staticTexts["MobileTaskComposerSubmissionDraftState"] + XCTAssertTrue(draftState.waitForExistence(timeout: 3)) + XCTAssertEqual(draftState.label, "persisted") + let cancel = app.buttons["Cancel"] + XCTAssertTrue(cancel.exists) + XCTAssertTrue(cancel.isEnabled, "Cancel must stay enabled before the create boundary") + if cancel.isEnabled { + cancel.tap() + XCTAssertFalse( + app.textFields["MobileTaskComposerPrompt"].waitForExistence(timeout: 2), + "Pre-boundary Cancel must dismiss the composer" + ) + } + } + /// Regression: fast pinch-zoom must not hang the main thread (the /// scene-update watchdog `0x8BADF00D` was killing the app because /// libghostty surface calls block on the main thread) and must not @@ -1883,6 +2700,51 @@ final class cmuxUITests: XCTestCase { return app } + @MainActor + private func launchConnectedAppViaManualPairing(port: UInt16) throws -> XCUIApplication { + let portText = String(port) + guard let finalPortDigit = portText.last else { + throw URLError(.badURL) + } + let app = launchApp(mockData: true, environment: [ + "CMUX_UITEST_ADD_DEVICE_PORT": String(portText.dropLast()), + ]) + let pairingForm = app.otherElements["MobileAddDeviceForm"] + XCTAssertTrue(pairingForm.waitForExistence(timeout: 8)) + + let hostField = app.textFields["MobileAddDeviceHostField"] + XCTAssertTrue(hostField.waitForExistence(timeout: 4)) + hostField.tap() + hostField.typeText("127.0.0.1") + + let portField = app.textFields["MobileAddDevicePortField"] + XCTAssertTrue(portField.waitForExistence(timeout: 4)) + portField.tap() + portField.typeText(String(finalPortDigit)) + XCTAssertEqual(hostField.value as? String, "127.0.0.1") + XCTAssertEqual(portField.value as? String, portText) + + let pairButton = app.buttons["MobilePairButton"] + let pairReady = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "enabled == true"), + object: pairButton + ) + XCTAssertEqual(XCTWaiter.wait(for: [pairReady], timeout: 4), .completed) + XCTAssertTrue(pairButton.isHittable) + pairButton.tap() + + XCTAssertTrue( + pairingForm.waitForNonExistence(timeout: 20), + "Successful manual loopback pairing must dismiss the Add Computer sheet" + ) + + waitForWorkspaceShell(in: app) + try openSelectedWorkspaceIfNeeded(app) + assertTerminalRow(0, label: "$ cmux ios status", in: app) + assertTerminalRow(1, label: "Mobile Core: connected", in: app) + return app + } + private func attachURL(port: UInt16) throws -> URL { let route = try CmxAttachRoute( id: "debug_loopback", @@ -1894,6 +2756,7 @@ final class cmuxUITests: XCTestCase { terminalID: nil, macDeviceID: "ui-test-mac", macDisplayName: "UI Test Mac", + macPairingCompatibilityVersion: CmxMobileDefaults.pairingCompatibilityVersion, routes: [route], expiresAt: Date(timeIntervalSinceNow: 60 * 60), authToken: "ui-test-ticket" @@ -1993,10 +2856,12 @@ final class cmuxUITests: XCTestCase { private func launchApp( mockData: Bool, clearAuth: Bool = false, - environment: [String: String] = [:] + environment: [String: String] = [:], + launchArguments: [String] = [] ) -> XCUIApplication { let app = XCUIApplication() app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launchArguments += launchArguments app.launchEnvironment["CMUX_UITEST_MOCK_DATA"] = mockData ? "1" : "0" for (key, value) in environment { app.launchEnvironment[key] = value @@ -2381,6 +3246,55 @@ final class cmuxUITests: XCTestCase { dismissKeyboard(in: app, preferAddDeviceAccessoryDoneButton: isAddDeviceField(element)) } + @MainActor + private func taskTemplateEditorRow(named name: String, in app: XCUIApplication) -> XCUIElement { + app.buttons.matching( + NSPredicate( + format: "label CONTAINS %@ AND label CONTAINS %@", + name, + "Plain shell" + ) + ).firstMatch + } + + @MainActor + private func selectTaskComposerAgent( + named name: String, + in app: XCUIApplication, + file: StaticString = #filePath, + line: UInt = #line + ) { + let menu = app.buttons["MobileTaskComposerAgentMenu"] + XCTAssertTrue(menu.waitForExistence(timeout: 4), file: file, line: line) + tap(menu, in: app, file: file, line: line) + tapMenuItem(app.buttons[name], in: app, file: file, line: line) + } + + @MainActor + private func revealTaskComposerControl( + _ control: XCUIElement, + in app: XCUIApplication, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertTrue(control.waitForExistence(timeout: 4), file: file, line: line) + let hero = app.descendants(matching: .any)["MobileTaskComposerHero"] + for _ in 0..<3 where !control.isHittable { + if hero.exists, hero.isHittable { + hero.swipeUp(velocity: .slow) + } else { + app.coordinate(withNormalizedOffset: .zero) + .withOffset(CGVector(dx: 200, dy: 360)) + .press( + forDuration: 0.05, + thenDragTo: app.coordinate(withNormalizedOffset: .zero) + .withOffset(CGVector(dx: 200, dy: 160)) + ) + } + } + XCTAssertTrue(control.isHittable, "Task composer control stayed behind the keyboard", file: file, line: line) + } + @MainActor private func replaceText(_ text: String, in element: XCUIElement, app: XCUIApplication) throws { XCTAssertTrue(element.waitForExistence(timeout: 4)) @@ -4072,6 +4986,14 @@ private enum MockColorBands { } private final class MobileSyncMockHostServer: @unchecked Sendable { + struct WorkspaceCreateRequest: Sendable { + let title: String? + let workingDirectory: String? + let initialCommand: String? + let initialEnvironment: [String: String]? + let operationID: String? + } + private struct Workspace { var id: String var title: String @@ -4090,10 +5012,14 @@ private final class MobileSyncMockHostServer: @unchecked Sendable { private let listener: NWListener private let queue = DispatchQueue(label: "dev.cmux.ios-ui-tests.mobile-sync-server") private let createdWorkspaceTerminalDelay: TimeInterval? + private let supportsManualAttachTicket: Bool + private let workspaceCreateSelectsCreatedWorkspace: Bool + private let macInstanceTag: String private var readyContinuation: CheckedContinuation? private var connections: [NWConnection] = [] private var selectedWorkspaceID = "workspace-main" private var selectedTerminalID = "terminal-build" + private var workspaceCreateRequests: [WorkspaceCreateRequest] = [] private var replayCounts: [String: Int] = [:] private var streamOffset: UInt64 = 1 private var workspaces: [Workspace] = [ @@ -4148,10 +5074,16 @@ private final class MobileSyncMockHostServer: @unchecked Sendable { init( defaultTerminalLines: [String]? = nil, additionalMainTerminalCount: Int = 0, - createdWorkspaceTerminalDelay: TimeInterval? = nil + createdWorkspaceTerminalDelay: TimeInterval? = nil, + supportsManualAttachTicket: Bool = false, + workspaceCreateSelectsCreatedWorkspace: Bool = true, + macInstanceTag: String = mockHostInstanceTag() ) throws { listener = try NWListener(using: .tcp, on: .any) self.createdWorkspaceTerminalDelay = createdWorkspaceTerminalDelay + self.supportsManualAttachTicket = supportsManualAttachTicket + self.workspaceCreateSelectsCreatedWorkspace = workspaceCreateSelectsCreatedWorkspace + self.macInstanceTag = macInstanceTag appendMainTerminals(count: additionalMainTerminalCount) // Optionally replace the selected terminal's content (used by the // color-band render test so the bands stream on attach without a flaky @@ -4229,6 +5161,27 @@ private final class MobileSyncMockHostServer: @unchecked Sendable { return "\(selection.workspaceID)/\(selection.terminalID)" } + func waitForWorkspaceCreateRequest( + timeout: TimeInterval = 8 + ) async -> WorkspaceCreateRequest? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let request = await latestWorkspaceCreateRequest() { + return request + } + try? await Task.sleep(nanoseconds: 50_000_000) + } + return await latestWorkspaceCreateRequest() + } + + private func latestWorkspaceCreateRequest() async -> WorkspaceCreateRequest? { + await withCheckedContinuation { continuation in + queue.async { + continuation.resume(returning: self.workspaceCreateRequests.last) + } + } + } + private func currentSelection() async -> (workspaceID: String, terminalID: String) { await withCheckedContinuation { continuation in queue.async { @@ -4303,6 +5256,12 @@ private final class MobileSyncMockHostServer: @unchecked Sendable { } private func receiveRequest(on connection: NWConnection, buffer: Data = Data()) { + var buffered = buffer + if let payload = Self.nextFrame(from: &buffered) { + respond(to: payload, on: connection, remainingBuffer: buffered) + return + } + connection.receive( minimumIncompleteLength: 1, maximumLength: 64 * 1024 @@ -4311,7 +5270,7 @@ private final class MobileSyncMockHostServer: @unchecked Sendable { return } - var nextBuffer = buffer + var nextBuffer = buffered if let data, !data.isEmpty { nextBuffer.append(data) } @@ -4360,13 +5319,27 @@ private final class MobileSyncMockHostServer: @unchecked Sendable { let id = request["id"] as? String ?? "" let params = request["params"] as? [String: Any] ?? [:] + if method == "mobile.attach_ticket.create", !supportsManualAttachTicket { + let envelope: [String: Any] = [ + "id": id, + "ok": false, + "error": [ + "code": "method_not_found", + "message": "Unknown method", + ], + ] + let responsePayload = try JSONSerialization.data(withJSONObject: envelope) + return Self.frame(responsePayload) + } let result: [String: Any] switch method { + case "mobile.attach_ticket.create": + result = try manualAttachTicketResult() case "mobile.workspace.list", "workspace.list": result = workspaceListResult() case "workspace.create": - result = createWorkspaceResult() + result = createWorkspaceResult(params: params) case "terminal.create": result = createTerminalResult(params: params) case "mobile.events.subscribe": @@ -4395,6 +5368,9 @@ private final class MobileSyncMockHostServer: @unchecked Sendable { private func mobileHostStatusResult() -> [String: Any] { [ + "mac_device_id": "ui-test-mac", + "mac_display_name": "UI Test Mac", + "mac_instance_tag": macInstanceTag, "routes": [], "terminal_fidelity": "render_grid", "capabilities": [ @@ -4407,6 +5383,7 @@ private final class MobileSyncMockHostServer: @unchecked Sendable { "terminal.replay.v1", "terminal.viewport.v1", "workspace.actions.v1", + "workspace.task_create.v1", "workspace.read_state.v1", "workspace.close.v1", "dogfood.v1", @@ -4415,31 +5392,68 @@ private final class MobileSyncMockHostServer: @unchecked Sendable { ] } - private func createWorkspaceResult() -> [String: Any] { + private func manualAttachTicketResult() throws -> [String: Any] { + guard let port = listener.port?.rawValue else { + throw serverError("Listener has no port.") + } + let route = try CmxAttachRoute( + id: "debug_loopback", + kind: .debugLoopback, + endpoint: .hostPort(host: "127.0.0.1", port: Int(port)) + ) + let ticket = try CmxAttachTicket( + workspaceID: "", + terminalID: nil, + macDeviceID: "ui-test-mac", + macDisplayName: "UI Test Mac", + macPairingCompatibilityVersion: CmxMobileDefaults.pairingCompatibilityVersion, + routes: [route], + expiresAt: Date(timeIntervalSinceNow: 60 * 60), + authToken: "ui-test-ticket" + ) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return ["ticket": try JSONSerialization.jsonObject(with: encoder.encode(ticket))] + } + + private func createWorkspaceResult(params: [String: Any]) -> [String: Any] { + let request = WorkspaceCreateRequest( + title: params["title"] as? String, + workingDirectory: params["working_directory"] as? String, + initialCommand: params["initial_command"] as? String, + initialEnvironment: params["initial_env"] as? [String: String], + operationID: params["operation_id"] as? String + ) + workspaceCreateRequests.append(request) let nextIndex = workspaces.count + 1 let workspaceID = "workspace-\(nextIndex)" let terminalID = "\(workspaceID)-terminal-1" + let title = request.title ?? "Workspace \(nextIndex)" + let workingDirectory = request.workingDirectory ?? "~/workspace-\(nextIndex)" let terminal = Terminal( id: terminalID, title: "Terminal 1", - currentDirectory: "~/workspace-\(nextIndex)", + currentDirectory: workingDirectory, lines: [ "$ cmux ios", - "workspace: Workspace \(nextIndex)", + "workspace: \(title)", "terminal: Terminal 1", ] ) let workspace = Workspace( id: workspaceID, - title: "Workspace \(nextIndex)", - currentDirectory: "~/workspace-\(nextIndex)", + title: title, + currentDirectory: workingDirectory, terminals: createdWorkspaceTerminalDelay == nil ? [terminal] : [] ) workspaces.append(workspace) - selectedWorkspaceID = workspaceID - if createdWorkspaceTerminalDelay == nil { - selectedTerminalID = terminalID - } else { + if workspaceCreateSelectsCreatedWorkspace { + selectedWorkspaceID = workspaceID + if createdWorkspaceTerminalDelay == nil { + selectedTerminalID = terminalID + } + } + if createdWorkspaceTerminalDelay != nil { scheduleCreatedWorkspaceTerminal(terminal, workspaceID: workspaceID) } @@ -4674,6 +5688,25 @@ private final class MobileSyncMockHostServer: @unchecked Sendable { } } +/// Maps the XCUITest bundle back to the target app's tagged DEBUG build scope. +/// Tagged builds override the test bundle identifier with the app identifier; +/// ordinary UI tests retain their reserved `uitests` identifier and map to the +/// production policy's `dev` fallback. +private func mockHostInstanceTag( + testBundleIdentifier: String? = Bundle(for: cmuxUITests.self).bundleIdentifier +) -> String { + let runnerSuffix = ".xctrunner" + guard let testBundleIdentifier else { return "dev" } + let appBundleIdentifier = testBundleIdentifier.hasSuffix(runnerSuffix) + ? String(testBundleIdentifier.dropLast(runnerSuffix.count)) + : testBundleIdentifier + guard appBundleIdentifier != "dev.cmux.ios.uitests" else { return "dev" } + return MobileIOSBuildScope.current( + infoDictionary: nil, + bundleIdentifier: appBundleIdentifier + )?.value ?? "dev" +} + private extension XCUIApplication { var isLandscape: Bool { let frame = windows.firstMatch.exists ? windows.firstMatch.frame : self.frame