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
8 changes: 4 additions & 4 deletions InputPilot.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions InputPilot/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment on lines +196 to +200

var permissionWarningMessage: String {
switch status.permissionStatus {
case .denied:
Expand Down
30 changes: 29 additions & 1 deletion InputPilot/App/InputPilotApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
}
Comment on lines +44 to +54
}
115 changes: 115 additions & 0 deletions InputPilot/UI/WelcomeView.swift
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +73 to +76
} 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)
}
}
}
78 changes: 78 additions & 0 deletions InputPilotTests/AppStateFailurePathTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down