diff --git a/macos/Sources/OmarchyVMHelper/PermissionWindowRestorer.swift b/macos/Sources/OmarchyVMHelper/PermissionWindowRestorer.swift new file mode 100644 index 0000000..c739d08 --- /dev/null +++ b/macos/Sources/OmarchyVMHelper/PermissionWindowRestorer.swift @@ -0,0 +1,116 @@ +import AppKit + +/// Restores an accessory-app window after a system-owned permission prompt. +/// +/// App activation is asynchronous. `makeKeyAndOrderFront` only orders a +/// window against windows in the same application, so calling it immediately +/// after requesting activation can still leave the window behind the app that +/// macOS made active while dismissing the prompt. +@MainActor +final class PermissionWindowRestorer { + typealias Scheduler = (TimeInterval, @escaping @MainActor () -> Void) -> Void + + private let canRestore: () -> Bool + private let isApplicationActive: () -> Bool + private let orderFrontRegardless: (NSRect) -> Void + private let activateApplication: () -> Void + private let makeKeyAndOrderFront: (NSRect) -> Void + private let retryDelays: [TimeInterval] + private let schedule: Scheduler + + private var pendingFrame: NSRect? + private var requestGeneration = 0 + private var finalRetryCompleted = false + + init( + canRestore: @escaping () -> Bool, + isApplicationActive: @escaping () -> Bool, + orderFrontRegardless: @escaping (NSRect) -> Void, + activateApplication: @escaping () -> Void, + makeKeyAndOrderFront: @escaping (NSRect) -> Void, + retryDelays: [TimeInterval], + schedule: @escaping Scheduler + ) { + self.canRestore = canRestore + self.isApplicationActive = isApplicationActive + self.orderFrontRegardless = orderFrontRegardless + self.activateApplication = activateApplication + self.makeKeyAndOrderFront = makeKeyAndOrderFront + self.retryDelays = retryDelays + self.schedule = schedule + } + + var isPending: Bool { pendingFrame != nil } + + func requestDidFinish(preserving frame: NSRect) { + guard canRestore() else { + cancel() + return + } + + requestGeneration &+= 1 + let generation = requestGeneration + pendingFrame = frame + finalRetryCompleted = false + restore(frame: frame) + + // AVFoundation can invoke its completion handler before the system + // permission host finishes dismissing and reordering its own window. + // Reassert after that teardown instead of trusting the first ordering. + for (index, delay) in retryDelays.enumerated() { + let isFinalRetry = index == retryDelays.indices.last + schedule(delay) { [weak self] in + self?.retry( + generation: generation, + frame: frame, + isFinalRetry: isFinalRetry + ) + } + } + } + + func applicationDidBecomeActive() { + guard let frame = pendingFrame else { return } + guard canRestore() else { + cancel() + return + } + makeKeyAndOrderFront(frame) + if finalRetryCompleted { + pendingFrame = nil + } + } + + func cancel() { + requestGeneration &+= 1 + pendingFrame = nil + finalRetryCompleted = false + } + + private func retry(generation: Int, frame: NSRect, isFinalRetry: Bool) { + guard generation == requestGeneration, pendingFrame != nil else { return } + guard canRestore() else { + cancel() + return + } + restore(frame: frame) + if isFinalRetry { + finalRetryCompleted = true + if isApplicationActive() { + pendingFrame = nil + } + } + } + + private func restore(frame: NSRect) { + // This is the only NSWindow ordering operation that explicitly works + // while another application is active. Restoring the frame here also + // prevents prompt teardown from recascading the start menu. + orderFrontRegardless(frame) + if isApplicationActive() { + makeKeyAndOrderFront(frame) + } else { + activateApplication() + } + } +} diff --git a/macos/Sources/OmarchyVMHelper/PortForwardingEditor.swift b/macos/Sources/OmarchyVMHelper/PortForwardingEditor.swift index 4b17cae..4bff9be 100644 --- a/macos/Sources/OmarchyVMHelper/PortForwardingEditor.swift +++ b/macos/Sources/OmarchyVMHelper/PortForwardingEditor.swift @@ -64,6 +64,7 @@ final class PortForwardingEditor: NSObject, NSWindowDelegate, NSTextFieldDelegat defer: false ) window.title = "Port Forwarding" + window.titleVisibility = .hidden window.titlebarAppearsTransparent = true window.isReleasedWhenClosed = false window.delegate = self diff --git a/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift b/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift index f428148..f7ac25e 100644 --- a/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift +++ b/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift @@ -1,5 +1,19 @@ import AppKit +@MainActor +enum StartMenuWindowChrome { + static func apply(to window: NSWindow) { + window.title = "Try Omarchy" + // The start menu draws its own heading inside a full-size content view. + // Keep the native title as the window identity, but do not composite a + // second copy over that custom heading in the transparent title bar. + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.isMovableByWindowBackground = true + window.isReleasedWhenClosed = false + } +} + private final class MouseIgnoringTextField: NSTextField { override func hitTest(_ point: NSPoint) -> NSView? { nil } } @@ -80,6 +94,42 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { private weak var startMenuScrollView: NSScrollView? private(set) var portForwardingEditor: PortForwardingEditor? private weak var immersiveCaption: NSTextField? + private lazy var permissionWindowRestorer = PermissionWindowRestorer( + canRestore: { [weak self] in + guard let self else { return false } + return self.window.isVisible + && !self.launchInProgress + && !self.resetInProgress + && !self.microphoneRequestInFlight + && !self.cameraRequestInFlight + && self.window.attachedSheet == nil + && NSApp.modalWindow == nil + && self.portForwardingEditor == nil + }, + isApplicationActive: { NSApp.isActive }, + orderFrontRegardless: { [weak self] frame in + guard let self else { return } + self.window.setFrame(frame, display: false) + self.window.orderFrontRegardless() + }, + activateApplication: { + // `activate(ignoringOtherApps:)` is deprecated on the deployment + // target. The system permission UI cooperatively yields to this + // modern activation request as it closes. + NSApp.activate() + }, + makeKeyAndOrderFront: { [weak self] frame in + guard let self else { return } + self.window.setFrame(frame, display: false) + self.window.makeKeyAndOrderFront(nil) + }, + retryDelays: [0.1, 0.3], + schedule: { delay, action in + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + action() + } + } + ) init( accessibilityStatus: @escaping () -> Bool, @@ -140,10 +190,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { ) super.init() - window.title = "Try Omarchy" - window.titlebarAppearsTransparent = true - window.isMovableByWindowBackground = true - window.isReleasedWhenClosed = false + StartMenuWindowChrome.apply(to: window) window.delegate = self window.contentView = content } @@ -168,6 +215,13 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { render() } + func applicationDidBecomeActive() { + refreshPermissionStatus() + // Refresh replaces the view hierarchy, so key/front restoration must + // be the final operation rather than something a render can disturb. + permissionWindowRestorer.applicationDidBecomeActive() + } + func promptForReset() { guard canResetStorage else { return } window.makeKeyAndOrderFront(nil) @@ -175,6 +229,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } func dismiss() { + permissionWindowRestorer.cancel() portForwardingEditor?.dismiss() portForwardingEditor = nil window.orderOut(nil) @@ -889,12 +944,15 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } @objc private func beginAccessibilityRequest() { + permissionWindowRestorer.cancel() requestAccessibility() render() } @objc private func beginMicrophoneRequest() { guard microphoneStatus() == .notDetermined, !microphoneRequestInFlight else { return } + permissionWindowRestorer.cancel() + let windowFrame = window.frame microphoneRequestInFlight = true render() requestMicrophone { [weak self] _ in @@ -902,12 +960,13 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { guard let self else { return } self.microphoneRequestInFlight = false self.render() - self.restoreAfterPermissionRequest() + self.permissionWindowRestorer.requestDidFinish(preserving: windowFrame) } } } @objc private func openMicrophoneSettings() { + permissionWindowRestorer.cancel() guard let url = URL( string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone" ) else { return } @@ -916,6 +975,8 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { @objc private func beginCameraRequest() { guard cameraStatus() == .notDetermined, !cameraRequestInFlight else { return } + permissionWindowRestorer.cancel() + let windowFrame = window.frame cameraRequestInFlight = true render() requestCamera { [weak self] _ in @@ -923,20 +984,13 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { guard let self else { return } self.cameraRequestInFlight = false self.render() - self.restoreAfterPermissionRequest() + self.permissionWindowRestorer.requestDidFinish(preserving: windowFrame) } } } - private func restoreAfterPermissionRequest() { - // The system permission prompt can leave another process active. Since - // this launcher is an accessory app, ordering its window alone does not - // reliably raise it above windows belonging to that active app. - NSApp.activate(ignoringOtherApps: true) - window.makeKeyAndOrderFront(nil) - } - @objc private func openCameraSettings() { + permissionWindowRestorer.cancel() guard let url = URL( string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera" ) else { return } @@ -944,6 +998,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } @objc private func openStorageLocation() { + permissionWindowRestorer.cancel() guard let storageLocationURL = storageLocationURL() else { return } do { if !FileManager.default.fileExists(atPath: storageLocationURL.path) { @@ -993,6 +1048,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { @objc private func beginStorageLocationSelection() { guard canResetStorage, !launchInProgress, !resetInProgress else { return } + permissionWindowRestorer.cancel() let panel = NSOpenPanel() panel.title = "Choose where to keep the Omarchy VM" panel.message = "Omarchy puts its VM files straight into the folder you choose \u{2014} it does not create a folder inside it. Pick an empty folder, or one Omarchy already uses. The drive must be APFS." @@ -1057,6 +1113,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { !resetInProgress, !microphoneRequestInFlight, !cameraRequestInFlight else { return } + permissionWindowRestorer.cancel() let panel = NSOpenPanel() panel.title = "Choose a folder to share with Omarchy" panel.message = "Omarchy will be able to read and change everything inside this folder, linked as ~/." @@ -1095,6 +1152,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { @objc private func beginPortForwardingConfiguration() { guard !launchInProgress, !resetInProgress, portForwardingEditor == nil else { return } + permissionWindowRestorer.cancel() let editor = PortForwardingEditor( mappings: portForwardingStatus(), save: { [weak self] mappings in @@ -1136,6 +1194,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { !resetInProgress, !microphoneRequestInFlight, !cameraRequestInFlight else { return } + permissionWindowRestorer.cancel() let estimate = storageSpaceEstimate() let alert = NSAlert() alert.alertStyle = .critical diff --git a/macos/Sources/OmarchyVMHelper/VMApplicationController.swift b/macos/Sources/OmarchyVMHelper/VMApplicationController.swift index 765abdb..dcd8567 100644 --- a/macos/Sources/OmarchyVMHelper/VMApplicationController.swift +++ b/macos/Sources/OmarchyVMHelper/VMApplicationController.swift @@ -74,7 +74,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { } func applicationDidBecomeActive(_ notification: Notification) { - startMenuWindow?.refreshPermissionStatus() + startMenuWindow?.applicationDidBecomeActive() } private func showStartMenu() { diff --git a/macos/Tests/OmarchyVMHelperTests/PermissionWindowRestorerTests.swift b/macos/Tests/OmarchyVMHelperTests/PermissionWindowRestorerTests.swift new file mode 100644 index 0000000..2b6f71d --- /dev/null +++ b/macos/Tests/OmarchyVMHelperTests/PermissionWindowRestorerTests.swift @@ -0,0 +1,206 @@ +import AppKit +import Testing +@testable import OmarchyVMHelper + +@Suite("Permission window restoration", .serialized) +@MainActor +struct PermissionWindowRestorerTests { + @Test("an inactive callback orders globally before waiting for activation") + func inactiveApplication() { + let harness = Harness(isActive: false) + let frame = NSRect(x: 90, y: 120, width: 600, height: 760) + + harness.restorer.requestDidFinish(preserving: frame) + + #expect(harness.events == [.orderRegardless(frame), .activate]) + #expect(harness.restorer.isPending) + + harness.isActive = true + harness.restorer.applicationDidBecomeActive() + + #expect(harness.events == [ + .orderRegardless(frame), + .activate, + .makeKeyAndOrderFront(frame), + ]) + #expect(harness.restorer.isPending) + + harness.runScheduledActions() + #expect(!harness.restorer.isPending) + } + + @Test("an activation that wins the callback race finishes immediately") + func alreadyActiveApplication() { + let harness = Harness(isActive: true) + let frame = NSRect(x: 30, y: 40, width: 600, height: 760) + + harness.restorer.requestDidFinish(preserving: frame) + + #expect(harness.events == [ + .orderRegardless(frame), + .makeKeyAndOrderFront(frame), + ]) + #expect(harness.restorer.isPending) + + harness.runScheduledActions() + #expect(!harness.restorer.isPending) + } + + @Test("post-dismissal retries reassert ordering after an early activation") + func postDismissalRetries() { + let harness = Harness(isActive: false) + let frame = NSRect(x: 10, y: 20, width: 600, height: 760) + harness.restorer.requestDidFinish(preserving: frame) + harness.isActive = true + + harness.restorer.applicationDidBecomeActive() + harness.runScheduledActions() + + #expect(harness.events.filter { + if case .orderRegardless = $0 { true } else { false } + }.count == 3) + #expect(harness.events.filter { + if case .makeKeyAndOrderFront = $0 { true } else { false } + }.count == 3) + #expect(harness.events.filter { $0 == .activate }.count == 1) + #expect(!harness.restorer.isPending) + } + + @Test("a dismissed menu is never resurrected") + func cancelledRestoration() { + let harness = Harness(isActive: false) + let frame = NSRect(x: 10, y: 20, width: 600, height: 760) + harness.restorer.requestDidFinish(preserving: frame) + + harness.canRestore = false + harness.restorer.cancel() + harness.isActive = true + harness.restorer.applicationDidBecomeActive() + harness.runScheduledActions() + + #expect(harness.events == [.orderRegardless(frame), .activate]) + #expect(!harness.restorer.isPending) + } + + @Test("a new permission request invalidates retries from the previous request") + func newPermissionRequestCancelsStaleRetries() { + let harness = Harness(isActive: false) + let firstFrame = NSRect(x: 10, y: 20, width: 600, height: 760) + let secondFrame = NSRect(x: 40, y: 50, width: 600, height: 760) + harness.restorer.requestDidFinish(preserving: firstFrame) + + harness.restorer.cancel() + harness.isActive = true + harness.restorer.requestDidFinish(preserving: secondFrame) + harness.runScheduledActions() + + #expect(harness.events.filter { $0 == .orderRegardless(firstFrame) }.count == 1) + #expect(harness.events.filter { $0 == .orderRegardless(secondFrame) }.count == 3) + #expect(harness.events.filter { $0 == .makeKeyAndOrderFront(firstFrame) }.isEmpty) + #expect(harness.events.filter { $0 == .makeKeyAndOrderFront(secondFrame) }.count == 3) + #expect(harness.events.filter { $0 == .activate }.count == 1) + #expect(!harness.restorer.isPending) + } + + @Test("activation cancels restoration when the menu can no longer be shown") + func activationAfterMenuBecomesUnavailable() { + let harness = Harness(isActive: false) + let frame = NSRect(x: 10, y: 20, width: 600, height: 760) + harness.restorer.requestDidFinish(preserving: frame) + + harness.canRestore = false + harness.isActive = true + harness.restorer.applicationDidBecomeActive() + harness.runScheduledActions() + + #expect(harness.events == [.orderRegardless(frame), .activate]) + #expect(!harness.restorer.isPending) + } + + @Test("failed activation remains pending after bounded retries") + func activationNeverArrives() { + let harness = Harness(isActive: false) + let frame = NSRect(x: 10, y: 20, width: 600, height: 760) + harness.restorer.requestDidFinish(preserving: frame) + + harness.runScheduledActions() + + #expect(harness.events.filter { + if case .orderRegardless = $0 { true } else { false } + }.count == 3) + #expect(harness.events.filter { $0 == .activate }.count == 3) + #expect(harness.restorer.isPending) + + harness.isActive = true + harness.restorer.applicationDidBecomeActive() + #expect(harness.events.last == .makeKeyAndOrderFront(frame)) + #expect(!harness.restorer.isPending) + } + + @Test("the custom heading hides the duplicate native title") + func windowChrome() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 600, height: 760), + styleMask: [.titled, .closable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + + StartMenuWindowChrome.apply(to: window) + + #expect(window.title == "Try Omarchy") + #expect(window.titleVisibility == .hidden) + #expect(window.titlebarAppearsTransparent) + #expect(window.isMovableByWindowBackground) + #expect(!window.isReleasedWhenClosed) + window.close() + + let editor = PortForwardingEditor(mappings: [], save: { _ in nil }) + #expect(editor.window.title == "Port Forwarding") + #expect(editor.window.titleVisibility == .hidden) + editor.dismiss() + } +} + +@MainActor +private final class Harness { + enum Event: Equatable { + case orderRegardless(NSRect) + case activate + case makeKeyAndOrderFront(NSRect) + } + + var canRestore = true + var isActive: Bool + var events: [Event] = [] + var scheduledActions: [(TimeInterval, @MainActor () -> Void)] = [] + lazy var restorer = PermissionWindowRestorer( + canRestore: { [unowned self] in canRestore }, + isApplicationActive: { [unowned self] in isActive }, + orderFrontRegardless: { [unowned self] frame in + events.append(.orderRegardless(frame)) + }, + activateApplication: { [unowned self] in + events.append(.activate) + }, + makeKeyAndOrderFront: { [unowned self] frame in + events.append(.makeKeyAndOrderFront(frame)) + }, + retryDelays: [0.1, 0.3], + schedule: { [unowned self] delay, action in + scheduledActions.append((delay, action)) + } + ) + + init(isActive: Bool) { + self.isActive = isActive + } + + func runScheduledActions() { + let actions = scheduledActions.sorted { $0.0 < $1.0 } + scheduledActions.removeAll() + for (_, action) in actions { + action() + } + } +}