diff --git a/Sources/OmarchyAppleInstaller/ClosedEngineHelperServer.swift b/Sources/OmarchyAppleInstaller/ClosedEngineHelperServer.swift index d192cf41..3d9e6f9a 100644 --- a/Sources/OmarchyAppleInstaller/ClosedEngineHelperServer.swift +++ b/Sources/OmarchyAppleInstaller/ClosedEngineHelperServer.swift @@ -34,6 +34,9 @@ /// An app from another build is replacing this helper; it takes no new /// work meanwhile, so replacing it can never cut a job short. case beingReplaced + /// A reinstall would delete the old Omarchy stub, and macOS could not be + /// confirmed as the startup disk first. Nothing was erased. + case macOSStartupNotSet } /// Whether the helper is idle, working on a job, or held for replacement by @@ -332,6 +335,10 @@ ) } + if operation == .install, package.candidateKind == "replace" { + try await setMacOSStartupBeforeReplace(authorization) + } + var tailer: EngineJournalTailer? if let progress, let journalURL = EngineJournalLocator.journalURL( @@ -402,6 +409,27 @@ return result } + /// A replace deletes the old Omarchy stub, which the Mac may start up + /// from. As removal does (eed259e), make the running macOS the startup + /// disk and the next restart's choice, and confirm it, before the engine + /// erases anything. + private func setMacOSStartupBeforeReplace(_ authorization: MachineOwnerAuthorization) + async throws + { + let disks = removalDisks + let validateAdministrator = removalAdminValidator + do { + try await Task.detached { + let snapshot = try disks.snapshot() + guard try disks.startup(snapshot) != .macOS else { return } + try validateAdministrator(authorization) + try setRunningMacOSAsStartupDisk(disks, snapshot: snapshot, authorization: authorization) + }.value + } catch { + throw ClosedEngineHelperError.macOSStartupNotSet + } + } + public func writeInstallConf( document: Data, storeIdentifier: String, diff --git a/Sources/OmarchyAppleInstaller/EngineHandoffPackageImporter.swift b/Sources/OmarchyAppleInstaller/EngineHandoffPackageImporter.swift index a15da1a7..208d6612 100644 --- a/Sources/OmarchyAppleInstaller/EngineHandoffPackageImporter.swift +++ b/Sources/OmarchyAppleInstaller/EngineHandoffPackageImporter.swift @@ -16,6 +16,8 @@ public let planDigest: String public let deviceIdentifier: String public let storeIdentifier: String + /// The plan's candidate kind, bound into the plan digest the importer checked. + public let candidateKind: String public init( packageURL: URL, @@ -29,7 +31,8 @@ bindingDigest: String, planDigest: String, deviceIdentifier: String, - storeIdentifier: String + storeIdentifier: String, + candidateKind: String ) { self.packageURL = packageURL self.manifestURL = manifestURL @@ -43,6 +46,7 @@ self.planDigest = planDigest self.deviceIdentifier = deviceIdentifier self.storeIdentifier = storeIdentifier + self.candidateKind = candidateKind } } @@ -166,7 +170,8 @@ bindingDigest: identity.bindingDigest, planDigest: request.planDigest, deviceIdentifier: request.deviceIdentifier, - storeIdentifier: request.storeIdentifier + storeIdentifier: request.storeIdentifier, + candidateKind: request.candidateKind ) } diff --git a/Sources/OmarchyAppleInstaller/InstallerAllocationRecommendation.swift b/Sources/OmarchyAppleInstaller/InstallerAllocationRecommendation.swift index bdd262d1..17a8125a 100644 --- a/Sources/OmarchyAppleInstaller/InstallerAllocationRecommendation.swift +++ b/Sources/OmarchyAppleInstaller/InstallerAllocationRecommendation.swift @@ -129,6 +129,22 @@ public struct InstallerAllocationRecommendation: ) } + /// Reinstalling over the existing Omarchy at `sourceIdentifier`: the + /// engine's replace candidate at its exact length. The engine erases that + /// install and reuses its extent, so macOS is not resized. + public init(replacing sourceIdentifier: String, in inventory: ValidatedEngineInventory) throws { + let matches = inventory.candidates.filter { + $0.kind == "replace" && $0.sourceIdentifier == sourceIdentifier + } + guard matches.count == 1, let match = matches.first else { + throw InstallerAllocationRecommendationError.noEligibleCandidate + } + candidate = match + minimumBytes = match.lengthBytes + maximumBytes = match.lengthBytes + requestedLengthBytes = match.lengthBytes + } + /// The free or resize candidate that comes closest to the install minimum, /// measured the way the ranking above admits candidates. Nil when the /// inventory offers no such candidate at all. diff --git a/Sources/OmarchyAppleInstaller/OmarchyRemoval.swift b/Sources/OmarchyAppleInstaller/OmarchyRemoval.swift index 1bcb845d..461aca72 100644 --- a/Sources/OmarchyAppleInstaller/OmarchyRemoval.swift +++ b/Sources/OmarchyAppleInstaller/OmarchyRemoval.swift @@ -289,10 +289,7 @@ }) } - var macOSName: String { - snapshot.containers.first { $0.uuid == snapshot.macOSContainerUUID }?.volumes - .first { $0.roles == ["System"] }?.name ?? "macOS" - } + var macOSName: String { snapshot.macOSName } /// Every intermediate state must be exactly the approved layout minus the /// partitions already removed. This also proves the gap is adjacent to macOS. @@ -374,7 +371,7 @@ throw RemovalFailure( message: "A macOS administrator account is needed to set the startup disk.") } - try setMacOSStartup(plan, snapshot: first, authorization: authorization) + try setRunningMacOSAsStartupDisk(disks, snapshot: first, authorization: authorization) var recorded = false do { try removeAndReturnSpace(plan) { phase in @@ -432,35 +429,43 @@ try plan.validate(disks.snapshot(), removed: removed, expanded: true) try record("complete") } + } - /// Nothing is deleted unless macOS then reports the running macOS as both - /// the startup disk and the next restart's choice. - private func setMacOSStartup( - _ plan: OmarchyRemovalPlan, snapshot: RemovalSnapshot, - authorization: MachineOwnerAuthorization - ) throws { - let name = plan.macOSName + extension RemovalSnapshot { + var macOSName: String { + containers.first { $0.uuid == macOSContainerUUID }?.volumes + .first { $0.roles == ["System"] }?.name ?? "macOS" + } + } + + /// Removal and a reinstall over an existing Omarchy both delete a stub the + /// Mac may start up from. Nothing is deleted unless macOS then reports the + /// running macOS as both the startup disk and the next restart's choice. + func setRunningMacOSAsStartupDisk( + _ disks: any RemovalDiskOperating, snapshot: RemovalSnapshot, + authorization: MachineOwnerAuthorization + ) throws { + let name = snapshot.macOSName + do { + try disks.setMacOSStartup(snapshot, nextOnly: false, authorization: authorization) + } catch let refusal as RemovalStartupRefusal { + throw RemovalFailure( + message: RemovalText.startupRefused(name, reason: refusal.reason), complete: true) + } + var now = try disks.startup(snapshot) + if now == .nextStartupOverride { do { - try disks.setMacOSStartup(snapshot, nextOnly: false, authorization: authorization) - } catch let refusal as RemovalStartupRefusal { - throw RemovalFailure( - message: RemovalText.startupRefused(name, reason: refusal.reason), complete: true) - } - var now = try disks.startup(snapshot) - if now == .nextStartupOverride { - do { - try disks.setMacOSStartup(snapshot, nextOnly: true, authorization: authorization) - } catch { - let reason = (error as? RemovalStartupRefusal)?.reason ?? error.localizedDescription - throw RemovalFailure( - message: RemovalText.startupUnconfirmed(name, now, reason: reason), complete: true) - } - now = try disks.startup(snapshot) - } - guard now == .macOS else { + try disks.setMacOSStartup(snapshot, nextOnly: true, authorization: authorization) + } catch { + let reason = (error as? RemovalStartupRefusal)?.reason ?? error.localizedDescription throw RemovalFailure( - message: RemovalText.startupUnconfirmed(name, now, reason: nil), complete: true) + message: RemovalText.startupUnconfirmed(name, now, reason: reason), complete: true) } + now = try disks.startup(snapshot) + } + guard now == .macOS else { + throw RemovalFailure( + message: RemovalText.startupUnconfirmed(name, now, reason: nil), complete: true) } } #endif diff --git a/Sources/OmarchyAppleInstallerApp/LiveInstallerEnvironment.swift b/Sources/OmarchyAppleInstallerApp/LiveInstallerEnvironment.swift index aaf10d76..dcf30137 100644 --- a/Sources/OmarchyAppleInstallerApp/LiveInstallerEnvironment.swift +++ b/Sources/OmarchyAppleInstallerApp/LiveInstallerEnvironment.swift @@ -211,6 +211,7 @@ final class LiveInstallerEnvironment: InstallerEnvironment, @unchecked Sendable func preparePlan( omarchyBytes: UInt64?, + replacing: String?, progress: @escaping @Sendable (AssetProgressUpdate) -> Void ) async throws -> PlanPreparationDisplay { let (host, hasTranscript) = lock.withLock { @@ -300,20 +301,26 @@ final class LiveInstallerEnvironment: InstallerEnvironment, @unchecked Sendable progress(AssetProgressUpdate(stage: .planning, rows: collector.rows())) - // Existing installs are never replaced or joined: report them and let - // the session refuse. + // An existing install is replaced only when the person chose to + // reinstall over exactly that one; otherwise report it and let the + // session refuse. A replace reuses the install's own space, so macOS is + // not resized. let existing = Self.existingInstalls(in: signedInspection.validated) - if !existing.isEmpty { + let recommendation: InstallerAllocationRecommendation + if let replacing { + recommendation = try InstallerAllocationRecommendation(replacing: replacing, in: inventory) + } else if !existing.isEmpty { return .existingInstallChoice(existing) + } else { + recommendation = try InstallerAllocationRecommendation( + inventory: inventory, + targetBytes: omarchyBytes ?? InstallerAllocationRecommendation.balancedTargetBytes, + reservedBytes: planningReserve, + snapshotConstraint: { + APFSSnapshotInspector().constraint(in: host.storage) + } + ) } - let recommendation = try InstallerAllocationRecommendation( - inventory: inventory, - targetBytes: omarchyBytes ?? InstallerAllocationRecommendation.balancedTargetBytes, - reservedBytes: planningReserve, - snapshotConstraint: { - APFSSnapshotInspector().constraint(in: host.storage) - } - ) let candidate = recommendation.candidate let requestedLengthBytes = recommendation.requestedLengthBytes @@ -670,8 +677,10 @@ final class LiveInstallerEnvironment: InstallerEnvironment, @unchecked Sendable release: String ) -> PlanDisplay { let length = review.plan.lengthBytes + // A free extent or a replaced install sits beside macOS, which keeps its size. + let keepsMacOS = ["free", "replace"].contains(review.plan.candidateKind) let total = - review.plan.candidateKind == "free" + keepsMacOS ? host.storage.containerSizeBytes + recommendation.candidate.lengthBytes : max(host.storage.containerSizeBytes, length) @@ -683,9 +692,8 @@ final class LiveInstallerEnvironment: InstallerEnvironment, @unchecked Sendable minimumBytes: recommendation.minimumBytes, maximumBytes: recommendation.maximumBytes, releaseDescription: release, - targetDescription: - "Internal storage · \(review.plan.candidateKind == "free" ? "Use free space" : "Resize macOS")", - fixedMacOSBytes: review.plan.candidateKind == "free" ? host.storage.containerSizeBytes : nil, + targetDescription: "Internal storage · " + Self.targetDescription(review.plan.candidateKind), + fixedMacOSBytes: keepsMacOS ? host.storage.containerSizeBytes : nil, macOSFreeBeforeAllocationBytes: review.plan.candidateKind == "resize" && recommendation.candidate.sourceIdentifier == host.storage.physicalStoreIdentifier @@ -695,6 +703,14 @@ final class LiveInstallerEnvironment: InstallerEnvironment, @unchecked Sendable ) } + private static func targetDescription(_ candidateKind: String) -> String { + switch candidateKind { + case "free": "Use free space" + case "replace": "Replace the existing Omarchy" + default: "Resize macOS" + } + } + static func completionDisplay( _ progress: InstallerExecutionProgress, installConf: InstallConfHandoff = .recorded diff --git a/Sources/OmarchyAppleInstallerApp/OnePage/OnePageInstallerView.swift b/Sources/OmarchyAppleInstallerApp/OnePage/OnePageInstallerView.swift index 819370d4..9903410c 100644 --- a/Sources/OmarchyAppleInstallerApp/OnePage/OnePageInstallerView.swift +++ b/Sources/OmarchyAppleInstallerApp/OnePage/OnePageInstallerView.swift @@ -293,8 +293,12 @@ struct OnePageInstallerView: View { case .existingInstallRefused: Button(PlainLanguage.closeInstaller) { NSApplication.shared.terminate(nil) } - .omarchyPrimaryButton() - .keyboardShortcut(.defaultAction) + .omarchySecondaryButton() + if session.canReinstallOverExisting { + Button(PlainLanguage.reinstallOmarchy) { Task { await session.reinstallOverExisting() } } + .omarchyPrimaryButton() + .keyboardShortcut(.defaultAction) + } case .planReview(_, let acknowledged): Button(PlainLanguage.planInstall) { @@ -438,12 +442,15 @@ struct OnePageInstallerView: View { .font(OmarchyTheme.heading) .fixedSize(horizontal: false, vertical: true) .padding(.vertical, 2) - Text( - "If you’d like to remove Omarchy and return its space to macOS, select Installation → Remove Omarchy from the menu bar." - ) - .font(OmarchyTheme.body) - .foregroundStyle(OmarchyTheme.secondaryText) - .fixedSize(horizontal: false, vertical: true) + if session.canReinstallOverExisting { + Text(PlainLanguage.reinstallDetail) + .font(OmarchyTheme.body) + .fixedSize(horizontal: false, vertical: true) + } + Text(PlainLanguage.removeOmarchyHint) + .font(OmarchyTheme.body) + .foregroundStyle(OmarchyTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) } } diff --git a/Sources/OmarchyAppleInstallerApp/PreviewInstallerEnvironment.swift b/Sources/OmarchyAppleInstallerApp/PreviewInstallerEnvironment.swift index e7b9b6c8..20f1d4cc 100644 --- a/Sources/OmarchyAppleInstallerApp/PreviewInstallerEnvironment.swift +++ b/Sources/OmarchyAppleInstallerApp/PreviewInstallerEnvironment.swift @@ -68,9 +68,10 @@ func preparePlan( omarchyBytes: UInt64?, + replacing: String?, progress: @escaping @Sendable (AssetProgressUpdate) -> Void ) async throws -> PlanPreparationDisplay { - if scenario == .existingInstall { + if scenario == .existingInstall, replacing == nil { try? await Task.sleep(for: .milliseconds(400)) return .existingInstallChoice([ ExistingInstallDisplay( diff --git a/Sources/OmarchyCanaryPhysicalTool/main.swift b/Sources/OmarchyCanaryPhysicalTool/main.swift index c947ea47..81020cc0 100644 --- a/Sources/OmarchyCanaryPhysicalTool/main.swift +++ b/Sources/OmarchyCanaryPhysicalTool/main.swift @@ -333,7 +333,8 @@ private enum OmarchyCanaryPhysicalTool { bindingDigest: bindingDigest, planDigest: plan.planDigest, deviceIdentifier: plan.deviceIdentifier, - storeIdentifier: plan.storeIdentifier + storeIdentifier: plan.storeIdentifier, + candidateKind: plan.candidateKind ) let result = try await executor.execute( package, diff --git a/Sources/OmarchyInstallerUXCore/InstallerEnvironment.swift b/Sources/OmarchyInstallerUXCore/InstallerEnvironment.swift index 4e7e50c3..b685aef1 100644 --- a/Sources/OmarchyInstallerUXCore/InstallerEnvironment.swift +++ b/Sources/OmarchyInstallerUXCore/InstallerEnvironment.swift @@ -98,8 +98,9 @@ } /// Plan preparation either yields a reviewable plan or reports the - /// existing installs it found, which the session refuses. Nothing is - /// approved or executed from either value. + /// existing installs it found, which the session refuses unless the person + /// chose to reinstall over one. Nothing is approved or executed from either + /// value. public enum PlanPreparationDisplay: Equatable, Sendable { case plan(PlanDisplay) case existingInstallChoice([ExistingInstallDisplay]) @@ -533,9 +534,12 @@ func inspect() async throws -> HostDisplay /// `omarchyBytes` asks the planner for that much space for Omarchy; nil /// keeps the balanced default. The engine still clamps the request to the - /// candidate's real minimum and maximum. + /// candidate's real minimum and maximum. `replacing` names the existing + /// install to reinstall over: the plan reuses its exact space and ignores + /// `omarchyBytes`. func preparePlan( omarchyBytes: UInt64?, + replacing: String?, progress: @escaping @Sendable (AssetProgressUpdate) -> Void ) async throws -> PlanPreparationDisplay func approve() throws diff --git a/Sources/OmarchyInstallerUXCore/InstallerSession.swift b/Sources/OmarchyInstallerUXCore/InstallerSession.swift index e5e8df46..e12709fc 100644 --- a/Sources/OmarchyInstallerUXCore/InstallerSession.swift +++ b/Sources/OmarchyInstallerUXCore/InstallerSession.swift @@ -7,8 +7,8 @@ case inspecting case unsupported(FailureDisplay) case welcome(HostDisplay) - /// Omarchy is already on this Mac. The installer never replaces or adds - /// to an existing install; this is a terminal page with a Close button. + /// Omarchy is already on this Mac. The installer never adds a second + /// one; it replaces this one only through `reinstallOverExisting()`. case existingInstallRefused(host: HostDisplay) case preparingPlan(AssetProgressUpdate) /// Everything is downloaded and verified; the plan waits for the person to @@ -109,6 +109,9 @@ /// The approved plan the engine refused before changing the disk, kept so /// a re-plan can start from the size the person chose. private var refusedPlan: PlanDisplay? + /// The existing install a reinstall replaces. Set only by + /// `reinstallOverExisting()`, and forgotten by every new inspection. + private var replacingInstall: ExistingInstallDisplay? /// True while a chosen size is being re-planned; the Plan screen stays /// visible with its controls disabled instead of showing the download /// screen again. @@ -244,6 +247,24 @@ await preparePlan(host: host) } + /// True on the refusal page when the inspection found exactly one + /// existing install, the only case a reinstall can name unambiguously. + public var canReinstallOverExisting: Bool { + guard !isBusy, case .existingInstallRefused(let host) = phase else { return false } + return host.existingInstalls.count == 1 + } + + /// Plans a reinstall over the one existing install: the engine erases it + /// and reuses its exact space, so macOS is not resized. Nothing is erased + /// here; the plan still needs review, approval and the owner's password. + public func reinstallOverExisting() async { + guard canReinstallOverExisting, case .existingInstallRefused(let host) = phase else { + return + } + replacingInstall = host.existingInstalls[0] + await preparePlan(host: host) + } + /// Every returned plan is reviewed anew, even when the engine clamps to /// the old allocation. A revision clears any tentative view value. public func replan(omarchyBytes: UInt64) async { @@ -284,7 +305,9 @@ defer { isBusy = false } do { - let outcome = try await environment.preparePlan(omarchyBytes: omarchyBytes) { + let outcome = try await environment.preparePlan( + omarchyBytes: omarchyBytes, replacing: replacingInstall?.sourceIdentifier + ) { [weak self] update in Task { @MainActor in guard let self, self.operationID == currentOperation else { return } @@ -840,6 +863,7 @@ recoveryRetryAvailable = false isExecuting = false lastHost = nil + replacingInstall = nil refusedPlan = nil encryptLinuxDisk = allowsEncryption environment.setEncryptLinuxDisk(allowsEncryption) diff --git a/Sources/OmarchyInstallerUXCore/InstallerSimulation.swift b/Sources/OmarchyInstallerUXCore/InstallerSimulation.swift index ee9d044f..66ed360f 100644 --- a/Sources/OmarchyInstallerUXCore/InstallerSimulation.swift +++ b/Sources/OmarchyInstallerUXCore/InstallerSimulation.swift @@ -172,6 +172,7 @@ public func preparePlan( omarchyBytes: UInt64?, + replacing: String?, progress: @escaping @Sendable (AssetProgressUpdate) -> Void ) async throws -> PlanPreparationDisplay { progress(AssetProgressUpdate(stage: .fetchingCatalog)) diff --git a/Sources/OmarchyInstallerUXCore/PlainLanguage.swift b/Sources/OmarchyInstallerUXCore/PlainLanguage.swift index 3cf3392b..4c04bf45 100644 --- a/Sources/OmarchyInstallerUXCore/PlainLanguage.swift +++ b/Sources/OmarchyInstallerUXCore/PlainLanguage.swift @@ -26,6 +26,11 @@ public static let existingInstallHeadline = "Omarchy is already installed" public static let closeInstaller = "Close" + public static let reinstallOmarchy = "Reinstall Omarchy" + public static let reinstallDetail = + "Reinstall erases this Omarchy and everything on it, then installs the new one in the same space. macOS keeps its size, so nothing has to be resized. You review the exact plan before anything changes." + public static let removeOmarchyHint = + "To remove Omarchy and return its space to macOS instead, select Installation → Remove Omarchy from the menu bar." // MARK: Screen B — Plan @@ -613,6 +618,17 @@ technicalDetail: technical ) } + let startup = ClosedEngineHelperError.macOSStartupNotSet as NSError + if domain == startup.domain, code == startup.code { + return FailureDisplay( + headline: "macOS couldn’t be set as the startup disk", + plainDetail: + "Reinstall sets macOS as the startup disk before it erases the old Omarchy, and macOS didn’t confirm that change. Nothing was erased.", + technicalDetail: technical, + remedy: + "Choose macOS in System Settings → General → Startup Disk, then try again." + ) + } return FailureDisplay( headline: "The installation service couldn’t complete the request", plainDetail: diff --git a/Tests/OmarchyAppleInstallerTrustCoreTests/ClosedEngineHelperServerTests.swift b/Tests/OmarchyAppleInstallerTrustCoreTests/ClosedEngineHelperServerTests.swift index dec070bc..fcbaae65 100644 --- a/Tests/OmarchyAppleInstallerTrustCoreTests/ClosedEngineHelperServerTests.swift +++ b/Tests/OmarchyAppleInstallerTrustCoreTests/ClosedEngineHelperServerTests.swift @@ -204,6 +204,49 @@ XCTAssertTrue(try importedEntries(in: fixture.destination).isEmpty) } + /// A reinstall deletes the old Omarchy stub, which the Mac may start up + /// from. Like removal (eed259e), the helper makes macOS the startup disk + /// and confirms it before the engine runs, and never runs the engine when + /// that fails. A fresh install never touches the startup disk. + func testAReplaceMakesMacOSTheStartupDiskBeforeTheEngineRuns() async throws { + let refused = RemovalStartupRefusal(reason: "Could not set boot device property") + let cases: [(String, RemovalStartup, [Result], Bool)] = + [ + ("replace", .other("Omarchy"), [.success(.macOS)], true), + ("replace", .other("Omarchy"), [.failure(refused)], false), + ("replace", .other("Omarchy"), [.success(.other("Omarchy"))], false), + ("replace", .macOS, [], true), + ("free", .other("Omarchy"), [], true), + ] + for (kind, before, afterSet, runsEngine) in cases { + let name = "\(kind) from \(before), bless \(afterSet)" + let fixture = try makeFixture(candidateKind: kind) + defer { try? FileManager.default.removeItem(at: fixture.root) } + let source = try openDirectory(fixture.source) + defer { try? source.close() } + let disk = FakeRemovalDisk() + disk.startupState = before + disk.startupAfterSet = afterSet + let executor = StartupRecordingExecutor(disk: disk, result: fixture.transcript) + let server = ClosedEngineHelperServer( + workingDirectory: fixture.destination, executor: executor, + credentialValidator: AcceptingMachineOwnerCredentialValidator(), + removalDisks: disk, removalAdminValidator: { _ in }) + + do { + _ = try await server.submit( + packageDirectory: source, authorization: try machineOwnerAuthorization()) + XCTAssertTrue(runsEngine, name) + } catch { + XCTAssertFalse(runsEngine, name) + XCTAssertEqual(error as? ClosedEngineHelperError, .macOSStartupNotSet, name) + } + let startups = await executor.startupAtExecution + XCTAssertEqual(startups, runsEngine ? [kind == "free" ? before : .macOS] : [], name) + XCTAssertEqual(disk.startupWrites.count, afterSet.count, name) + } + } + func testEngineFailureIsKeptInRootDiagnosticsAndRethrown() async throws { let fixture = try makeFixture() defer { try? FileManager.default.removeItem(at: fixture.root) } @@ -487,7 +530,8 @@ private func makeFixture( deviceIdentifier: String = "apple,j314s", transcriptPlanMismatch: Bool = false, - includeCompletion: Bool = true + includeCompletion: Bool = true, + candidateKind: String = "free" ) throws -> HelperServerFixture { let root = FileManager.default.temporaryDirectory.appendingPathComponent( "omarchy-helper-server-\(UUID().uuidString.lowercased())", @@ -528,10 +572,14 @@ to: source.appendingPathComponent("omarchy.img.zst") ) + let identityDigest = "sha256:" + String(repeating: "9", count: 64) + let identityField = candidateKind == "replace" ? [identityDigest] : [] + let identityJSON = + candidateKind == "replace" ? #","identity_digest":"\#(identityDigest)""# : "" let layoutDigest = lengthPrefixedDigest( [ - "disk0", "free", "disk0s3", "447750000000", "107374182400", - ], + "disk0", candidateKind, "disk0s3", "447750000000", "107374182400", + ] + identityField, prefix: "sha256:" ) let requiredHumanSteps = [ @@ -541,7 +589,7 @@ let engineVersion = "v0.9.0-omarchy.1" let requestPlanDigest = lengthPrefixedDigest( [ - deviceIdentifier, "disk0", layoutDigest, "free", "disk0s3", + deviceIdentifier, "disk0", layoutDigest, candidateKind, "disk0s3", "447750000000", "107374182400", engineVersion, engineDigest, metadataDigest, payloadDigest, requiredHumanSteps.joined(separator: ","), @@ -562,7 +610,7 @@ : payloadDigest let transcriptPlanDigest = lengthPrefixedDigest( [ - "apple,j314s", "disk0", layoutDigest, "free", "disk0s3", + "apple,j314s", "disk0", layoutDigest, candidateKind, "disk0s3", "447750000000", "107374182400", engineVersion, transcriptEngineDigest, transcriptMetadataDigest, transcriptPayloadDigest, @@ -578,7 +626,7 @@ ) let request = Data( """ - {"format":1,"operation":"install","plan_digest":"\(requestPlanDigest)","device_identifier":"\(deviceIdentifier)","store_identifier":"disk0","layout_digest":"\(layoutDigest)","candidate_kind":"free","source_identifier":"disk0s3","offset_bytes":447750000000,"length_bytes":107374182400,"engine_version":"\(engineVersion)","required_human_steps":["enterOneTrueRecovery","authenticateMachineOwner"]} + {"format":1,"operation":"install","plan_digest":"\(requestPlanDigest)","device_identifier":"\(deviceIdentifier)","store_identifier":"disk0","layout_digest":"\(layoutDigest)","candidate_kind":"\(candidateKind)","source_identifier":"disk0s3","offset_bytes":447750000000,"length_bytes":107374182400,"engine_version":"\(engineVersion)","required_human_steps":["enterOneTrueRecovery","authenticateMachineOwner"]} """.utf8 ) let identity = Data( @@ -601,8 +649,8 @@ var lines = [ #"{"schema_version":1,"sequence":1,"type":"inspection","payload":{"device_identifier":"apple,j314s","support":"supported"}}"#, - #"{"schema_version":1,"sequence":2,"type":"inventory","payload":{"layout_digest":"\#(layoutDigest)","system_store_identifier":"disk0","candidates":[{"kind":"free","source_identifier":"disk0s3","offset_bytes":447750000000,"length_bytes":107374182400,"minimum_install_bytes":67501226240,"minimum_container_bytes":0}]}}"#, - #"{"schema_version":1,"sequence":3,"type":"plan","payload":{"plan_digest":"\#(transcriptPlanDigest)","device_identifier":"apple,j314s","store_identifier":"disk0","layout_digest":"\#(layoutDigest)","candidate_kind":"free","source_identifier":"disk0s3","offset_bytes":447750000000,"length_bytes":107374182400,"engine_version":"\#(engineVersion)","engine_digest":"\#(transcriptEngineDigest)","metadata_digest":"\#(transcriptMetadataDigest)","payload_digest":"\#(transcriptPayloadDigest)","required_human_steps":["enterOneTrueRecovery","authenticateMachineOwner"]}}"#, + #"{"schema_version":1,"sequence":2,"type":"inventory","payload":{"layout_digest":"\#(layoutDigest)","system_store_identifier":"disk0","candidates":[{"kind":"\#(candidateKind)","source_identifier":"disk0s3","offset_bytes":447750000000,"length_bytes":107374182400,"minimum_install_bytes":67501226240,"minimum_container_bytes":0\#(identityJSON)}]}}"#, + #"{"schema_version":1,"sequence":3,"type":"plan","payload":{"plan_digest":"\#(transcriptPlanDigest)","device_identifier":"apple,j314s","store_identifier":"disk0","layout_digest":"\#(layoutDigest)","candidate_kind":"\#(candidateKind)","source_identifier":"disk0s3","offset_bytes":447750000000,"length_bytes":107374182400,"engine_version":"\#(engineVersion)","engine_digest":"\#(transcriptEngineDigest)","metadata_digest":"\#(transcriptMetadataDigest)","payload_digest":"\#(transcriptPayloadDigest)","required_human_steps":["enterOneTrueRecovery","authenticateMachineOwner"]}}"#, ] if includeCompletion { lines.append( @@ -693,6 +741,27 @@ } } + /// Records which disk the Mac would start up from when the engine starts. + private actor StartupRecordingExecutor: ImportedEngineHandoffExecuting { + private(set) var startupAtExecution = [RemovalStartup]() + private let disk: FakeRemovalDisk + private let result: Data + + init(disk: FakeRemovalDisk, result: Data) { + self.disk = disk + self.result = result + } + + func execute( + _ package: ImportedEngineHandoffPackage, + authorization: MachineOwnerAuthorization, + operation: EngineHandoffOperation + ) async throws -> Data { + startupAtExecution.append(disk.startupState) + return result + } + } + private struct FailingHandoffExecutor: ImportedEngineHandoffExecuting { let error: PinnedAsahiEngineExecutionError diff --git a/Tests/OmarchyAppleInstallerTrustCoreTests/InstallerAllocationRecommendationTests.swift b/Tests/OmarchyAppleInstallerTrustCoreTests/InstallerAllocationRecommendationTests.swift index 6244060e..6edc536f 100644 --- a/Tests/OmarchyAppleInstallerTrustCoreTests/InstallerAllocationRecommendationTests.swift +++ b/Tests/OmarchyAppleInstallerTrustCoreTests/InstallerAllocationRecommendationTests.swift @@ -691,6 +691,41 @@ final class InstallerAllocationRecommendationTests: XCTestCase { XCTAssertEqual(recommendation.candidate, free) } + /// A reinstall reuses the existing install's exact extent through the + /// engine's replace candidate, so macOS is not shrunk again. Numbers from + /// the 14-inch M2 Max on 2026-10-06, where removal grew macOS and the next + /// install shrank it back by the same 255,999,344,640 bytes in 2,740 s. + func testReplacingPlansTheExistingInstallsExactExtent() throws { + let resize = candidate( + kind: "resize", source: "disk0s2", length: 3_740_329_984_000, + minimumInstall: 76_562_825_216, minimumContainer: 3_564_621_529_088) + let replace = candidate( + kind: "replace", source: "disk0s3", length: 255_999_344_640, + minimumInstall: 76_562_825_216, + identityDigest: "sha256:" + String(repeating: "9", count: 64)) + let inventory = inventory([resize, replace]) + + let recommendation = try InstallerAllocationRecommendation( + replacing: "disk0s3", in: inventory) + + XCTAssertEqual(recommendation.candidate, replace) + XCTAssertEqual(recommendation.requestedLengthBytes, 255_999_344_640) + XCTAssertEqual(recommendation.minimumBytes, 255_999_344_640) + XCTAssertEqual(recommendation.maximumBytes, 255_999_344_640) + XCTAssertEqual( + try PinnedAsahiPlanRequest( + inventory: inventory, candidate: recommendation.candidate, + requestedLengthBytes: recommendation.requestedLengthBytes + ).candidateKind, "replace") + for missing in ["disk0s2", "disk0s9"] { + XCTAssertThrowsError( + try InstallerAllocationRecommendation(replacing: missing, in: inventory), missing + ) { + XCTAssertEqual($0 as? InstallerAllocationRecommendationError, .noEligibleCandidate) + } + } + } + private func inventory( _ candidates: [ValidatedEngineCandidate] ) -> ValidatedEngineInventory { diff --git a/Tests/OmarchyAppleInstallerTrustCoreTests/PinnedAsahiEngineExecutorTests.swift b/Tests/OmarchyAppleInstallerTrustCoreTests/PinnedAsahiEngineExecutorTests.swift index 362fc8dc..7860b07c 100644 --- a/Tests/OmarchyAppleInstallerTrustCoreTests/PinnedAsahiEngineExecutorTests.swift +++ b/Tests/OmarchyAppleInstallerTrustCoreTests/PinnedAsahiEngineExecutorTests.swift @@ -642,7 +642,8 @@ bindingDigest: "sha256:" + String(repeating: "b", count: 64), planDigest: String(repeating: "a", count: 64), deviceIdentifier: "apple,j314s", - storeIdentifier: "disk0" + storeIdentifier: "disk0", + candidateKind: "free" ), transcript: transcript ) @@ -804,7 +805,8 @@ bindingDigest: "sha256:" + String(repeating: "b", count: 64), planDigest: String(repeating: "a", count: 64), deviceIdentifier: "apple,j314s", - storeIdentifier: "disk0" + storeIdentifier: "disk0", + candidateKind: "free" ) } } diff --git a/Tests/OmarchyInstallerUXCoreTests/InstallerSessionTests.swift b/Tests/OmarchyInstallerUXCoreTests/InstallerSessionTests.swift index e1874dc9..4e793f0e 100644 --- a/Tests/OmarchyInstallerUXCoreTests/InstallerSessionTests.swift +++ b/Tests/OmarchyInstallerUXCoreTests/InstallerSessionTests.swift @@ -1297,6 +1297,59 @@ } } + /// Reinstalling plans the engine's replace of the one install the + /// inspection found, instead of Remove Omarchy (grow macOS) and a new + /// install (shrink it again). The plan still needs review and approval. + func testReinstallPlansTheReplaceOfTheOneExistingInstall() async { + let environment = MockInstallerEnvironment() + let install = ExistingInstallDisplay(sourceIdentifier: "disk0s3", sizeDescription: "256 GB") + environment.host = HostDisplay( + chipAndSpace: MockInstallerEnvironment.supportedHost.chipAndSpace, + supported: true, existingInstalls: [install]) + environment.existingInstalls = [install] + let session = InstallerSession(environment: environment) + await session.inspect() + XCTAssertTrue(session.canReinstallOverExisting) + + await session.reinstallOverExisting() + + guard case .planPrepared(let plan, _) = session.phase else { + return XCTFail("Expected a prepared plan, got \(session.phase)") + } + XCTAssertEqual(plan, environment.plan) + XCTAssertEqual(environment.prepareCount, 1) + XCTAssertEqual(environment.lastReplacing, "disk0s3") + XCTAssertFalse(environment.hasApprovedPlan, "nothing is approved by choosing to reinstall") + + // A fresh check forgets the choice: a normal install never replaces. + environment.host = MockInstallerEnvironment.supportedHost + environment.existingInstalls = [] + await session.inspect() + await session.continueToPlan() + XCTAssertEqual(environment.prepareCount, 2) + XCTAssertNil(environment.lastReplacing) + } + + func testReinstallIsUnavailableUnlessExactlyOneInstallWasFound() async { + let environment = MockInstallerEnvironment() + let installs = ["disk0s3", "disk0s8"].map { + ExistingInstallDisplay(sourceIdentifier: $0, sizeDescription: "128 GB") + } + environment.host = HostDisplay( + chipAndSpace: MockInstallerEnvironment.supportedHost.chipAndSpace, + supported: true, existingInstalls: installs) + let session = InstallerSession(environment: environment) + await session.inspect() + XCTAssertFalse(session.canReinstallOverExisting) + + await session.reinstallOverExisting() + + guard case .existingInstallRefused = session.phase else { + return XCTFail("Expected the refusal to stay, got \(session.phase)") + } + XCTAssertEqual(environment.prepareCount, 0) + } + func testReinspectResetsDisplayedAndStoredEncryptionChoice() async { let environment = MockInstallerEnvironment() let session = InstallerSession(environment: environment) @@ -1453,6 +1506,7 @@ } var lastOmarchyBytes: UInt64? + var lastReplacing: String? var payloadPrefetchRequired = false var prefetchGate: OperationGate? private(set) var prefetchStartCount = 0 @@ -1465,6 +1519,7 @@ func preparePlan( omarchyBytes: UInt64?, + replacing: String?, progress: @escaping @Sendable (AssetProgressUpdate) -> Void ) async throws -> PlanPreparationDisplay { savedProgress = progress @@ -1472,6 +1527,7 @@ prepareCount += 1 cancelsBeforePrepare = prefetchCancelCount lastOmarchyBytes = omarchyBytes + lastReplacing = replacing approved = false for update in progressUpdates { progress(update) @@ -1480,7 +1536,7 @@ if let prepareError { throw prepareError } - if !existingInstalls.isEmpty { + if !existingInstalls.isEmpty, replacing == nil { return .existingInstallChoice(existingInstalls) } return .plan(plan) diff --git a/Tests/OmarchyInstallerUXCoreTests/InstallerSimulationTests.swift b/Tests/OmarchyInstallerUXCoreTests/InstallerSimulationTests.swift index c9eb5126..418988ad 100644 --- a/Tests/OmarchyInstallerUXCoreTests/InstallerSimulationTests.swift +++ b/Tests/OmarchyInstallerUXCoreTests/InstallerSimulationTests.swift @@ -246,9 +246,10 @@ func testFreeExtentCanGrowWithoutChangingMacOSOrTotalCapacity() async throws { let environment = InstallerSimulationEnvironment(scenario: .freeSpace, delay: .zero) - let first = try await environment.preparePlan(omarchyBytes: nil, progress: { _ in }) + let first = try await environment.preparePlan( + omarchyBytes: nil, replacing: nil, progress: { _ in }) let next = try await environment.preparePlan( - omarchyBytes: 600_000_000_000, progress: { _ in }) + omarchyBytes: 600_000_000_000, replacing: nil, progress: { _ in }) guard case .plan(let initial) = first, case .plan(let larger) = next else { return XCTFail("Expected plans") }