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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,9 @@ local repository. Installing a newer Try Omarchy app therefore does not apply
all of that app's factory-image changes to an existing VM, and an in-guest
update should not be assumed to reproduce them. A confirmed reset is the
deliberate, destructive way to start again from the newest bundled factory.
Reset deletes the saved VM; the next launch prepares its replacement. Launch
shows image preparation and verification progress when a new disk is needed,
then startup progress.

### Updating integrations in an existing VM

Expand Down
25 changes: 16 additions & 9 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,13 @@ creates the account on first boot.
(Bopomofo), the fontconfig rule prefers Traditional Chinese Han variants for
`zh-TW` text, and Chromium receives the Wayland IME flag needed for fcitx5.

When no VM exists, launch decompresses the compressed factory directly into its
unpublished writable disk, verifies its expanded size and digest, and publishes it
atomically. No expanded factory-image cache is retained. Development launches
through `make run` use the same single-workspace policy as normal app launches;
confirmed reset deletes the workspace, and the next launch selects the current
factory. Ephemeral mode also selects the current factory for testing.

Resources can set an optional maximum virtual disk capacity. On the next
normal launch, an existing disk is sparsely extended under the workspace lock,
after validating its metadata and boot kit. The native helper binds the change
Expand All @@ -262,7 +269,7 @@ allocates blocks as guest writes arrive; the configured capacity does not
reserve host space. New launcher settings default to 64 GiB, raised to the
existing capacity when larger. The editor always displays a capacity; older
settings without one display the existing or factory capacity.
New VMs use the selected capacity when their factory clone is prepared.
New VMs use the selected capacity when their writable disk is prepared.

Nothing is overwritten while the app runs. The app bundle and packaged factory
disk remain unchanged. Normal user launches use one private writable disk under
Expand All @@ -275,12 +282,13 @@ launch the existing VM without decompressing, cloning, expanding, or charging
free space for its new factory disk.

The current bundled factory applies only when no persistent VM exists, after an
explicitly confirmed reset, or in ephemeral mode. New and reset VMs atomically
stage the current factory's boot kit with the new writable disk. A compatible
explicitly confirmed reset, or in ephemeral mode. Reset only deletes the VM;
launch atomically stages the current factory's boot kit with a new writable
disk when none exists. A compatible
legacy identity-keyed disk can be migrated into the single workspace without
discarding its contents. If several recognized legacy disks exist, normal
launch stops at the start menu; confirmed reset safely removes them before
publishing one fresh workspace. Unrecognized host files are always left
launch stops at the start menu; confirmed reset safely removes them. The next
launch publishes one fresh workspace. Unrecognized host files are always left
untouched.

Older schema-2 VMs predate saved boot kits. Their first preserving launch uses
Expand All @@ -305,10 +313,9 @@ launcher receives that choice as `OMARCHY_QEMU_GPU_STATE_ROOT`. The chosen
folder is used as-is: it is never restructured with a folder created inside
it, so it must already be empty (or already be a workspace Omarchy has used)
— a populated folder or a drive's top level is refused with an explanation
instead. The volume must
be APFS: the storage library clones the factory image with `cp -c` and expands
the working disk sparsely, and it serializes launches with a `lockf` advisory
lock. On exFAT the same expansion allocates the full working size immediately,
instead. The volume must be APFS: the storage library expands the working
disk sparsely and uses `cp -c` for uncompressed development sources. It
serializes launches with a `lockf` advisory lock. On exFAT the same expansion allocates the full working size immediately,
and on a network share the lock is unreliable. Both layers check independently, the app
when the folder is chosen and the shell library again at launch, because the
volume can change in between. A location change never moves the existing VM;
Expand Down
33 changes: 22 additions & 11 deletions macos/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,14 @@ Runtime caches are private to `macos/.build/`; user-facing output always goes
to `dist/`. The generated app lives inside `dist/app.noindex/`, which keeps a
development build from appearing beside an installed copy in Command-Space.

Normal app launches maintain one stable user VM disk under
`~/Library/Application Support/Try Omarchy/VM/v1`. Storage integration tests
and specialized development runs can opt into identity-keyed parallel disks by
setting `OMARCHY_QEMU_GPU_DEVELOPMENT_MULTI_DISK=1`; release behavior leaves it
unset. Each persistent disk keeps the identity of the factory that created it
and is paired with a private, validated boot kit containing that factory's
Normal app launches and `make run` maintain one stable user VM disk under
`~/Library/Application Support/Try Omarchy/VM/v1`, reused across factory builds.
Use `make reset` to replace it with the current factory or `make run-ephemeral`
to test that factory without retaining its disk. Storage integration tests and
specialized direct-script runs can opt into identity-keyed parallel disks by
setting `OMARCHY_QEMU_GPU_DEVELOPMENT_MULTI_DISK=1`; the development app wrapper
explicitly disables this mode. Each persistent disk keeps the identity of the
factory that created it and is paired with a private, validated boot kit containing that factory's
kernel, initramfs, and base command line. App updates reuse the disk and its
boot kit; the current bundled factory is selected only for a new, reset, or
ephemeral VM. This keeps an older root filesystem on its matching kernel-module
Expand All @@ -114,7 +116,11 @@ the normal launch. Unsupported storage or boot ABIs, and ambiguous multiple
legacy disks, still use the user-facing, confirmed Reset Omarchy flow.
That destructive flow keeps **Reset** disabled until the user types
`Try Omarchy` exactly in a native sheet. Cancelling or dismissing the sheet
returns control without invoking the storage reset.
returns control without invoking the storage reset. During reset, the disabled
button names the current phase: checking the VM, deleting it, then finishing
the reset. The next launch prepares a new disk if needed, with its button
showing checking, image preparation, verification, setup, and startup phases.
Phase changes come from the launcher process and are also announced to VoiceOver.

The start menu can move that workspace to any APFS folder the user picks; the
folder is used exactly as chosen, never with a folder created inside it — a
Expand All @@ -125,10 +131,15 @@ variable still wins, so the development and test override keeps working
unchanged. Reset composes its environment exactly as a launch does, so it
always erases the workspace the user is actually running.

Reset reuses the verified, identity-keyed factory cache and APFS cloning. The
validated native helper streams SHA-256 through CryptoKit, including the full
expanded factory digest on a cache miss. Each storage transaction flushes its
written files before its staging directory, then flushes the parent after the
Reset deletes the VM without creating a replacement or requiring creation
headroom. On the next launch, a missing VM decompresses the bundled factory
directly into the staged writable disk; no expanded factory-image cache is
retained. The validated native helper
streams SHA-256 through CryptoKit, including the full expanded factory digest.
Existing VMs launch without decompression. Confirmed reset also removes safely
recognized disks left by older development launches; normal launch preserves
those disks and requests reset if there are several. Each storage transaction
flushes its written files before its staging directory, then flushes the parent after the
atomic rename. This avoids repeated system-wide `sync` calls without dropping
checksums, workspace locks, or interrupted-transaction recovery. A newly created
workspace still uses one global sync to persist its marker and directory
Expand Down
10 changes: 9 additions & 1 deletion macos/Sources/OmarchyVMHelper/QEMUGPULauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -772,6 +772,8 @@ enum CameraPreflight {
final class QEMUGPUProcessSupervisor: @unchecked Sendable {
enum LaunchEvent: Equatable {
case virtualMachineReady(qmpSocketPath: String?)
case resetProgress(VMResetPhase)
case launchProgress(VMLaunchPhase)
}

struct StandardErrorDrain {
Expand All @@ -792,6 +794,8 @@ final class QEMUGPUProcessSupervisor: @unchecked Sendable {
private var errorPipe: Pipe?
private var errorBuffer = ""
private var didReportVirtualMachineStart = false
private var resetProgressStream: VMProgressStream<VMResetPhase>?
private var launchProgressStream: VMProgressStream<VMLaunchPhase>?

func start(
executableURL: URL,
Expand Down Expand Up @@ -859,6 +863,9 @@ final class QEMUGPUProcessSupervisor: @unchecked Sendable {
errorPipe = pipe
errorBuffer = ""
didReportVirtualMachineStart = false
let isReset = arguments.first == QEMUGPUStorageOption.resetStorageOnly.rawValue
resetProgressStream = isReset ? VMProgressStream(operation: .reset) : nil
launchProgressStream = isReset ? nil : VMProgressStream(operation: .launch)
lock.unlock()

do {
Expand Down Expand Up @@ -998,7 +1005,8 @@ final class QEMUGPUProcessSupervisor: @unchecked Sendable {
lock.lock()
defer { lock.unlock() }
errorBuffer += String(decoding: data, as: UTF8.self)
var launchEvents: [LaunchEvent] = []
var launchEvents = (resetProgressStream?.append(data) ?? []).map(LaunchEvent.resetProgress)
launchEvents += (launchProgressStream?.append(data) ?? []).map(LaunchEvent.launchProgress)
if !didReportVirtualMachineStart,
let event = Self.virtualMachineReadyEvent(in: errorBuffer) {
didReportVirtualMachineStart = true
Expand Down
54 changes: 48 additions & 6 deletions macos/Sources/OmarchyVMHelper/StartMenuWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,11 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
private var microphoneRequestInFlight = false
private var cameraRequestInFlight = false
private var resetInProgress = false
private var resetPhase = VMResetPhase.checking
private weak var resetActionButton: OmarchyActionButton?
private var launchInProgress = false
private var launchPhase = VMLaunchPhase.checking
private weak var launchActionButton: OmarchyActionButton?
private var virtualMachineRunning = false
private var closeRunningSettings: (() -> Void)?
private var shutdownInProgress = false
Expand Down Expand Up @@ -487,6 +491,36 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
window.orderOut(nil)
}

func launchDidProgress(to phase: VMLaunchPhase) {
guard launchInProgress, launchPhase != phase else { return }
launchPhase = phase
launchActionButton?.updateTitle(phase.buttonTitle)
launchActionButton?.invalidateIntrinsicContentSize()
NSAccessibility.post(
element: window,
notification: .announcementRequested,
userInfo: [
.announcement: phase.buttonTitle,
.priority: NSAccessibilityPriorityLevel.medium.rawValue,
]
)
}

func resetDidProgress(to phase: VMResetPhase) {
guard resetInProgress, resetPhase != phase else { return }
resetPhase = phase
resetActionButton?.updateTitle(phase.buttonTitle)
resetActionButton?.invalidateIntrinsicContentSize()
NSAccessibility.post(
element: window,
notification: .announcementRequested,
userInfo: [
.announcement: phase.buttonTitle,
.priority: NSAccessibilityPriorityLevel.medium.rawValue,
]
)
}

func resetDidFinish(errorMessage: String?) {
guard resetInProgress else { return }
resetInProgress = false
Expand All @@ -499,11 +533,11 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
alert.informativeText = errorMessage
} else {
alert.alertStyle = .informational
alert.messageText = "Omarchy has been reset"
alert.messageText = "Try Omarchy has been reset"
if let estimate = pendingResetSpaceEstimate {
alert.informativeText = "The VM is back to factory settings. Up to \(estimate) of disk space was reclaimed. You can launch whenever you’re ready."
alert.informativeText = "The VM has been deleted. Up to \(estimate) of disk space was reclaimed. A fresh VM will be prepared on the next launch."
} else {
alert.informativeText = "The VM is back to factory settings. You can launch whenever you’re ready."
alert.informativeText = "The VM has been deleted. A fresh VM will be prepared on the next launch."
}
}
pendingResetSpaceEstimate = nil
Expand All @@ -522,7 +556,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
/// Clears the resetting state when the controller refused to start the
/// reset at all. Deliberately silent, and deliberately not
/// `resetDidFinish(errorMessage: nil)` — nothing was erased, so claiming
/// "Omarchy has been reset" would be a lie about a destructive action.
/// "Try Omarchy has been reset" would be a lie about a destructive action.
func resetDidAbort() {
guard resetInProgress else { return }
resetInProgress = false
Expand Down Expand Up @@ -854,11 +888,12 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
let integrationHeading = sectionHeading("INTEGRATIONS")

let reset = OmarchyActionButton(
title: resetInProgress ? "Resetting Omarchy…" : "Reset Omarchy",
title: resetInProgress ? resetPhase.buttonTitle : "Reset Omarchy",
style: .danger,
target: self,
action: #selector(resetOmarchy)
)
resetActionButton = reset
reset.identifier = NSUserInterfaceItemIdentifier("reset-button")
reset.isEnabled = canResetStorage
&& !prelaunchControlsLocked
Expand All @@ -876,13 +911,14 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
manage.isEnabled = !controlsBusy
let resetAction = virtualMachineRunning && canResetStorage ? manage : reset

let launchButtonTitle = virtualMachineRunning ? "Done" : (launchInProgress ? "Launching Omarchy…" : "Launch Omarchy")
let launchButtonTitle = virtualMachineRunning ? "Done" : (launchInProgress ? launchPhase.buttonTitle : "Launch Omarchy")
let launchButton = OmarchyActionButton(
title: launchButtonTitle,
style: .primary,
target: self,
action: virtualMachineRunning ? #selector(closeSettings) : #selector(launchOmarchy)
)
launchActionButton = launchButton
launchButton.keyEquivalent = launchInProgress ? "" : "\r"
launchButton.isEnabled = virtualMachineRunning || (!launchInProgress
&& !resetInProgress)
Expand Down Expand Up @@ -1034,6 +1070,9 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
)
)
scrollView.reflectScrolledClipView(scrollView.contentView)
if resetInProgress {
reset.scrollToVisible(reset.bounds)
}
updatePermissionRequestControls()
}

Expand Down Expand Up @@ -1714,6 +1753,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
// controller, which owns the preference and can explain and offer
// to switch, rather than asking the user to confirm erasing a
// workspace we would only be guessing the identity of.
resetPhase = .checking
resetInProgress = true
render()
resetStorage()
Expand All @@ -1735,6 +1775,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
resetConfirmationPrompt = nil
guard confirmed else { return }
pendingResetSpaceEstimate = estimate
resetPhase = .checking
resetInProgress = true
render()
resetStorage()
Expand Down Expand Up @@ -1773,6 +1814,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
!resetInProgress,
!microphoneRequestInFlight,
!cameraRequestInFlight else { return }
launchPhase = .checking
launchInProgress = true
render()
launch()
Expand Down
38 changes: 6 additions & 32 deletions macos/Sources/OmarchyVMHelper/StorageLocation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,31 +66,23 @@ struct BundledGuestMetrics: Equatable {

/// How much room the workspace needs on the chosen volume.
///
/// The factory source is a real multi-gigabyte file, written once per guest
/// build. The working disk is an APFS clone of it that is then expanded
/// sparsely, so it costs almost nothing at creation and grows only as the
/// guest writes. That is why the floor and the comfort target differ so much.
/// A new disk is decompressed directly into the workspace, then expanded
/// sparsely. Its initial bytes and boot headroom must fit; the selected virtual
/// capacity is a longer-term space target rather than an immediate allocation.
struct StorageSpaceRequirement: Equatable {
var sourceBytes: Int64
var workingBytes: Int64
/// True when this volume already holds the materialized factory source for
/// the current guest build, which is therefore already paid for.
var sourceAlreadyPresent: Bool

static let headroomBytes: Int64 = 1_073_741_824

private var unpaidSourceBytes: Int64 {
sourceAlreadyPresent ? 0 : sourceBytes
}

/// Below this the workspace cannot even be created.
var floorBytes: Int64 {
unpaidSourceBytes + Self.headroomBytes
sourceBytes + Self.headroomBytes
}

/// Below this the VM starts but the guest can run out of room later.
var comfortBytes: Int64 {
unpaidSourceBytes + workingBytes
workingBytes + Self.headroomBytes
}
}

Expand Down Expand Up @@ -321,12 +313,7 @@ enum StorageLocationPolicy {
if let metrics, !hasRecordedPersistentDisk {
let requirement = StorageSpaceRequirement(
sourceBytes: metrics.sourceDiskBytes,
workingBytes: metrics.workingDiskBytes,
sourceAlreadyPresent: hasMaterializedSource(
stateRoot: root,
identity: metrics.identity,
fileManager: fileManager
)
workingBytes: metrics.workingDiskBytes
)
guard capabilities.availableBytes >= requirement.floorBytes else {
throw StorageLocationPolicyError.insufficientSpace(
Expand All @@ -348,19 +335,6 @@ enum StorageLocationPolicy {
)
}

/// True when the factory source for this guest build is already written to
/// the workspace, so its bytes must not be demanded a second time.
static func hasMaterializedSource(
stateRoot: String,
identity: String,
fileManager: FileManager = .default
) -> Bool {
let source = URL(fileURLWithPath: stateRoot, isDirectory: true)
.appendingPathComponent("images", isDirectory: true)
.appendingPathComponent("\(identity).ext4", isDirectory: false)
return fileManager.fileExists(atPath: source.path)
}

static func displayPath(_ path: String, homeDirectory: String) -> String {
if path == homeDirectory {
return "~"
Expand Down
Loading
Loading