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/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..d19e4a9 --- /dev/null +++ b/InputPilot/UI/WelcomeView.swift @@ -0,0 +1,115 @@ +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) + .fixedSize(horizontal: false, vertical: true) + } + + 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") + } + .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) + .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) + .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") + .foregroundStyle(.green) + + Text("macOS applies Input Monitoring on the next launch. Quit and reopen InputPilot to finish setup.") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + Button("Quit InputPilot") { + NSApp.terminate(nil) + } + .keyboardShortcut(.defaultAction) + .controlSize(.large) + } + .frame(maxWidth: .infinity) + } 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) + .fixedSize(horizontal: false, vertical: true) + + Button("Done") { + dismiss() + } + .keyboardShortcut(.defaultAction) + .controlSize(.large) + } + .frame(maxWidth: .infinity) + } + } +} 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,