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
116 changes: 116 additions & 0 deletions macos/Sources/OmarchyVMHelper/PermissionWindowRestorer.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
1 change: 1 addition & 0 deletions macos/Sources/OmarchyVMHelper/PortForwardingEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
87 changes: 73 additions & 14 deletions macos/Sources/OmarchyVMHelper/StartMenuWindow.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand All @@ -168,13 +215,21 @@ 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)
confirmReset()
}

func dismiss() {
permissionWindowRestorer.cancel()
portForwardingEditor?.dismiss()
portForwardingEditor = nil
window.orderOut(nil)
Expand Down Expand Up @@ -889,25 +944,29 @@ 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
DispatchQueue.main.async {
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 }
Expand All @@ -916,34 +975,30 @@ 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
DispatchQueue.main.async {
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 }
NSWorkspace.shared.open(url)
}

@objc private func openStorageLocation() {
permissionWindowRestorer.cancel()
guard let storageLocationURL = storageLocationURL() else { return }
do {
if !FileManager.default.fileExists(atPath: storageLocationURL.path) {
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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 ~/<folder name>."
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate {
}

func applicationDidBecomeActive(_ notification: Notification) {
startMenuWindow?.refreshPermissionStatus()
startMenuWindow?.applicationDidBecomeActive()
}

private func showStartMenu() {
Expand Down
Loading
Loading