From 27e491c668154b55b0f07dfd47b5cadb2a87a1d9 Mon Sep 17 00:00:00 2001 From: LucaGerlich Date: Tue, 18 Aug 2026 20:17:56 +0200 Subject: [PATCH 1/2] fix: show a first-run window that requests Input Monitoring A new user saw nothing at all on first launch. The launch path only called IOHIDCheckAccess, which queries permission and never prompts; the only call that can raise the system dialog, IOHIDRequestAccess, sat behind a button in the menu bar menu. With LSUIElement there is no Dock icon and no window, so unless the user thought to open the menu, macOS never asked and the app was silently inert - and an app that never requests access does not appear in Privacy & Security either, so it could not be granted manually. Adds a first-run window explaining what InputPilot does, why it needs Input Monitoring, and that it never reads typed text, with a button that triggers the system prompt. It also covers the state macOS leaves behind right after a grant, where permission reads as granted but HID stays blocked until relaunch, by offering to quit so the user is not left with an app that looks permitted but does nothing. Verified by launching the built app: the window server reports the window on screen at launch when permission is missing. --- InputPilot/App/AppState.swift | 12 ++ InputPilot/App/InputPilotApp.swift | 30 ++++- InputPilot/UI/WelcomeView.swift | 103 ++++++++++++++++++ .../AppStateFailurePathTests.swift | 78 +++++++++++++ 4 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 InputPilot/UI/WelcomeView.swift diff --git a/InputPilot/App/AppState.swift b/InputPilot/App/AppState.swift index 077347a..f9fda47 100644 --- a/InputPilot/App/AppState.swift +++ b/InputPilot/App/AppState.swift @@ -187,6 +187,18 @@ final class AppState: ObservableObject { lastAction != nil && previousInputSourceIdBeforeLastSwitch != nil } + /// Without Input Monitoring the app can do nothing at all, and as a menu + /// bar app it has no window to say so - hence the first-run explainer. + var needsPermissionOnboarding: Bool { + !isInputMonitoringGranted + } + + /// macOS usually only lets a process read HID events after a relaunch that + /// follows the grant, so a granted-but-stopped monitor means "restart me". + var needsRelaunchAfterGrant: Bool { + isInputMonitoringGranted && !hidKeyboardMonitor.isRunning + } + var permissionWarningMessage: String { switch status.permissionStatus { case .denied: diff --git a/InputPilot/App/InputPilotApp.swift b/InputPilot/App/InputPilotApp.swift index f5a0910..7824a76 100644 --- a/InputPilot/App/InputPilotApp.swift +++ b/InputPilot/App/InputPilotApp.swift @@ -6,11 +6,20 @@ struct InputPilotApp: App { private let updaterService = UpdaterService() var body: some Scene { - MenuBarExtra("InputPilot", systemImage: "keyboard") { + MenuBarExtra { MenuBarMenuView(updaterService: updaterService) .environmentObject(appState) + } label: { + MenuBarLabel() + .environmentObject(appState) } + Window("Welcome to InputPilot", id: "welcome") { + WelcomeView() + .environmentObject(appState) + } + .windowResizability(.contentSize) + Window("Debug Log", id: "debug-log") { DebugLogView() .environmentObject(appState) @@ -25,3 +34,22 @@ struct InputPilotApp: App { .keyboardShortcut(",", modifiers: .command) } } + +/// The menu bar icon doubles as the app's only launch-time view, so it is the +/// one place that can open the first-run window without an AppKit delegate. +private struct MenuBarLabel: View { + @EnvironmentObject private var appState: AppState + @Environment(\.openWindow) private var openWindow + + var body: some View { + Image(systemName: "keyboard") + .onAppear { + guard appState.needsPermissionOnboarding else { + return + } + + openWindow(id: "welcome") + NSApp.activate(ignoringOtherApps: true) + } + } +} diff --git a/InputPilot/UI/WelcomeView.swift b/InputPilot/UI/WelcomeView.swift new file mode 100644 index 0000000..50103d4 --- /dev/null +++ b/InputPilot/UI/WelcomeView.swift @@ -0,0 +1,103 @@ +import SwiftUI + +/// First-run explainer. A menu bar app has no window of its own, so without +/// this a new user sees nothing happen at all: macOS never prompts for Input +/// Monitoring on its own, and the app stays invisible and inert. +struct WelcomeView: View { + @EnvironmentObject private var appState: AppState + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "keyboard") + .font(.system(size: 48)) + .foregroundStyle(.tint) + + VStack(spacing: 8) { + Text("Welcome to InputPilot") + .font(.title2.weight(.semibold)) + + Text("InputPilot switches your input language automatically, based on which keyboard you are typing on.") + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: 10) { + Label { + Text("macOS needs to grant **Input Monitoring** before InputPilot can tell your keyboards apart.") + } icon: { + Image(systemName: "lock.shield") + } + + Label { + Text("InputPilot only detects **which keyboard** sent a key press — never what you type. Nothing leaves your Mac.") + } icon: { + Image(systemName: "hand.raised") + } + } + .font(.callout) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 10)) + + actions + } + .padding(28) + .frame(width: 420) + } + + @ViewBuilder + private var actions: some View { + if appState.needsPermissionOnboarding { + VStack(spacing: 10) { + Button("Grant Access…") { + appState.requestInputMonitoringPermission() + } + .keyboardShortcut(.defaultAction) + .controlSize(.large) + + Button("Open System Settings") { + appState.openInputMonitoringSettings() + } + .buttonStyle(.link) + + Text("If macOS does not show a prompt, add InputPilot manually under Privacy & Security → Input Monitoring.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } else if appState.needsRelaunchAfterGrant { + VStack(spacing: 10) { + Label("Permission granted.", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + + Text("macOS applies Input Monitoring on the next launch. Quit and reopen InputPilot to finish setup.") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Button("Quit InputPilot") { + NSApp.terminate(nil) + } + .keyboardShortcut(.defaultAction) + .controlSize(.large) + } + } else { + VStack(spacing: 10) { + Label("You're all set.", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + + Text("Press a key on each keyboard you want to configure, then open Settings from the menu bar to assign an input source.") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Button("Done") { + dismiss() + } + .keyboardShortcut(.defaultAction) + .controlSize(.large) + } + } + } +} diff --git a/InputPilotTests/AppStateFailurePathTests.swift b/InputPilotTests/AppStateFailurePathTests.swift index daa44ed..1790f3f 100644 --- a/InputPilotTests/AppStateFailurePathTests.swift +++ b/InputPilotTests/AppStateFailurePathTests.swift @@ -410,6 +410,84 @@ struct AppStateFailurePathTests { #expect(mappingStore.getMapping(for: deviceKey) == "com.apple.keylayout.US") } + @Test + func onboardingIsRequestedWhenPermissionIsMissing() throws { + let suiteName = "AppStateFailurePathTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + throw FailurePathTestError.failedToCreateUserDefaultsSuite + } + + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + let appState = AppState( + permissionService: MockPermissionService(accessType: kIOHIDAccessTypeDenied), + hidKeyboardMonitor: MockHIDKeyboardMonitor(), + inputSourceService: MockInputSourceService(), + mappingStore: MockMappingStore(), + appSettingsStore: AppSettingsStore(defaults: defaults), + clock: ImmediateClock() + ) + + #expect(appState.needsPermissionOnboarding) + #expect(!appState.needsRelaunchAfterGrant) + } + + @Test + func onboardingIsNotRequestedOnceMonitoringRuns() throws { + let suiteName = "AppStateFailurePathTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + throw FailurePathTestError.failedToCreateUserDefaultsSuite + } + + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + let appState = AppState( + permissionService: MockPermissionService(accessType: kIOHIDAccessTypeGranted), + hidKeyboardMonitor: MockHIDKeyboardMonitor(), + inputSourceService: MockInputSourceService(), + mappingStore: MockMappingStore(), + appSettingsStore: AppSettingsStore(defaults: defaults), + clock: ImmediateClock() + ) + + #expect(!appState.needsPermissionOnboarding) + #expect(!appState.needsRelaunchAfterGrant) + } + + @Test + func relaunchIsRequestedWhenGrantedButMonitorCannotStart() throws { + let suiteName = "AppStateFailurePathTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + throw FailurePathTestError.failedToCreateUserDefaultsSuite + } + + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + // The state macOS leaves an app in right after the user grants access: + // permission reads as granted, but HID still refuses until relaunch. + let keyboardMonitor = MockHIDKeyboardMonitor() + keyboardMonitor.startResult = false + keyboardMonitor.lastStartErrorMessage = "HID monitor start blocked by macOS permissions/sandbox (kIOReturnNotPermitted)." + + let appState = AppState( + permissionService: MockPermissionService(accessType: kIOHIDAccessTypeGranted), + hidKeyboardMonitor: keyboardMonitor, + inputSourceService: MockInputSourceService(), + mappingStore: MockMappingStore(), + appSettingsStore: AppSettingsStore(defaults: defaults), + clock: ImmediateClock() + ) + + #expect(!appState.needsPermissionOnboarding) + #expect(appState.needsRelaunchAfterGrant) + } + private func usbKeyboard() -> ActiveKeyboardDevice { ActiveKeyboardDevice( vendorId: 1452, From 4529289e937640a7cc109307ddb5882edb668d77 Mon Sep 17 00:00:00 2001 From: LucaGerlich Date: Tue, 18 Aug 2026 20:21:32 +0200 Subject: [PATCH 2/2] fix: keep welcome text wrapping instead of truncating Every multi-line Text now refuses horizontal shrink, and the actions stack gets a width cap so it cannot take its widest child's single-line ideal width and let the window frame clip it. Without both, SwiftUI picked one text to truncate: first the caption, then the bullet once the caption was pinned. --- InputPilot.xcodeproj/project.pbxproj | 8 ++++---- InputPilot/UI/WelcomeView.swift | 12 ++++++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/InputPilot.xcodeproj/project.pbxproj b/InputPilot.xcodeproj/project.pbxproj index 1b0761a..279dfef 100644 --- a/InputPilot.xcodeproj/project.pbxproj +++ b/InputPilot.xcodeproj/project.pbxproj @@ -6,6 +6,10 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 5A00000012AB34CD56EF7803 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 5A00000012AB34CD56EF7802 /* Sparkle */; }; +/* End PBXBuildFile section */ + /* Begin PBXContainerItemProxy section */ 81F413C32F39F05700EEB6D9 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -47,10 +51,6 @@ }; /* End PBXFileSystemSynchronizedRootGroup section */ -/* Begin PBXBuildFile section */ - 5A00000012AB34CD56EF7803 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 5A00000012AB34CD56EF7802 /* Sparkle */; }; -/* End PBXBuildFile section */ - /* Begin PBXFrameworksBuildPhase section */ 81F413B02F39F05600EEB6D9 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; diff --git a/InputPilot/UI/WelcomeView.swift b/InputPilot/UI/WelcomeView.swift index 50103d4..d19e4a9 100644 --- a/InputPilot/UI/WelcomeView.swift +++ b/InputPilot/UI/WelcomeView.swift @@ -20,6 +20,7 @@ struct WelcomeView: View { Text("InputPilot switches your input language automatically, based on which keyboard you are typing on.") .multilineTextAlignment(.center) .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } VStack(alignment: .leading, spacing: 10) { @@ -28,12 +29,14 @@ struct WelcomeView: View { } icon: { Image(systemName: "lock.shield") } + .fixedSize(horizontal: false, vertical: true) Label { Text("InputPilot only detects **which keyboard** sent a key press — never what you type. Nothing leaves your Mac.") } icon: { Image(systemName: "hand.raised") } + .fixedSize(horizontal: false, vertical: true) } .font(.callout) .frame(maxWidth: .infinity, alignment: .leading) @@ -65,7 +68,12 @@ struct WelcomeView: View { .font(.caption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) } + // Without a width cap the stack takes its widest child's ideal + // single-line width, and the window frame clips the caption + // instead of letting it wrap. + .frame(maxWidth: .infinity) } else if appState.needsRelaunchAfterGrant { VStack(spacing: 10) { Label("Permission granted.", systemImage: "checkmark.circle.fill") @@ -75,6 +83,7 @@ struct WelcomeView: View { .font(.callout) .foregroundStyle(.secondary) .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) Button("Quit InputPilot") { NSApp.terminate(nil) @@ -82,6 +91,7 @@ struct WelcomeView: View { .keyboardShortcut(.defaultAction) .controlSize(.large) } + .frame(maxWidth: .infinity) } else { VStack(spacing: 10) { Label("You're all set.", systemImage: "checkmark.circle.fill") @@ -91,6 +101,7 @@ struct WelcomeView: View { .font(.callout) .foregroundStyle(.secondary) .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) Button("Done") { dismiss() @@ -98,6 +109,7 @@ struct WelcomeView: View { .keyboardShortcut(.defaultAction) .controlSize(.large) } + .frame(maxWidth: .infinity) } } }