Skip to content

Commit 3aaacd5

Browse files
authored
Fix permission prompt window restoration (#76)
1 parent c91235e commit 3aaacd5

5 files changed

Lines changed: 397 additions & 15 deletions

File tree

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import AppKit
2+
3+
/// Restores an accessory-app window after a system-owned permission prompt.
4+
///
5+
/// App activation is asynchronous. `makeKeyAndOrderFront` only orders a
6+
/// window against windows in the same application, so calling it immediately
7+
/// after requesting activation can still leave the window behind the app that
8+
/// macOS made active while dismissing the prompt.
9+
@MainActor
10+
final class PermissionWindowRestorer {
11+
typealias Scheduler = (TimeInterval, @escaping @MainActor () -> Void) -> Void
12+
13+
private let canRestore: () -> Bool
14+
private let isApplicationActive: () -> Bool
15+
private let orderFrontRegardless: (NSRect) -> Void
16+
private let activateApplication: () -> Void
17+
private let makeKeyAndOrderFront: (NSRect) -> Void
18+
private let retryDelays: [TimeInterval]
19+
private let schedule: Scheduler
20+
21+
private var pendingFrame: NSRect?
22+
private var requestGeneration = 0
23+
private var finalRetryCompleted = false
24+
25+
init(
26+
canRestore: @escaping () -> Bool,
27+
isApplicationActive: @escaping () -> Bool,
28+
orderFrontRegardless: @escaping (NSRect) -> Void,
29+
activateApplication: @escaping () -> Void,
30+
makeKeyAndOrderFront: @escaping (NSRect) -> Void,
31+
retryDelays: [TimeInterval],
32+
schedule: @escaping Scheduler
33+
) {
34+
self.canRestore = canRestore
35+
self.isApplicationActive = isApplicationActive
36+
self.orderFrontRegardless = orderFrontRegardless
37+
self.activateApplication = activateApplication
38+
self.makeKeyAndOrderFront = makeKeyAndOrderFront
39+
self.retryDelays = retryDelays
40+
self.schedule = schedule
41+
}
42+
43+
var isPending: Bool { pendingFrame != nil }
44+
45+
func requestDidFinish(preserving frame: NSRect) {
46+
guard canRestore() else {
47+
cancel()
48+
return
49+
}
50+
51+
requestGeneration &+= 1
52+
let generation = requestGeneration
53+
pendingFrame = frame
54+
finalRetryCompleted = false
55+
restore(frame: frame)
56+
57+
// AVFoundation can invoke its completion handler before the system
58+
// permission host finishes dismissing and reordering its own window.
59+
// Reassert after that teardown instead of trusting the first ordering.
60+
for (index, delay) in retryDelays.enumerated() {
61+
let isFinalRetry = index == retryDelays.indices.last
62+
schedule(delay) { [weak self] in
63+
self?.retry(
64+
generation: generation,
65+
frame: frame,
66+
isFinalRetry: isFinalRetry
67+
)
68+
}
69+
}
70+
}
71+
72+
func applicationDidBecomeActive() {
73+
guard let frame = pendingFrame else { return }
74+
guard canRestore() else {
75+
cancel()
76+
return
77+
}
78+
makeKeyAndOrderFront(frame)
79+
if finalRetryCompleted {
80+
pendingFrame = nil
81+
}
82+
}
83+
84+
func cancel() {
85+
requestGeneration &+= 1
86+
pendingFrame = nil
87+
finalRetryCompleted = false
88+
}
89+
90+
private func retry(generation: Int, frame: NSRect, isFinalRetry: Bool) {
91+
guard generation == requestGeneration, pendingFrame != nil else { return }
92+
guard canRestore() else {
93+
cancel()
94+
return
95+
}
96+
restore(frame: frame)
97+
if isFinalRetry {
98+
finalRetryCompleted = true
99+
if isApplicationActive() {
100+
pendingFrame = nil
101+
}
102+
}
103+
}
104+
105+
private func restore(frame: NSRect) {
106+
// This is the only NSWindow ordering operation that explicitly works
107+
// while another application is active. Restoring the frame here also
108+
// prevents prompt teardown from recascading the start menu.
109+
orderFrontRegardless(frame)
110+
if isApplicationActive() {
111+
makeKeyAndOrderFront(frame)
112+
} else {
113+
activateApplication()
114+
}
115+
}
116+
}

macos/Sources/OmarchyVMHelper/PortForwardingEditor.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ final class PortForwardingEditor: NSObject, NSWindowDelegate, NSTextFieldDelegat
6464
defer: false
6565
)
6666
window.title = "Port Forwarding"
67+
window.titleVisibility = .hidden
6768
window.titlebarAppearsTransparent = true
6869
window.isReleasedWhenClosed = false
6970
window.delegate = self

macos/Sources/OmarchyVMHelper/StartMenuWindow.swift

Lines changed: 73 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
import AppKit
22

3+
@MainActor
4+
enum StartMenuWindowChrome {
5+
static func apply(to window: NSWindow) {
6+
window.title = "Try Omarchy"
7+
// The start menu draws its own heading inside a full-size content view.
8+
// Keep the native title as the window identity, but do not composite a
9+
// second copy over that custom heading in the transparent title bar.
10+
window.titleVisibility = .hidden
11+
window.titlebarAppearsTransparent = true
12+
window.isMovableByWindowBackground = true
13+
window.isReleasedWhenClosed = false
14+
}
15+
}
16+
317
private final class MouseIgnoringTextField: NSTextField {
418
override func hitTest(_ point: NSPoint) -> NSView? { nil }
519
}
@@ -80,6 +94,42 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
8094
private weak var startMenuScrollView: NSScrollView?
8195
private(set) var portForwardingEditor: PortForwardingEditor?
8296
private weak var immersiveCaption: NSTextField?
97+
private lazy var permissionWindowRestorer = PermissionWindowRestorer(
98+
canRestore: { [weak self] in
99+
guard let self else { return false }
100+
return self.window.isVisible
101+
&& !self.launchInProgress
102+
&& !self.resetInProgress
103+
&& !self.microphoneRequestInFlight
104+
&& !self.cameraRequestInFlight
105+
&& self.window.attachedSheet == nil
106+
&& NSApp.modalWindow == nil
107+
&& self.portForwardingEditor == nil
108+
},
109+
isApplicationActive: { NSApp.isActive },
110+
orderFrontRegardless: { [weak self] frame in
111+
guard let self else { return }
112+
self.window.setFrame(frame, display: false)
113+
self.window.orderFrontRegardless()
114+
},
115+
activateApplication: {
116+
// `activate(ignoringOtherApps:)` is deprecated on the deployment
117+
// target. The system permission UI cooperatively yields to this
118+
// modern activation request as it closes.
119+
NSApp.activate()
120+
},
121+
makeKeyAndOrderFront: { [weak self] frame in
122+
guard let self else { return }
123+
self.window.setFrame(frame, display: false)
124+
self.window.makeKeyAndOrderFront(nil)
125+
},
126+
retryDelays: [0.1, 0.3],
127+
schedule: { delay, action in
128+
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
129+
action()
130+
}
131+
}
132+
)
83133

84134
init(
85135
accessibilityStatus: @escaping () -> Bool,
@@ -140,10 +190,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
140190
)
141191
super.init()
142192

143-
window.title = "Try Omarchy"
144-
window.titlebarAppearsTransparent = true
145-
window.isMovableByWindowBackground = true
146-
window.isReleasedWhenClosed = false
193+
StartMenuWindowChrome.apply(to: window)
147194
window.delegate = self
148195
window.contentView = content
149196
}
@@ -168,13 +215,21 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
168215
render()
169216
}
170217

218+
func applicationDidBecomeActive() {
219+
refreshPermissionStatus()
220+
// Refresh replaces the view hierarchy, so key/front restoration must
221+
// be the final operation rather than something a render can disturb.
222+
permissionWindowRestorer.applicationDidBecomeActive()
223+
}
224+
171225
func promptForReset() {
172226
guard canResetStorage else { return }
173227
window.makeKeyAndOrderFront(nil)
174228
confirmReset()
175229
}
176230

177231
func dismiss() {
232+
permissionWindowRestorer.cancel()
178233
portForwardingEditor?.dismiss()
179234
portForwardingEditor = nil
180235
window.orderOut(nil)
@@ -889,25 +944,29 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
889944
}
890945

891946
@objc private func beginAccessibilityRequest() {
947+
permissionWindowRestorer.cancel()
892948
requestAccessibility()
893949
render()
894950
}
895951

896952
@objc private func beginMicrophoneRequest() {
897953
guard microphoneStatus() == .notDetermined, !microphoneRequestInFlight else { return }
954+
permissionWindowRestorer.cancel()
955+
let windowFrame = window.frame
898956
microphoneRequestInFlight = true
899957
render()
900958
requestMicrophone { [weak self] _ in
901959
DispatchQueue.main.async {
902960
guard let self else { return }
903961
self.microphoneRequestInFlight = false
904962
self.render()
905-
self.restoreAfterPermissionRequest()
963+
self.permissionWindowRestorer.requestDidFinish(preserving: windowFrame)
906964
}
907965
}
908966
}
909967

910968
@objc private func openMicrophoneSettings() {
969+
permissionWindowRestorer.cancel()
911970
guard let url = URL(
912971
string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"
913972
) else { return }
@@ -916,34 +975,30 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
916975

917976
@objc private func beginCameraRequest() {
918977
guard cameraStatus() == .notDetermined, !cameraRequestInFlight else { return }
978+
permissionWindowRestorer.cancel()
979+
let windowFrame = window.frame
919980
cameraRequestInFlight = true
920981
render()
921982
requestCamera { [weak self] _ in
922983
DispatchQueue.main.async {
923984
guard let self else { return }
924985
self.cameraRequestInFlight = false
925986
self.render()
926-
self.restoreAfterPermissionRequest()
987+
self.permissionWindowRestorer.requestDidFinish(preserving: windowFrame)
927988
}
928989
}
929990
}
930991

931-
private func restoreAfterPermissionRequest() {
932-
// The system permission prompt can leave another process active. Since
933-
// this launcher is an accessory app, ordering its window alone does not
934-
// reliably raise it above windows belonging to that active app.
935-
NSApp.activate(ignoringOtherApps: true)
936-
window.makeKeyAndOrderFront(nil)
937-
}
938-
939992
@objc private func openCameraSettings() {
993+
permissionWindowRestorer.cancel()
940994
guard let url = URL(
941995
string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera"
942996
) else { return }
943997
NSWorkspace.shared.open(url)
944998
}
945999

9461000
@objc private func openStorageLocation() {
1001+
permissionWindowRestorer.cancel()
9471002
guard let storageLocationURL = storageLocationURL() else { return }
9481003
do {
9491004
if !FileManager.default.fileExists(atPath: storageLocationURL.path) {
@@ -993,6 +1048,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
9931048

9941049
@objc private func beginStorageLocationSelection() {
9951050
guard canResetStorage, !launchInProgress, !resetInProgress else { return }
1051+
permissionWindowRestorer.cancel()
9961052
let panel = NSOpenPanel()
9971053
panel.title = "Choose where to keep the Omarchy VM"
9981054
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 {
10571113
!resetInProgress,
10581114
!microphoneRequestInFlight,
10591115
!cameraRequestInFlight else { return }
1116+
permissionWindowRestorer.cancel()
10601117
let panel = NSOpenPanel()
10611118
panel.title = "Choose a folder to share with Omarchy"
10621119
panel.message = "Omarchy will be able to read and change everything inside this folder, linked as ~/<folder name>."
@@ -1095,6 +1152,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
10951152

10961153
@objc private func beginPortForwardingConfiguration() {
10971154
guard !launchInProgress, !resetInProgress, portForwardingEditor == nil else { return }
1155+
permissionWindowRestorer.cancel()
10981156
let editor = PortForwardingEditor(
10991157
mappings: portForwardingStatus(),
11001158
save: { [weak self] mappings in
@@ -1136,6 +1194,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
11361194
!resetInProgress,
11371195
!microphoneRequestInFlight,
11381196
!cameraRequestInFlight else { return }
1197+
permissionWindowRestorer.cancel()
11391198
let estimate = storageSpaceEstimate()
11401199
let alert = NSAlert()
11411200
alert.alertStyle = .critical

macos/Sources/OmarchyVMHelper/VMApplicationController.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate {
7474
}
7575

7676
func applicationDidBecomeActive(_ notification: Notification) {
77-
startMenuWindow?.refreshPermissionStatus()
77+
startMenuWindow?.applicationDidBecomeActive()
7878
}
7979

8080
private func showStartMenu() {

0 commit comments

Comments
 (0)