Skip to content
Open
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
28 changes: 28 additions & 0 deletions Sources/OmarchyAppleInstaller/ClosedEngineHelperServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -332,6 +335,10 @@
)
}

if operation == .install, package.candidateKind == "replace" {
try await setMacOSStartupBeforeReplace(authorization)
}

var tailer: EngineJournalTailer?
if let progress,
let journalURL = EngineJournalLocator.journalURL(
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -29,7 +31,8 @@
bindingDigest: String,
planDigest: String,
deviceIdentifier: String,
storeIdentifier: String
storeIdentifier: String,
candidateKind: String
) {
self.packageURL = packageURL
self.manifestURL = manifestURL
Expand All @@ -43,6 +46,7 @@
self.planDigest = planDigest
self.deviceIdentifier = deviceIdentifier
self.storeIdentifier = storeIdentifier
self.candidateKind = candidateKind
}
}

Expand Down Expand Up @@ -166,7 +170,8 @@
bindingDigest: identity.bindingDigest,
planDigest: request.planDigest,
deviceIdentifier: request.deviceIdentifier,
storeIdentifier: request.storeIdentifier
storeIdentifier: request.storeIdentifier,
candidateKind: request.candidateKind
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
65 changes: 35 additions & 30 deletions Sources/OmarchyAppleInstaller/OmarchyRemoval.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
46 changes: 31 additions & 15 deletions Sources/OmarchyAppleInstallerApp/LiveInstallerEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion Sources/OmarchyCanaryPhysicalTool/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 7 additions & 3 deletions Sources/OmarchyInstallerUXCore/InstallerEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading