Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions Development/Simulation.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Requires Xcode and XcodeBuildMCP. The launcher builds the debug app and supplies
| Authorization | Missing helper; plan changes before approval; first credentials rejected | Respect the gate; permit back-navigation before submission; rejected dummy credentials can be retried |
| Execution | Connection lost; empty reply; helper failure; interrupted live progress | Retain verified activity; uncertain outcomes cannot start another installation |
| Engine refusal | Space shrinks before install | The engine refuses the approved 137 GB plan before any disk step; the page says no disk changes were made and offers **Check available space**, which returns to review with 133 GB, a size-change notice and a cleared acknowledgement; the next install succeeds |
| Prepared resume | Prepared resume target changed | Explain earlier disk preparation, retain the saved checkpoint in Last verified activity, preserve the journal, and require reconciliation without offering a fresh install or blind retry |
| Recovery | Recovery fails then retry succeeds; manual recovery required; shutdown request fails | Retry only the eligible Recovery path; keep instructions visible when shutdown fails |

Click the size field to edit GB. Use the green checkmark or Return to apply; use the red cross or Escape to cancel. Invalid or out-of-range values cannot be applied. Installation is disabled until you apply or cancel.
Expand Down
17 changes: 11 additions & 6 deletions Sources/OmarchyAppleInstaller/ClosedEngineHandoffProcess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,17 @@
}

public func execute(_ invocation: ClosedEngineInvocation) async throws -> Data {
let handoff = try InstallerPerformance.measure("app_handoff") {
try ClosedEngineHandoffBuilder().prepare(
invocation: invocation,
assets: assets,
in: handoffDirectory
)
let handoff: PreparedEngineHandoff
do {
handoff = try InstallerPerformance.measure("app_handoff") {
try ClosedEngineHandoffBuilder().prepare(
invocation: invocation,
assets: assets,
in: handoffDirectory
)
}
} catch {
throw InstallerPreSubmissionFailure(error)
}
defer { try? FileManager.default.removeItem(at: handoff.packageURL) }
return try await submitter.submit(
Expand Down
6 changes: 5 additions & 1 deletion Sources/OmarchyAppleInstaller/EngineFailureDiagnostics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@
case deviceUnsupported = 3
/// The plan, request, or identity failed an integrity check.
case planIntegrity = 4
/// A resumed run could not reconcile its checkpointed APFS target.
case preparedResumeMismatch = 5

/// Exact final-exception messages raised before the first mutation by
/// Exact final-exception messages raised by admission or resume checks in
/// `omarchy_execution.admit_execution`, `omarchy_runtime` and the Asahi
/// adapter preflight. Anything not listed stays unclassified.
static func classify(exception: String, message: String) -> EngineFailureReason {
Expand All @@ -37,6 +39,8 @@
"approved candidate is unavailable", "system store changed during resume",
"inventory changed during resume":
return .diskLayoutChanged
case "prepared resume target does not match checkpoint":
return .preparedResumeMismatch
case "device is explicitly unsupported":
return .deviceUnsupported
case "plan digest mismatch", "helper environment binding mismatch",
Expand Down
40 changes: 31 additions & 9 deletions Sources/OmarchyAppleInstaller/InstallerExecutionCoordinator.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
#if os(macOS)
import Foundation

/// Created only by app-side checks that fail before an execution request is sent.
/// A transport failure after submission must never be wrapped in this type.
public struct InstallerPreSubmissionFailure: Error, Sendable {
public let underlying: any Error

public init(_ underlying: any Error) {
self.underlying = underlying
}
}

public struct InstallerExecutionCoordinator: Sendable {
private let processAdapter = ClosedEngineProcessAdapter()

public init() {}
private let ping: @Sendable (AuthenticatedEngineXPCSubmitter) async throws -> Void

public init() {
ping = { try await $0.ping() }
}

init(ping: @escaping @Sendable (AuthenticatedEngineXPCSubmitter) async throws -> Void) {
self.ping = ping
}

public func execute(
_ prepared: PreparedInstallerPlanExecution,
Expand Down Expand Up @@ -53,14 +71,18 @@
operation: EngineHandoffOperation,
journalProgress: (@Sendable (Data) -> Void)?
) async throws -> InstallerExecutionProgress {
let submitter = try AuthenticatedEngineXPCSubmitter(
machServiceName: configuration.helperMachServiceName,
helperCodeSigningRequirement:
configuration.helperCodeSigningRequirement,
journalProgress: journalProgress
)
// Nothing leaves the app until the helper has answered.
try await submitter.ping()
let submitter: AuthenticatedEngineXPCSubmitter
do {
submitter = try AuthenticatedEngineXPCSubmitter(
machServiceName: configuration.helperMachServiceName,
helperCodeSigningRequirement: configuration.helperCodeSigningRequirement,
journalProgress: journalProgress
)
// Ping sends no execution request, handoff or credentials.
try await ping(submitter)
} catch {
Comment thread
maralcbr marked this conversation as resolved.
throw InstallerPreSubmissionFailure(error)
}
let process = ClosedEngineHandoffProcess(
assets: prepared.review.assets,
handoffDirectory: handoffDirectory,
Expand Down
37 changes: 28 additions & 9 deletions Sources/OmarchyAppleInstaller/PayloadPrefetch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -345,20 +345,30 @@
work: @escaping @Sendable () async throws -> Void
) async throws {
let stream = network.updates()
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask { try await work() }
try await withThrowingTaskGroup(of: Bool.self) { group in
group.addTask {
try await work()
try Task.checkCancellation()
return true
}
group.addTask {
for await path in stream {
if Task.isCancelled {
return
return false
}
if !path.allowsPrefetch {
throw PayloadPrefetchError.meteredNetwork
}
}
return false
}
do {
try await group.next()
// A stopped path observer is not proof that the file was staged.
// Only the work task can establish successful verification.
while let workFinished = try await group.next() {
if workFinished { break }
}
try Task.checkCancellation()
group.cancelAll()
} catch {
group.cancelAll()
Expand Down Expand Up @@ -531,7 +541,8 @@

private var generation = UUID()
private var controller: PayloadPrefetchController?
private var runningDigest: String?
private var runningArtifact: PinnedInstallerArtifact?
private var runningDestination: URL?
private var latest: PayloadPrefetchState = .idle
private var observers: [UUID: @Sendable (PayloadPrefetchState) -> Void] = [:]
private var waiters: [UUID: CheckedContinuation<Void, any Error>] = [:]
Expand Down Expand Up @@ -581,7 +592,9 @@
let parent = canonical.deletingLastPathComponent()

let reuse = lock.withLock { () -> Bool in
if runningDigest == artifact.expectedDigest {
if runningArtifact == artifact,
runningDestination == canonical.standardizedFileURL
{
switch latest {
case .failed, .cancelled:
return false
Expand All @@ -600,7 +613,8 @@
lock.lock()
predecessor = controller
controller = nil
runningDigest = artifact.expectedDigest
runningArtifact = artifact
runningDestination = canonical.standardizedFileURL
generation = UUID()
self.generation = generation
latest = .idle
Expand Down Expand Up @@ -683,7 +697,11 @@
}
}
}
try self.promote(staged.fileURL, to: canonical)
try Task.checkCancellation()
try self.lock.withLock {
guard self.generation == generation else { throw CancellationError() }
try self.promote(staged.fileURL, to: canonical)
Comment thread
maralcbr marked this conversation as resolved.
}
try? FileManager.default.removeItem(at: work)
}
}
Expand Down Expand Up @@ -767,7 +785,8 @@
public func cancel() {
lock.lock()
latest = .idle
runningDigest = nil
runningArtifact = nil
runningDestination = nil
cancelEpoch &+= 1
let existing = controller
controller = nil
Expand Down
18 changes: 18 additions & 0 deletions Sources/OmarchyAppleInstaller/VerifiedArtifactStager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ public struct VerifiedArtifactStager: Sendable {
in stagingDirectory: URL,
progress: ArtifactStagingProgressHandler? = nil
) async throws -> StagedInstallerArtifact {
try Task.checkCancellation()
let fileManager = FileManager.default
try ensureSafeDirectory(stagingDirectory, fileManager: fileManager)
let destination = stagingDirectory.appendingPathComponent(
Expand All @@ -291,6 +292,8 @@ public struct VerifiedArtifactStager: Sendable {
if fileManager.fileExists(atPath: destination.path) {
do {
try verify(artifact, at: destination)
} catch is CancellationError {
throw CancellationError()
} catch {
throw ArtifactStageError.destinationConflict(artifact.fileName)
}
Expand Down Expand Up @@ -320,6 +323,7 @@ public struct VerifiedArtifactStager: Sendable {
try fileManager.copyItem(at: source, to: pending)
try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: pending.path)
try verify(artifact, at: pending)
try Task.checkCancellation()
try fileManager.moveItem(at: pending, to: destination)
report(
progress, artifact: artifact, phase: .verified, bytesCompleted: artifact.expectedSizeBytes)
Expand Down Expand Up @@ -373,6 +377,7 @@ public struct VerifiedArtifactStager: Sendable {
destination: URL,
progress: ArtifactStagingProgressHandler?
) async throws -> StagedInstallerArtifact {
try Task.checkCancellation()
let fileManager = FileManager.default
var partFileURLs = [URL]()
var completedBytes: UInt64 = 0
Expand Down Expand Up @@ -424,6 +429,7 @@ public struct VerifiedArtifactStager: Sendable {
expectedSizeBytes: artifact.expectedSizeBytes
)

try Task.checkCancellation()
do {
try fileManager.moveItem(at: pending, to: destination)
} catch {
Expand All @@ -432,6 +438,8 @@ public struct VerifiedArtifactStager: Sendable {
}
do {
try verify(artifact, at: destination)
} catch is CancellationError {
throw CancellationError()
} catch {
throw ArtifactStageError.destinationConflict(artifact.fileName)
}
Expand Down Expand Up @@ -479,6 +487,8 @@ public struct VerifiedArtifactStager: Sendable {
expectedSizeBytes: expectedSizeBytes,
at: destination
)
} catch is CancellationError {
throw CancellationError()
} catch {
throw ArtifactStageError.destinationConflict(fileName)
}
Expand All @@ -498,6 +508,7 @@ public struct VerifiedArtifactStager: Sendable {
)
defer { try? fileManager.removeItem(at: pending) }

try Task.checkCancellation()
let materialization = try promoter.promote(
source: downloaded,
pending: pending
Expand All @@ -509,6 +520,7 @@ public struct VerifiedArtifactStager: Sendable {
at: pending
)

try Task.checkCancellation()
do {
try fileManager.moveItem(at: pending, to: destination)
} catch {
Expand All @@ -522,6 +534,8 @@ public struct VerifiedArtifactStager: Sendable {
expectedSizeBytes: expectedSizeBytes,
at: destination
)
} catch is CancellationError {
throw CancellationError()
} catch {
throw ArtifactStageError.destinationConflict(fileName)
}
Expand All @@ -536,6 +550,7 @@ public struct VerifiedArtifactStager: Sendable {
expectedDigest: String,
expectedSizeBytes: UInt64
) throws {
try Task.checkCancellation()
let fileManager = FileManager.default
guard
fileManager.createFile(
Expand All @@ -558,6 +573,7 @@ public struct VerifiedArtifactStager: Sendable {
while let chunk = try reader.read(upToCount: Self.chunkBytes),
!chunk.isEmpty
{
try Task.checkCancellation()
try writer.write(contentsOf: chunk)
hasher.update(data: chunk)
let total = size.addingReportingOverflow(UInt64(chunk.count))
Expand Down Expand Up @@ -732,6 +748,7 @@ public struct VerifiedArtifactStager: Sendable {
private func digestAndSize(
of fileURL: URL
) throws -> (digest: String, size: UInt64) {
try Task.checkCancellation()
let handle = try FileHandle(forReadingFrom: fileURL)
defer { try? handle.close() }
var hasher = SHA256()
Expand All @@ -740,6 +757,7 @@ public struct VerifiedArtifactStager: Sendable {
while let chunk = try handle.read(upToCount: Self.chunkBytes),
!chunk.isEmpty
{
try Task.checkCancellation()
let addition = UInt64(chunk.count)
guard !size.addingReportingOverflow(addition).overflow else {
throw ArtifactStageError.invalidExpectedSize
Expand Down
12 changes: 10 additions & 2 deletions Sources/OmarchyAppleInstallerApp/InstallerComponents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -194,21 +194,29 @@ struct ProgressTrack: View {
Capsule()
.fill(OmarchyTheme.accent)
.frame(width: geometry.size.width * min(1, max(0, fraction)))
.animation(.linear(duration: 0.18), value: fraction)
.animation(reduceMotion ? nil : .linear(duration: 0.18), value: fraction)
} else {
Capsule()
.fill(OmarchyTheme.accent.opacity(0.75))
.frame(width: geometry.size.width * 0.32)
.offset(x: sweep ? geometry.size.width * 0.68 : 0)
.animation(
.easeInOut(duration: 1.1).repeatForever(autoreverses: true),
reduceMotion ? nil : .easeInOut(duration: 1.1).repeatForever(autoreverses: true),
value: sweep
)
.onAppear { sweep = !reduceMotion }
.onChange(of: reduceMotion) { _, reduced in sweep = !reduced }
}
}
}
.frame(height: height)
.accessibilityRepresentation {
if let fraction {
ProgressView("Progress", value: min(1, max(0, fraction)))
} else {
ProgressView("In progress")
}
}
}
}

Expand Down
Loading
Loading