Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
202 commits
Select commit Hold shift + click to select a range
95b79c2
iOS: floating task composer creates workspaces with agent templates
lawrencecchen Jul 8, 2026
d5dd73b
Fix package-conventions-lint: replace free function taskTemplateIcon …
lawrencecchen Jul 8, 2026
2c9418b
Fix Swift file length budget and policy findings: split god-file rout…
lawrencecchen Jul 8, 2026
c0671d8
Address review findings: submit lifecycle, emoji seeding, API surface
lawrencecchen Jul 8, 2026
7aa4b90
Localize seeded task template names
lawrencecchen Jul 8, 2026
7c9c8d8
Task composer: block dismissal during submit; keep directory in sync …
lawrencecchen Jul 8, 2026
bf3faad
Task composer: don't preselect a forgotten Mac
lawrencecchen Jul 9, 2026
804c7d7
Task composer: brand agent icons, OpenCode seed, tighter FAB padding
lawrencecchen Jul 9, 2026
feaa150
Flatten agent icon asset names to catalog root
lawrencecchen Jul 9, 2026
f692cc8
Load agent brand icons from loose package PNGs, not the asset catalog
lawrencecchen Jul 9, 2026
ffe31f0
Task composer: failing test — stale rejected create reports success
lawrencecchen Jul 9, 2026
240da60
Task composer: report stale create failures instead of success
lawrencecchen Jul 9, 2026
dc7076e
Task composer: re-dial when the selected Mac's connection dropped
lawrencecchen Jul 9, 2026
6f77ed1
Polish task composer lifecycle and accessibility
azooz2003-bit Jul 11, 2026
d14cab6
Preserve task composer directory on Mac fallback
azooz2003-bit Jul 11, 2026
06b8a6e
Merge remote-tracking branch 'origin/main' into feat-ios-task-composer
azooz2003-bit Jul 11, 2026
15a06ca
Harden task composer submission boundaries
azooz2003-bit Jul 11, 2026
caeb741
Set empty task prompt environment explicitly
azooz2003-bit Jul 11, 2026
9af6b77
test: cover task composer retry idempotency
azooz2003-bit Jul 11, 2026
0f5c958
test: reject stale task composer draft writes
azooz2003-bit Jul 11, 2026
71f648f
fix: make task creation retries idempotent
azooz2003-bit Jul 11, 2026
95a0c03
chore: keep terminal controller line neutral
azooz2003-bit Jul 11, 2026
f7bdfdf
test: cover task composer compatibility gates
azooz2003-bit Jul 11, 2026
7c343b8
fix: harden task composer submission compatibility
azooz2003-bit Jul 11, 2026
42a1672
Merge remote-tracking branch 'origin/main' into feat-ios-task-composer
azooz2003-bit Jul 11, 2026
92e9f09
test: cover task composer data boundaries
azooz2003-bit Jul 11, 2026
0986767
fix: isolate task composer execution and state
azooz2003-bit Jul 11, 2026
2ca5d32
test: cover task directory validation boundaries
azooz2003-bit Jul 11, 2026
9220a2b
fix: validate mobile task directories off main actor
azooz2003-bit Jul 12, 2026
1c548c6
test: cover bounded task directory validation
azooz2003-bit Jul 12, 2026
5e75f97
fix: serialize mobile task directory validation
azooz2003-bit Jul 12, 2026
a96f278
Merge remote-tracking branch 'origin/main' into feat-ios-task-composer
Jul 13, 2026
ba41a52
test: cover interrupted composer edge cases
Jul 13, 2026
25d03c3
fix: harden task composer retries and validation
Jul 13, 2026
92d0f12
test: cover composer routing and navigation races
Jul 13, 2026
b13207a
fix: pin task creation and compact navigation
Jul 13, 2026
882d3a6
Merge remote-tracking branch 'origin/main' into feat-ios-task-composer
Jul 13, 2026
ca2e262
test: cover immutable task submission policies
Jul 13, 2026
5feee8c
fix: harden task composer settlement
Jul 13, 2026
1a57034
test: cover task prompt composition edges
Jul 13, 2026
b31997e
fix: preserve explicit task prompt inputs
Jul 13, 2026
0e118d3
test: clarify controlled deadline suspension
Jul 13, 2026
20a8c25
docs: document task submission snapshot
Jul 13, 2026
b9a5a7d
test: stress concurrent mount snapshots
Jul 13, 2026
4e199dd
fix: isolate concurrent mount snapshots
Jul 13, 2026
70ade2e
fix: narrow task composer import visibility
Jul 13, 2026
ff38df3
test: cover session-scoped draft clearing
Jul 13, 2026
55a67e8
fix: scope task draft clearing to session
Jul 13, 2026
1789c19
test: cover whitespace-only task commands
Jul 13, 2026
756760e
fix: treat blank task commands as plain shell
Jul 13, 2026
ec9199b
Merge remote-tracking branch 'origin/main' into feat-ios-task-composer
Jul 13, 2026
09dc074
test: reject working directory alias bypasses
Jul 13, 2026
adc1667
fix: reject working directory alias bypasses
Jul 13, 2026
bf3cd87
test: cover prompt append before comments
Jul 13, 2026
6c9d6be
test: cover request-equivalent task edits
Jul 13, 2026
a700687
test: preserve hash token append semantics
Jul 13, 2026
80ef2ba
test: assert request identity rotation policy
Jul 13, 2026
3e00678
test: cover prompt append before shell separators
Jul 13, 2026
4865d96
test: cover mobile cwd validation alias
Jul 13, 2026
fc54dc6
test: reconcile restored task tombstones
Jul 13, 2026
1bbf6cd
fix: append task prompts before trailing shell syntax
Jul 13, 2026
c7e0364
fix: preserve identity for equivalent task requests
Jul 13, 2026
9980a20
fix: validate mobile cwd aliases
Jul 13, 2026
367bf45
fix: reconcile restored task tombstones
Jul 13, 2026
9475ab4
test: preserve trailing task command trivia
Jul 13, 2026
f60fd44
Merge remote-tracking branch 'origin/main' into feat-ios-task-composer
Jul 13, 2026
35624e7
test: normalize task commands for request identity
Jul 13, 2026
a2a1027
fix: compare normalized task create requests
Jul 13, 2026
c6d6f53
refactor: keep task composer within file budgets
Jul 13, 2026
b74a849
test: cover comment-only task templates
Jul 13, 2026
7a7eafe
test: cover shell programs without commands
Jul 13, 2026
96a044c
test: classify shell programs without commands
Jul 13, 2026
a905dfe
fix: classify task templates without commands
Jul 13, 2026
f48a3ba
refactor: isolate task and restore actions
Jul 13, 2026
3c63701
feat: describe task composer controls for VoiceOver
Jul 13, 2026
3a27cf4
test: decline prompt injection for complex shell scripts
Jul 13, 2026
b8a79e6
fix: decline prompt injection for complex shell scripts
Jul 13, 2026
b21e65b
fix: inject session restore cache explicitly
Jul 13, 2026
944bc44
refactor: isolate shell grammar composer tests
Jul 13, 2026
8e2044a
test: preserve manual directory across template changes
Jul 13, 2026
782642d
fix: preserve manual directory across template changes
Jul 13, 2026
fe30301
Merge remote-tracking branch 'origin/main' into feat-ios-task-composer
Jul 13, 2026
36cf256
test: cover task composer review regressions
Jul 13, 2026
e079c86
fix: address task composer review feedback
Jul 13, 2026
870d20d
test: cover task composer accessibility targets
Jul 13, 2026
a4d8532
fix: expose task composer actions to VoiceOver
Jul 13, 2026
0c89325
Merge remote-tracking branch 'origin/main' into feat-ios-task-composer
Jul 13, 2026
83334d7
docs: document task composer accessibility preview
Jul 13, 2026
baf6d0b
test: cover task composer review regressions
Jul 13, 2026
fcc91af
fix: close task composer review findings
Jul 13, 2026
cc1e55f
test: cover final task composer review findings
Jul 13, 2026
8e70bf6
fix: close final task composer review findings
Jul 13, 2026
e36ba1d
test: cover final session and shell expansion races
Jul 13, 2026
e970a56
fix: guard composer settlement by session
Jul 13, 2026
7db28ea
test(ios): model signed-in composer preview
Jul 13, 2026
40c8cf6
test: cover task composer review regressions
Jul 13, 2026
7a3694e
fix: address task composer review findings
Jul 13, 2026
6d9f025
Merge remote-tracking branch 'origin/main' into feat-ios-task-composer
Jul 13, 2026
d2d2743
test: cover cancelled create rejection race
Jul 13, 2026
31f2e52
fix: preserve definite create rejections
Jul 13, 2026
a0e56b2
test: cover settled rejection and heredoc prompts
Jul 13, 2026
a4253a8
fix: preserve rpc settlement and heredoc literals
Jul 13, 2026
5421d35
Split task composer parsing helpers
Jul 13, 2026
47999b5
Test nested shell contexts in task templates
Jul 13, 2026
799ab0d
Track nested task template shell contexts
Jul 13, 2026
0129f02
Test verbatim task template command contract
Jul 13, 2026
7dbede3
Preserve task template commands verbatim
Jul 13, 2026
b785bcf
Test verbatim task command transport
Jul 13, 2026
5b04467
Preserve task commands through host startup
Jul 13, 2026
5a1c7c2
Test authoritative restored task tombstones
Jul 13, 2026
1ca1b51
Honor task tombstones across restarts
Jul 13, 2026
5062392
Test task creation transaction boundaries
Jul 13, 2026
66ae0f5
Make task creation transaction atomic
Jul 13, 2026
abc883d
Test composer cancellation during Mac switch
Jul 13, 2026
33f2f35
Cancel owned Mac switch with composer task
Jul 13, 2026
a90efbe
Test cancellation and tombstone persistence failures
Jul 13, 2026
34e821a
Make task submission settlement crash-safe
Jul 13, 2026
0b0beda
Test task composer directory and presentation UX
Jul 13, 2026
7b1d205
Stabilize task composer directory selection
Jul 13, 2026
5389540
Test mobile task directory discovery RPC
Jul 13, 2026
470e502
Test stale task directory search capability metadata
Jul 13, 2026
c395332
Discover task directories from connected Macs
Jul 14, 2026
16aeb69
Merge remote-tracking branch 'origin/main' into feat-ios-task-composer
Jul 14, 2026
824d3fc
Scope task directory candidate assembly
Jul 14, 2026
99aab43
Split task composer files for source budgets
Jul 14, 2026
e9d75ff
Add regressions for interrupted task recovery
Jul 14, 2026
6d70d7c
Fix task recovery and directory search cancellation
Jul 14, 2026
3d36c07
Merge remote-tracking branch 'origin/main' into feat-ios-task-composer
Jul 14, 2026
2b22f82
Add regressions for composer review findings
Jul 14, 2026
396ec9d
Fix composer review edge cases
Jul 14, 2026
899491c
Fix iOS test Foundation import
Jul 14, 2026
c248fc7
Add regressions for final composer review
Jul 14, 2026
e5a6b4e
Fix final task composer review findings
Jul 14, 2026
c614e29
Merge remote-tracking branch 'origin/main' into feat-ios-task-composer
Jul 14, 2026
ec7b0fd
Merge remote-tracking branch 'origin/main' into feat-ios-task-composer
Jul 15, 2026
8c3e770
Merge remote-tracking branch 'origin/main' into feat-ios-task-composer
Jul 15, 2026
acbbcc4
Merge remote-tracking branch 'origin/main' into feat-ios-task-composer
Jul 16, 2026
67dbdc2
Untrack generated iOS workspace Package.resolved
Jul 16, 2026
90a42f8
Replace hidden-window resize sleeps with deterministic tmux size waits
Jul 16, 2026
4ab0364
Restore tracked iOS workspace lockfile
Jul 17, 2026
bafa0a4
Merge remote-tracking branch 'origin/main' into feat-ios-task-composer
Jul 17, 2026
f0bbbd8
Fix instance-tag forwarding after main merge
Jul 17, 2026
7060c2d
Polish iOS task composer launch flow
Jul 17, 2026
ef70ab6
Fix task composer accessibility targets
Jul 17, 2026
806f682
Test task composer launch readiness
Jul 17, 2026
9e6fce2
Require prompts before launching agents
Jul 17, 2026
7a1d2f2
Test bundled task agent icons
Jul 17, 2026
d656bac
Load bundled task agent icons
Jul 17, 2026
846bffd
Test immediate paired Mac visibility
Jul 17, 2026
bfd3142
Refresh paired Macs after attach
Jul 17, 2026
00de4f5
Fix iOS reconnect and build isolation (#8299)
azooz2003-bit Jul 17, 2026
54469cd
Test seeded directory search fast path
Jul 17, 2026
bba67d7
Prioritize seeded directories in search
Jul 17, 2026
f552e1a
Test cold directory search responsiveness
Jul 17, 2026
1302090
Prioritize exact cold directory searches
Jul 17, 2026
90b9d34
Merge remote-tracking branch 'origin/main' into feat-ios-task-composer
Jul 17, 2026
fddad9d
Test exact project root against seeded descendant
Jul 17, 2026
e2b0ff6
Prioritize exact project roots over seeded descendants
Jul 17, 2026
dfc4001
Test exact directory basename ordering
Jul 17, 2026
ab19101
Rank exact directory names ahead of context
Jul 17, 2026
fe417e8
Test glanceable directory result labels
Jul 17, 2026
ad189da
Make directory results glanceable
Jul 17, 2026
c691209
Let folder results breathe at large text sizes
Jul 17, 2026
0a1560d
Test batched task-template deletion
Jul 17, 2026
061842e
Close task composer review findings
Jul 17, 2026
c3cbfcd
Test accessibility task composer action sizing
Jul 17, 2026
aec7dc7
Keep task composer usable at large text sizes
Jul 17, 2026
4c44001
Add Shell icon lab and task composer proofs
Jul 17, 2026
d5fb2a3
Keep UX experiments in debug-only CMUX Labs
Jul 17, 2026
98d6885
test(ios): require created workspace identity
Jul 17, 2026
a9c5913
feat(ios): complete prompt-to-agent launch route
Jul 17, 2026
be33419
test(ios): define task retry phase policy
Jul 17, 2026
0e46f6b
fix(ios): make task submission retries explicit
Jul 17, 2026
c134a0e
Add failing directory pagination recovery tests
Jul 17, 2026
fd2fd7a
Fix task composer directory pagination recovery
Jul 17, 2026
d8e544d
test(mobile): cover filesystem job quota
Jul 17, 2026
ea9f98b
fix(mobile): cap concurrent directory jobs
Jul 17, 2026
442fe1b
Localize task composer recovery copy
Jul 17, 2026
97f8719
test(ios): cover task retry recovery
Jul 17, 2026
9b8c515
Add failing pagination recovery UI coverage
Jul 17, 2026
4c50392
Clarify pagination recovery failures
Jul 17, 2026
cd057ac
Localize task composer debug state
Jul 17, 2026
a61e249
Expand directory picker action targets
Jul 17, 2026
9278093
Harden pagination recovery UI proof
Jul 17, 2026
dc745b9
test(ios): expose manual pairing to composer
Jul 17, 2026
2e6ace7
test(ios): cover connected task creation
Jul 17, 2026
07f03f9
fix(ios): refresh Macs after manual pairing
Jul 17, 2026
ae4ae8d
fix(ios): finish composer debug compilation
Jul 18, 2026
ea04121
test(ios): make connected composer proof deterministic
Jul 18, 2026
e065996
Merge remote-tracking branch 'origin/main' into feat-ios-task-composer
Jul 18, 2026
abc44f3
test(ios): align mock host with tagged build isolation
Jul 18, 2026
6bf5bb8
test(ios): prove labs stay out of release
Jul 18, 2026
36fe8e8
fix(ios): keep labs experiments out of release
Jul 18, 2026
8b5a4ed
test(ios): cover task composer autofocus
Jul 18, 2026
3f3c81e
test(ios): require visible task composer keyboard
Jul 18, 2026
46ec346
fix(ios): own task composer initial focus at sheet lifecycle
Jul 18, 2026
bf989cf
Merge remote-tracking branch 'origin/main' into feat-ios-task-composer
Jul 18, 2026
7b2b247
Refine iOS task composer hierarchy
Jul 18, 2026
31e9cb3
test(ios): cover directory browser scroll responsiveness
Jul 20, 2026
ea6fe74
fix(ios): stabilize task directory browsing
Jul 20, 2026
91ea857
Gate iOS task composer behind beta setting
Jul 20, 2026
4fbd5c8
Merge remote-tracking branch 'origin/main' into feat-ios-task-composer
Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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))))
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Result<Data, MobileShellConnectionError>, Never>
enum PendingRequestSettlement {
case response(Result<Data, MobileShellConnectionError>)
case cancelled
}
typealias PendingContinuation = CheckedContinuation<PendingRequestSettlement, Never>
typealias ConnectingTask = (id: UUID, lease: MobileRPCConnectAttemptLease?, task: Task<any CmxByteTransport, any Error>, waiters: Set<UUID>, completed: Bool)
static let defaultAbandonedConnectCleanupTimeoutNanoseconds: UInt64 = 1_000_000_000
static let defaultLateAbandonedConnectCloseTimeoutNanoseconds: UInt64 = 5_000_000_000
Expand Down Expand Up @@ -123,10 +127,10 @@ actor MobileCoreRPCSession {
let frame = try MobileSyncFrameCodec.encodeFrame(payload)
let responseTimeoutNanoseconds = try taskTimeout.remainingNanoseconds(until: deadlineUptimeNanoseconds)

let result: Result<Data, MobileShellConnectionError> = 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()
Expand All @@ -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
Expand All @@ -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<String>) -> EventSubscription {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 }
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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 })
}
}
Original file line number Diff line number Diff line change
@@ -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("~/")
}
}
Loading