From fb8b1df6985aec50788dfbf9b7df61db2d24f77b Mon Sep 17 00:00:00 2001 From: Nikita Bobko Date: Sat, 27 Jun 2026 00:01:32 +0200 Subject: [PATCH] More user friendly accessibility permission acquisition flow No need for app restart anymore --- Sources/AppBundle/initAppBundle.swift | 3 +- Sources/AppBundle/ui/MenuBar.swift | 98 ++++++++++++---------- Sources/AppBundle/ui/TrayMenuModel.swift | 7 ++ Sources/AppBundle/util/accessibility.swift | 18 +++- 4 files changed, 78 insertions(+), 48 deletions(-) diff --git a/Sources/AppBundle/initAppBundle.swift b/Sources/AppBundle/initAppBundle.swift index b9d2d2af..83805d70 100644 --- a/Sources/AppBundle/initAppBundle.swift +++ b/Sources/AppBundle/initAppBundle.swift @@ -7,15 +7,16 @@ import Foundation initTerminationHandler() unsafe _isCli = false initServerArgs() + await waitForAccessibilityPermission_nonCancellable() if isDebug { await toggleReleaseServerIfDebug(.off) interceptTermination(SIGINT) interceptTermination(SIGKILL) } + await bootstrapConfig_nonCancellable() _ = await reloadConfig_nonCancellable() - checkAccessibilityPermissions() startUnixSocketServer() GlobalObserver.initObserver() Workspace.garbageCollectUnusedWorkspaces() // init workspaces diff --git a/Sources/AppBundle/ui/MenuBar.swift b/Sources/AppBundle/ui/MenuBar.swift index 44e13431..c1825b6c 100644 --- a/Sources/AppBundle/ui/MenuBar.swift +++ b/Sources/AppBundle/ui/MenuBar.swift @@ -11,53 +11,60 @@ public func menuBar(viewModel: TrayMenuModel) -> some Scene { // todo should it Button("Copy to clipboard") { identification.copyToClipboard() } .keyboardShortcut("C", modifiers: .command) Divider() - if let token: RunSessionGuard = .isServerEnabled, viewModel.lastReloadConfigContainedWarnings { - Button { - Task.startUnstructured { - try await runLightSession(.menuBarButton, token) { - let args: ReloadConfigCmdArgs = ReloadConfigCmdArgs(rawArgs: []).copy(\.warningsAsErrors, true) - _ = await reloadConfig_nonCancellable(args: args) - } - } - } label: { - Label("Config contains warnings...", systemImage: "exclamationmark.triangle.fill") - } - Divider() - } - if let token: RunSessionGuard = .isServerEnabled { - Text("Workspaces:") - ForEach(viewModel.workspaces, id: \.name) { workspace in + + if viewModel.axPermissionStatus == .granted { + if let token: RunSessionGuard = .isServerEnabled, viewModel.lastReloadConfigContainedWarnings { Button { Task.startUnstructured { - try await runLightSession(.menuBarButton, token) { _ = Workspace.get(byName: workspace.name).focusWorkspace() } + try await runLightSession(.menuBarButton, token) { + let args: ReloadConfigCmdArgs = ReloadConfigCmdArgs(rawArgs: []).copy(\.warningsAsErrors, true) + _ = await reloadConfig_nonCancellable(args: args) + } } } label: { - Toggle(isOn: .constant(workspace.isFocused)) { - Text(workspace.name + workspace.suffix).font(.system(.body, design: .monospaced)) + Label("Config contains warnings...", systemImage: "exclamationmark.triangle.fill") + } + Divider() + } + if let token: RunSessionGuard = .isServerEnabled { + Text("Workspaces:") + ForEach(viewModel.workspaces, id: \.name) { workspace in + Button { + Task.startUnstructured { + try await runLightSession(.menuBarButton, token) { _ = Workspace.get(byName: workspace.name).focusWorkspace() } + } + } label: { + Toggle(isOn: .constant(workspace.isFocused)) { + Text(workspace.name + workspace.suffix).font(.system(.body, design: .monospaced)) + } } } + Divider() + } + Button { + NSWorkspace.shared.open(URL(string: "https://github.com/sponsors/nikitabobko").orDie()) + viewModel.sponsorshipMessage = sponsorshipPrompts.randomElement().orDie() + } label: { + Text("Sponsor AeroSpace on GitHub") + Text(viewModel.sponsorshipMessage) } Divider() - } - Button { - NSWorkspace.shared.open(URL(string: "https://github.com/sponsors/nikitabobko").orDie()) - viewModel.sponsorshipMessage = sponsorshipPrompts.randomElement().orDie() - } label: { - Text("Sponsor AeroSpace on GitHub") - Text(viewModel.sponsorshipMessage) - } - Divider() - Button(viewModel.isEnabled ? "Disable" : "Enable") { - Task.startUnstructured { - try await runLightSession(.menuBarButton, .forceRun) { - _ = await EnableCommand(args: EnableCmdArgs(rawArgs: [], targetState: .toggle)) - .run(.defaultEnv, .emptyStdin) + Button(viewModel.isEnabled ? "Disable" : "Enable") { + Task.startUnstructured { + try await runLightSession(.menuBarButton, .forceRun) { + _ = await EnableCommand(args: EnableCmdArgs(rawArgs: [], targetState: .toggle)) + .run(.defaultEnv, .emptyStdin) + } } + }.keyboardShortcut("E", modifiers: .command) + getExperimentalUISettingsMenu(viewModel: viewModel) + openConfigButton() + reloadConfigButton(warningsAsErrors: false) + } else { + Button("AeroSpace requires accessibility permission to move windows") { + viewModel.axPermissionStatus = .waitingWithPrompt } - }.keyboardShortcut("E", modifiers: .command) - getExperimentalUISettingsMenu(viewModel: viewModel) - openConfigButton() - reloadConfigButton(warningsAsErrors: false) + } Button("Quit \(aeroSpaceAppName)") { Task.startUnstructured { terminationHandler?.beforeTermination() @@ -65,12 +72,17 @@ public func menuBar(viewModel: TrayMenuModel) -> some Scene { // todo should it } }.keyboardShortcut("Q", modifiers: .command) } label: { - if viewModel.isEnabled { - MenuBarLabel().environmentObject(viewModel) - } else { - Image(systemName: "pause.circle.fill") - .resizable() - .aspectRatio(contentMode: .fit) + switch (viewModel.axPermissionStatus, viewModel.isEnabled) { + case (.granted, true): + MenuBarLabel().environmentObject(viewModel) + case (.granted, false): + Image(systemName: "pause.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + case (_, _): + Image(systemName: "exclamationmark.triangle.fill") + .resizable() + .aspectRatio(contentMode: .fit) } } } diff --git a/Sources/AppBundle/ui/TrayMenuModel.swift b/Sources/AppBundle/ui/TrayMenuModel.swift index 3e95e49b..b26d497b 100644 --- a/Sources/AppBundle/ui/TrayMenuModel.swift +++ b/Sources/AppBundle/ui/TrayMenuModel.swift @@ -14,6 +14,13 @@ public final class TrayMenuModel: ObservableObject { @Published var experimentalUISettings: ExperimentalUISettings = ExperimentalUISettings() @Published var sponsorshipMessage: String = sponsorshipPrompts.randomElement().orDie() @Published var lastReloadConfigContainedWarnings: Bool = false + @Published var axPermissionStatus: AxPermissionStatus = .waitingWithPrompt +} + +enum AxPermissionStatus: Equatable { + case granted + case waiting + case waitingWithPrompt } @MainActor func updateTrayText() { diff --git a/Sources/AppBundle/util/accessibility.swift b/Sources/AppBundle/util/accessibility.swift index d4cf65fa..2fbb8daa 100644 --- a/Sources/AppBundle/util/accessibility.swift +++ b/Sources/AppBundle/util/accessibility.swift @@ -3,11 +3,21 @@ import Common import PrivateApi @MainActor -func checkAccessibilityPermissions() { +func waitForAccessibilityPermission_nonCancellable() async { let options = [axTrustedCheckOptionPrompt: true] - if !AXIsProcessTrustedWithOptions(options as CFDictionary) { - resetAccessibility() // Because macOS doesn't reset it for us when the app signature changes... - terminateApp() + while true { + let status = TrayMenuModel.shared.axPermissionStatus == .waitingWithPrompt + ? AXIsProcessTrustedWithOptions(options as CFDictionary) + : AXIsProcessTrusted() + if status { + TrayMenuModel.shared.axPermissionStatus = .granted + break + } + if TrayMenuModel.shared.axPermissionStatus == .waitingWithPrompt { + resetAccessibility() // Because macOS doesn't reset it for us when the app signature changes... + } + TrayMenuModel.shared.axPermissionStatus = .waiting + try? await Task.sleep(for: .seconds(1)) } } -- 2.51.2