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
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -590,9 +590,13 @@ the native application menu offers the same command. The update window checks
the project's latest stable GitHub release and links to its release notes and
download. Review the release's macOS requirements before installing.

**Automatically check for updates** is off by default. When enabled, opening
the app checks at most once every 24 hours; a newer release changes the start
menu link to **Update Available…**. Manual checks remain available at any time.
**Automatically check for updates** is on by default; an existing choice to
disable it is preserved. Opening the app checks at most once every 24 hours.
A newer release changes the start menu link to **Update Available…** and shows
a **Download / Skip** dialog when the launcher or settings are ready. It waits
while you use the guest or another dialog. **Download** opens the GitHub release
page; **Skip** hides reminders for that version. Each release is announced only
once, and the update link and manual checks remain available at any time.
Checks contact GitHub without a GitHub account, and failures do not block VM
startup. This feature does not download or install app updates automatically.

Expand Down
16 changes: 12 additions & 4 deletions docs/app-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,20 @@ not migrate that VM to the newest factory image.
## Release checks

The first stage of issue #232 provides version display, manual release checks,
and opt-in checks on app startup, limited to one attempt per 24 hours. Both
and default-on checks on app startup, limited to one attempt per 24 hours. An
explicit opt-out is preserved. Both
successful and failed attempts count toward the limit; manual checks bypass
it. A cached release keeps the update indicator available across launches.
Checks run asynchronously with bounded network timeouts and never invoke VM
shutdown, app replacement, or an installer. Automatic results only change the
launcher link; they do not interrupt a running guest or open a dialog.
shutdown, app replacement, or an installer. A newer known release changes the
launcher link and presents a Download / Skip sheet once per release, when the
launcher or settings are active and idle. Checks finishing during guest use,
launch/reset/shutdown, permission requests, or other dialogs defer presentation.
Both choices suppress further automatic reminders for that version; Skip leaves
the cached update link and manual download available. Network failures remain
quiet. Unknown installed releases and development builds never receive an
automatic update prompt. Reading an available-update result in the manual
Updates window also counts as its notification.

The checker uses GitHub's public `/repos/omacom/try-omarchy/releases/latest`
endpoint without credentials. It accepts published, non-prerelease `vX.Y.Z`
Expand All @@ -35,7 +43,7 @@ guest package version is used as a substitute.
Use [Sparkle 2](https://sparkle-project.org/documentation/) for signed downloads,
verification, replacement, and relaunch. The release checker is an interim
notification feature, not a second installation mechanism. When Sparkle ships,
replace its GitHub transport and migrate the user's opt-in preference; do not
replace its GitHub transport and preserve the user's automatic-check preference; do not
run two automatic checkers.

Before enabling installation, maintainers need to:
Expand Down
8 changes: 7 additions & 1 deletion macos/Sources/OmarchyVMHelper/AppRelease.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,12 @@ struct AppReleasePreferences {
private static let enabledKey = "appReleaseAutomaticChecks"
private static let lastAttemptKey = "appReleaseLastCheckAttempt"
private static let latestVersionKey = "appReleaseLatestVersion"
private static let lastPromptedVersionKey = "appReleaseLastPromptedVersion"

init(defaults: UserDefaults = .standard) { self.defaults = defaults }

var automaticChecks: Bool {
get { defaults.bool(forKey: Self.enabledKey) }
get { defaults.object(forKey: Self.enabledKey) == nil || defaults.bool(forKey: Self.enabledKey) }
nonmutating set { defaults.set(newValue, forKey: Self.enabledKey) }
}

Expand All @@ -158,6 +159,11 @@ struct AppReleasePreferences {

func recordAttempt(at date: Date) { defaults.set(date, forKey: Self.lastAttemptKey) }

var lastPromptedVersion: AppReleaseVersion? {
get { defaults.string(forKey: Self.lastPromptedVersionKey).flatMap(AppReleaseVersion.init) }
nonmutating set { defaults.set(newValue?.description, forKey: Self.lastPromptedVersionKey) }
}

var latestRelease: AppRelease? {
get {
guard let text = defaults.string(forKey: Self.latestVersionKey),
Expand Down
16 changes: 16 additions & 0 deletions macos/Sources/OmarchyVMHelper/AppReleaseChecker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,22 @@ final class AppReleaseChecker {
return AppReleaseCheckState.result(installed: installed, latest: latest).menuTitle
}

/// Cached updates may wait for a quiet moment in the launcher or settings.
/// A manual check remains available even after this version was skipped.
func automaticPromptRelease(presentationReady: Bool) -> AppRelease? {
guard presentationReady, preferences.automaticChecks,
let latest = preferences.latestRelease,
case .available = AppReleaseCheckState.result(installed: installed, latest: latest),
preferences.lastPromptedVersion.map({ latest.version > $0 }) ?? true else { return nil }
return latest
}

func acknowledgeRelease(_ release: AppRelease) {
if preferences.lastPromptedVersion.map({ release.version > $0 }) ?? true {
preferences.lastPromptedVersion = release.version
}
}

init(
installed: InstalledAppRelease = .current,
preferences: AppReleasePreferences = AppReleasePreferences(),
Expand Down
19 changes: 19 additions & 0 deletions macos/Sources/OmarchyVMHelper/AppReleaseWindow.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import AppKit

@MainActor
enum AppReleasePrompt {
static func alert(for release: AppRelease) -> NSAlert {
let alert = NSAlert()
alert.alertStyle = .informational
alert.messageText = "Try Omarchy \(release.version) is available"
alert.informativeText = "Download opens the release page on GitHub. Shut down Omarchy before replacing the app; your VM and settings are kept.\n\nSkip hides reminders for this version."
alert.addButton(withTitle: "Download")
alert.addButton(withTitle: "Skip").keyEquivalent = "\u{1b}"
return alert
}
}

@MainActor
final class AppReleaseWindow: NSObject {
private let checker: AppReleaseChecker
Expand All @@ -8,6 +21,8 @@ final class AppReleaseWindow: NSObject {
private let checkButton = NSButton(title: "Check Now", target: nil, action: nil)
private let automatic = NSButton(checkboxWithTitle: "Automatically check for updates", target: nil, action: nil)

var isVisible: Bool { window.isVisible }

init(checker: AppReleaseChecker) {
self.checker = checker
window = NSWindow(
Expand Down Expand Up @@ -66,6 +81,10 @@ final class AppReleaseWindow: NSObject {
status.stringValue = checker.state.message
checkButton.isEnabled = checker.state != .checking
automatic.state = checker.preferences.automaticChecks ? .on : .off
// Reading the result here already serves as the update notification.
if window.isVisible, case .available(let release) = checker.state {
checker.acknowledgeRelease(release)
}
}

@objc private func checkNow() { checker.check() }
Expand Down
58 changes: 58 additions & 0 deletions macos/Sources/OmarchyVMHelper/ApplicationPresentation.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,63 @@
import AppKit

/// AppKit activation and modal presentation can overlap the handoff to QEMU.
/// Keep the intended policy and check it again after the current UI operation.
@MainActor
final class ApplicationActivationController {
private var desiredPolicy = ApplicationPresentation.prelaunchActivationPolicy
private var reconciliationScheduled = false
private let currentPolicy: @MainActor () -> NSApplication.ActivationPolicy
private let setPolicy: @MainActor (NSApplication.ActivationPolicy) -> Bool
private let schedule: (@escaping @MainActor () -> Void) -> Void

convenience init() {
self.init(
currentPolicy: { NSApp.activationPolicy() },
setPolicy: { NSApp.setActivationPolicy($0) },
schedule: { action in
RunLoop.main.perform(inModes: [.common, .modalPanel]) {
MainActor.assumeIsolated { action() }
}
}
)
}

init(
currentPolicy: @escaping @MainActor () -> NSApplication.ActivationPolicy,
setPolicy: @escaping @MainActor (NSApplication.ActivationPolicy) -> Bool,
schedule: @escaping (@escaping @MainActor () -> Void) -> Void
) {
self.currentPolicy = currentPolicy
self.setPolicy = setPolicy
self.schedule = schedule
}

func setDesiredPolicy(_ policy: NSApplication.ActivationPolicy) {
desiredPolicy = policy
reconcile()
}

func reconcile() {
applyPolicy()
guard !reconciliationScheduled else { return }
reconciliationScheduled = true
schedule { [weak self] in
guard let self else { return }
self.reconciliationScheduled = false
// Read the current intent: QEMU may have exited and returned to
// the start menu while this check was waiting to run.
self.applyPolicy()
}
}

private func applyPolicy() {
guard currentPolicy() != desiredPolicy else { return }
if !setPolicy(desiredPolicy) {
fputs("omarchy-vm-helper: AppKit rejected activation policy \(desiredPolicy.rawValue)\n", stderr)
}
}
}

enum ApplicationHelpLink: Int, CaseIterable {
case usage
case troubleshooting
Expand Down
13 changes: 13 additions & 0 deletions macos/Sources/OmarchyVMHelper/StartMenuWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,17 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
private var controlsBusy: Bool {
launchInProgress || shutdownInProgress || automaticStartupConfirmationInProgress
}
var onAppReleasePromptOpportunity: (() -> Void)?
var canPresentAppReleasePrompt: Bool {
window.isKeyWindow && !controlsBusy && !resetInProgress
&& !microphoneRequestInFlight && !cameraRequestInFlight
&& window.attachedSheet == nil && networkEditor == nil
&& portForwardingEditor == nil && resourceEditor == nil && usbDeviceEditor == nil
}

func windowDidBecomeKey(_ notification: Notification) {
onAppReleasePromptOpportunity?()
}
private var prelaunchControlsLocked: Bool { controlsBusy || virtualMachineRunning }
private var pendingResetSpaceEstimate: String?
private var resetConfirmationPrompt: ResetConfirmationPrompt?
Expand Down Expand Up @@ -493,6 +504,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
button.isEnabled = wasEnabled
}
permissionDisabledButtons.removeAll()
onAppReleasePromptOpportunity?()
}
}

Expand Down Expand Up @@ -635,6 +647,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
}

private func render() {
defer { onAppReleasePromptOpportunity?() }
for (button, wasEnabled) in permissionDisabledButtons {
button.isEnabled = wasEnabled
}
Expand Down
51 changes: 47 additions & 4 deletions macos/Sources/OmarchyVMHelper/VMApplicationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,9 @@ final class VMApplicationController: NSObject, NSApplicationDelegate {
private let disposableWorkspace = DisposableVMWorkspace()
private var isDisposable: Bool { initialArguments.first == QEMUGPUStorageOption.ephemeral.rawValue }
private var settingsReturnApplication: NSRunningApplication?
private let appReleaseChecker = AppReleaseChecker()
private let appReleaseChecker: AppReleaseChecker
private var appReleaseWindow: AppReleaseWindow?
private var appReleasePromptScheduled = false
private var volumeObserver: NSObjectProtocol?
private var hostPowerObserver: HostPowerNotificationObserver?
private let hostSleepCoordinator = VMHostSleepCoordinator()
Expand All @@ -81,6 +82,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate {
private var activeStateRoot: String?

private var lifecycle = VMRunLifecycle()
private let activationController = ApplicationActivationController()
private var childRunning = false
private var applicationTerminationPending = false
private var virtualMachineReachedStart = false
Expand Down Expand Up @@ -117,7 +119,8 @@ final class VMApplicationController: NSObject, NSApplicationDelegate {
volumeProbe: VolumeProbing = URLVolumeProbe(),
volumeRootDetector: VolumeRootDetecting = FileManagerVolumeRootDetector(),
deviceProvider: HostAudioDeviceProviding = CoreAudioHostAudioDeviceProvider(),
bundledMetrics: BundledGuestMetrics? = QEMUGPUStorageSpaceEstimate.bundledMetrics()
bundledMetrics: BundledGuestMetrics? = QEMUGPUStorageSpaceEstimate.bundledMetrics(),
appReleaseChecker: AppReleaseChecker? = nil
) {
self.launcherURL = launcherURL
self.initialArguments = initialArguments
Expand All @@ -137,12 +140,14 @@ final class VMApplicationController: NSObject, NSApplicationDelegate {
self.volumeRootDetector = volumeRootDetector
self.deviceProvider = deviceProvider
self.bundledMetrics = bundledMetrics
self.appReleaseChecker = appReleaseChecker ?? AppReleaseChecker()
}

func applicationDidFinishLaunching(_ notification: Notification) {
appReleaseChecker.onChange = { [weak self] in
self?.startMenuWindow?.refreshAppReleaseStatus()
self?.appReleaseWindow?.refresh()
self?.scheduleAppReleasePrompt()
}
observeVolumeUnmounts()
observeHostPowerEvents()
Expand All @@ -153,9 +158,37 @@ final class VMApplicationController: NSObject, NSApplicationDelegate {
)
prepareStartMenu(startAutomatically: startAutomatically)
appReleaseChecker.checkAutomaticallyIfDue()
scheduleAppReleasePrompt()
}

private func scheduleAppReleasePrompt() {
guard !appReleasePromptScheduled else { return }
appReleasePromptScheduled = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.appReleasePromptScheduled = false
self.presentAppReleasePromptIfReady()
}
}

private func presentAppReleasePromptIfReady() {
let ready = NSApp.isActive && !isPresentingBlockingAlert
&& NSApp.modalWindow == nil && !lifecycle.isStopping && !lifecycle.isTerminating
&& appReleaseWindow?.isVisible != true
&& startMenuWindow?.canPresentAppReleasePrompt == true
guard let release = appReleaseChecker.automaticPromptRelease(presentationReady: ready),
let window = startMenuWindow?.window else { return }
// Remember presentation before attaching the sheet to avoid duplicate prompts.
appReleaseChecker.acknowledgeRelease(release)
AppReleasePrompt.alert(for: release).beginSheetModal(for: window) { response in
if response == .alertFirstButtonReturn {
NSWorkspace.shared.open(release.url)
}
}
}

@objc func checkForAppUpdates(_ sender: Any?) {
activationController.reconcile()
if appReleaseWindow == nil {
appReleaseWindow = AppReleaseWindow(checker: appReleaseChecker)
}
Expand All @@ -176,11 +209,13 @@ final class VMApplicationController: NSObject, NSApplicationDelegate {
}

func applicationDidBecomeActive(_ notification: Notification) {
activationController.reconcile()
startMenuWindow?.applicationDidBecomeActive()
scheduleAppReleasePrompt()
}

private func prepareStartMenu(startAutomatically: Bool, honorInitialReset: Bool = true) {
NSApp.setActivationPolicy(ApplicationPresentation.prelaunchActivationPolicy)
activationController.setDesiredPolicy(ApplicationPresentation.prelaunchActivationPolicy)
let resetOptions = [
QEMUGPUStorageOption.resetStorage.rawValue,
QEMUGPUStorageOption.resetStorageOnly.rawValue,
Expand Down Expand Up @@ -307,6 +342,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate {
}
)
startMenuWindow = startMenu
startMenu.onAppReleasePromptOpportunity = { [weak self] in self?.scheduleAppReleasePrompt() }
// Automatic startup uses the same launch path as the button, including
// any required VM fix or boot recovery consent before QEMU starts.
if startAutomatically {
Expand Down Expand Up @@ -697,8 +733,8 @@ final class VMApplicationController: NSObject, NSApplicationDelegate {
}
}
}
NSApp.setActivationPolicy(ApplicationPresentation.runningActivationPolicy)
startMenuWindow?.dismiss()
activationController.setDesiredPolicy(ApplicationPresentation.runningActivationPolicy)
controlSocketPath = qmpSocketPath
startMenuWindow?.virtualMachineDidStart(
requestSettingsAction: { [weak self] action in self?.shutDownForSettings(action) },
Expand Down Expand Up @@ -735,6 +771,8 @@ final class VMApplicationController: NSObject, NSApplicationDelegate {
// Successful checks and migrations are retained in Settings without
// interrupting startup with a second dialog.
guard report.needsAttention else { return }
activationController.reconcile()
defer { activationController.reconcile() }
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = report.summary
Expand All @@ -753,6 +791,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate {
guard childRunning, virtualMachineReachedStart,
(!lifecycle.isStopping || lifecycle.settingsAction != nil),
!isPresentingBlockingAlert, let startMenuWindow else { return false }
activationController.reconcile()
if startMenuWindow.window.isVisible || startMenuWindow.window.isMiniaturized {
startMenuWindow.bringToFront()
return true
Expand Down Expand Up @@ -1059,6 +1098,8 @@ final class VMApplicationController: NSObject, NSApplicationDelegate {
hostSleepCoordinator.pausedForHostSleep
else { return }

activationController.reconcile()
defer { activationController.reconcile() }
NSApp.activate(ignoringOtherApps: true)
let alert = NSAlert()
alert.alertStyle = .critical
Expand All @@ -1083,6 +1124,8 @@ final class VMApplicationController: NSObject, NSApplicationDelegate {
let prefix = mountPoint.hasSuffix("/") ? mountPoint : mountPoint + "/"
guard root == mountPoint || root.hasPrefix(prefix) else { return }

activationController.reconcile()
defer { activationController.reconcile() }
fputs(
"omarchy-vm-helper: the volume holding the Omarchy VM was unmounted; stopping\n",
stderr
Expand Down
Loading
Loading