diff --git a/.github/workflows/ocr-review.yml b/.github/workflows/ocr-review.yml index 000c9d9..23e8850 100644 --- a/.github/workflows/ocr-review.yml +++ b/.github/workflows/ocr-review.yml @@ -37,7 +37,7 @@ permissions: jobs: code-review: runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 60 # Run only on human-authored comments from contributors/maintainers on a PR # starting with trigger keywords (/open-code-review, @open-code-review, /ocr, /review). # Automatic runs on PR open/synchronize are intentionally disabled. diff --git a/.gitignore b/.gitignore index 35f9527..c9fe23d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ DerivedData/ .vscode/ .claude/settings.local.json .grepai/ +.codesign-identity # Generated dictionary artifacts .build/dictionary-bin/ @@ -15,3 +16,8 @@ plan/artifacts/*.json plan/artifacts/*.txt scripts/__pycache__/ Sources/Dictionary/Resources/uk_full.txt + +# Local / temporary files and tooling +.pi/ +temp.txt +*.key diff --git a/Package.swift b/Package.swift index 90a99e7..a10af66 100644 --- a/Package.swift +++ b/Package.swift @@ -19,7 +19,7 @@ let package = Package( ), .target( name: "Dictionary", - dependencies: [], + dependencies: ["Utils"], path: "Sources/Dictionary", exclude: ["Resources/uk_full.txt"], resources: [ diff --git a/README.md b/README.md index 5227827..73de411 100644 --- a/README.md +++ b/README.md @@ -22,28 +22,47 @@ A macOS menu bar utility that automatically corrects keyboard layout mistakes. T - macOS 13.0 or later -## Installation (Recommended) +## Installation -The easiest way to install and set up SwitchFix is using the automated install script. It builds the app, installs it to your Applications folder, sets it to run at startup, and guides you through the necessary macOS privacy permissions. - -1. Download or clone this repository to your Mac. -2. Open your Terminal and navigate to the SwitchFix folder. -3. Run the setup script: +### From source ```bash +git clone https://github.com/rundax/SwitchFix.git +cd SwitchFix ./install.sh ``` -4. Follow the interactive prompts to grant the required **Accessibility** and **Input Monitoring** permissions. +The script builds the app, installs it to `/Applications`, sets it to run at startup, and guides you through the required **Accessibility** and **Input Monitoring** permissions. + +> **Note:** Requires Xcode Command Line Tools. The script will prompt you to install them if missing. + +### From DMG + +Download a pre-built `.dmg` from the [Releases page](https://github.com/rundax/SwitchFix/releases), open it, and double-click **Install SwitchFix**. + +## Development + +### Stable code signing (recommended) + +Ad-hoc signing (the default) changes the binary hash on every build, which forces you to re-grant Accessibility and Input Monitoring permissions each time. To avoid this, create a local code-signing certificate once: + +```bash +./scripts/setup-codesign.sh +``` + +This creates a self-signed certificate in your Keychain and saves it to `.codesign-identity`. All subsequent builds via `build-app.sh` and `install.sh` will use it automatically — permissions survive rebuilds. + +### Build without installing -> **Note:** If you don't have Apple's Command Line Tools installed, the script will prompt you to install them first. Just follow the macOS prompts and re-run `./install.sh` when it finishes. +```bash +./scripts/build-app.sh # → dist/SwitchFix.app +``` -### Easy Installation (Pre-built Releases) +### Create a DMG -You can also download a pre-compiled `.dmg` version from the [Releases page](https://github.com/rundax/SwitchFix/releases). -1. Open the downloaded `.dmg` file. -2. Double-click the **`Install SwitchFix`** script inside. -3. A terminal will open to securely copy the app to your Applications folder, bypass Apple's "App is damaged" quarantine warning for unsigned apps, and interactively guide you through granting the necessary macOS privacy permissions. +```bash +./scripts/create-dmg.sh # → dist/SwitchFix.dmg +``` ## Menu Bar Options @@ -56,14 +75,14 @@ SwitchFix lives in your menu bar with an **Ab** icon. The menu provides: ## Advanced Configuration -SwitchFix stores hotkeys in `UserDefaults`. You can customize them via Terminal if you prefer advanced bindings: +SwitchFix stores hotkeys in `UserDefaults`. Customize via Terminal: ```bash -# Set revert hotkey to CapsLock (no modifiers) +# Revert hotkey: CapsLock (no modifiers) defaults write com.switchfix.app SwitchFix_revertHotkeyKeyCode -int 57 defaults write com.switchfix.app SwitchFix_revertHotkeyModifiers -int 0 -# Set correction hotkey to Ctrl+Shift+Space +# Correction hotkey: Ctrl+Shift+Space defaults write com.switchfix.app SwitchFix_hotkeyKeyCode -int 49 defaults write com.switchfix.app SwitchFix_hotkeyModifiers -int $((262144+131072)) ``` diff --git a/Sources/Core/InputEngine.swift b/Sources/Core/InputEngine.swift index 8bc5d8a..56ffe7d 100644 --- a/Sources/Core/InputEngine.swift +++ b/Sources/Core/InputEngine.swift @@ -1,5 +1,6 @@ import Foundation import os +import Utils public struct DetectionRequest: Equatable { public let word: String @@ -255,6 +256,7 @@ public final class InputEngine { private func process(_ input: CapturedInput) { latestProcessedSequence = input.sequence + logger.debug("input seq=\(input.sequence) kind=\(String(describing: input.kind)) keyCode=\(input.keyCode) autorepeat=\(input.isAutorepeat) srcPid=\(input.sourcePID)") if input.kind.recordsUserEdit { correctionQueue.async { [weak self] in @@ -269,7 +271,7 @@ public final class InputEngine { // word buffer up and get "corrected" with a wrong delete count. stateMachine.invalidateUntilBoundary() resetDetectorState() - logger.debug("buffer invalidated reason=stale-capture-context") + logger.debug("buffer invalidated reason=stale-capture-context captured=\(String(describing: input.context)) live=\(String(describing: liveContext))") return } @@ -286,12 +288,15 @@ public final class InputEngine { private func handle(_ command: InputStateCommand) { switch command { - case .append, .deleteLast: - break + case .append(let text): + logger.debug("buffer '\(self.stateMachine.currentBuffer)' (+ '\(text)')") + case .deleteLast: + logger.debug("buffer '\(self.stateMachine.currentBuffer)' (backspace)") case .invalidate(let reason): resetDetectorState() - logger.debug("buffer invalidated reason=\(String(describing: reason), privacy: .public)") + logger.debug("buffer invalidated reason=\(String(describing: reason))") case .flush(let word, let boundary, let sequence, let context): + logger.notice("word flushed '\(word)' boundary='\(boundary)' seq=\(sequence) layout=\(context.layout.rawValue)") let latest = captureState.snapshot() runDetection(DetectionRequest( word: word, @@ -302,8 +307,10 @@ public final class InputEngine { context: context )) case .requestManualCorrection(let word, let sequence, let context): + logger.notice("hotkey correction requested word='\(word ?? "nil")' seq=\(sequence)") requestManualCorrection(word: word, sequence: sequence, context: context) case .requestRevert(let word, let sequence, let context): + logger.notice("revert hotkey pressed word='\(word ?? "nil")' seq=\(sequence)") correctionQueue.async { [weak self] in guard let self else { return } if !self.corrector.undo( @@ -343,7 +350,15 @@ public final class InputEngine { ) } let duration = DispatchTime.now().uptimeNanoseconds &- startedAt - self.logger.debug("dictionary lookup ns=\(duration, privacy: .public)") + if let result { + SwitchFixLog.detector.notice( + "detect '\(result.originalWord)' -> '\(result.convertedWord)' source=\(result.sourceLayout.rawValue) target=\(result.targetLayout.rawValue) switch=\(result.shouldSwitchLayout) ms=\(Double(duration) / 1_000_000.0)" + ) + } else { + SwitchFixLog.detector.info( + "detect '\(request.word)' -> keep (no correction, ms=\(Double(duration) / 1_000_000.0))" + ) + } guard let result else { return } self.inputQueue.async { self.prepareCorrection(result: result, request: request) @@ -353,18 +368,33 @@ public final class InputEngine { private func prepareCorrection(result: DetectionResult, request: DetectionRequest) { let latest = captureState.snapshot() - guard latest.latestPhysicalSequence == request.sequence, - latest.editGeneration == request.editGeneration, - latest.correctionEpoch == request.correctionEpoch, - latest.context == request.context, - latest.context.secureFocus == .notSecure, - latest.correctionAllowed, - result.originalWord.count <= 64 else { - logger.debug("correction cancelled before emission") + var cancelReason: String? + if latest.latestPhysicalSequence != request.sequence { + cancelReason = "stale-sequence" + } else if latest.editGeneration != request.editGeneration { + cancelReason = "edit-generation-changed" + } else if latest.correctionEpoch != request.correctionEpoch { + cancelReason = "correction-epoch-changed" + } else if latest.context != request.context { + cancelReason = "context-changed" + } else if latest.context.secureFocus != .notSecure { + cancelReason = "secure-focus" + } else if !latest.context.appAllowed { + cancelReason = "app-not-allowed" + } else if !latest.correctionAllowed { + cancelReason = "correction-disallowed" + } else if result.originalWord.count > 64 { + cancelReason = "word-too-long" + } + guard cancelReason == nil else { + logger.debug("correction cancelled reason=\(cancelReason!) word='\(result.originalWord)'") return } let boundary = request.boundary + logger.notice( + "correction planned '\(result.originalWord)' -> '\(result.convertedWord)' deletes=\(result.originalWord.count + boundary.count) pid=\(request.context.frontmostPID)" + ) let plan = CorrectionPlan( boundarySequence: request.sequence, contextEpoch: request.context.epoch, @@ -382,7 +412,10 @@ public final class InputEngine { correctionQueue.async { [weak self] in guard let self else { return } - guard plan.isEligible(using: self.captureState.snapshot()) else { return } + guard plan.isEligible(using: self.captureState.snapshot()) else { + SwitchFixLog.corrector.debug("emission skipped: state changed before apply '\(plan.originalText)'") + return + } if let customEmission = self.customEmission { _ = customEmission(plan) } else { diff --git a/Sources/Core/InputSourceManager.swift b/Sources/Core/InputSourceManager.swift index a4d74e9..15af97d 100644 --- a/Sources/Core/InputSourceManager.swift +++ b/Sources/Core/InputSourceManager.swift @@ -1,6 +1,7 @@ import Carbon import Foundation import os +import Utils public final class InputSourceManager { public static let shared = InputSourceManager() @@ -146,11 +147,12 @@ public final class InputSourceManager { value.pendingSelectionID = sourceID return (source, sourceID) }) else { - NSLog("[SwitchFix] switchTo(%@): no cached source", layout.rawValue) + SwitchFixLog.source.error("switchTo(\(layout.rawValue)): no cached input source") return false } if currentInputSourceID() == target.1 { state.withLock { $0.pendingSelectionID = nil } + SwitchFixLog.source.debug("switchTo(\(layout.rawValue)): already active") return true } @@ -164,7 +166,9 @@ public final class InputSourceManager { } } callbacks.selectionFailed?() - NSLog("[SwitchFix] switchTo(%@): TISSelectInputSource failed (%d)", layout.rawValue, status) + SwitchFixLog.source.error("switchTo(\(layout.rawValue)): TISSelectInputSource failed (\(status))") + } else { + SwitchFixLog.source.notice("layout switched to \(layout.rawValue) (\(target.1))") } return status == noErr } diff --git a/Sources/Core/InputStateMachine.swift b/Sources/Core/InputStateMachine.swift index 0706535..2aaf37e 100644 --- a/Sources/Core/InputStateMachine.swift +++ b/Sources/Core/InputStateMachine.swift @@ -104,9 +104,17 @@ public struct InputStateMachine { return [.nativeUndo] case .hotkey: let word = currentBuffer.isEmpty ? nil : currentBuffer + // The correction rewrites these characters outside the event stream, + // so the buffer must drop them; keeping them desyncs the buffer from + // the screen and makes the next flush delete already-corrected text. + currentBuffer = "" return [.requestManualCorrection(word: word, sequence: input.sequence, context: input.context)] case .revertHotkey: let word = currentBuffer.isEmpty ? nil : currentBuffer + // Same desync hazard: whether the revert succeeds (original text is + // restored) or falls back to a manual correction, the on-screen word + // is rewritten without buffer-visible events. + currentBuffer = "" return [.requestRevert(word: word, sequence: input.sequence, context: input.context)] case .delete: guard canBuffer(input.context) else { diff --git a/Sources/Core/KeyboardMonitor.swift b/Sources/Core/KeyboardMonitor.swift index efff288..4c7ef08 100644 --- a/Sources/Core/KeyboardMonitor.swift +++ b/Sources/Core/KeyboardMonitor.swift @@ -157,10 +157,8 @@ public final class KeyboardMonitor { let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tapResult.tap, 0) else { let accessibility = Permissions.isAccessibilityGranted() let inputMonitoring = Permissions.isInputMonitoringGranted() - NSLog( - "[SwitchFix] KeyboardMonitor: failed to create event tap (Accessibility: %@, Input Monitoring: %@)", - accessibility ? "granted" : "missing", - inputMonitoring ? "granted" : "missing" + SwitchFixLog.monitor.error( + "KeyboardMonitor: failed to create event tap (Accessibility: \(accessibility ? "granted" : "missing"), Input Monitoring: \(inputMonitoring ? "granted" : "missing"))" ) return false } @@ -173,9 +171,8 @@ public final class KeyboardMonitor { } CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) CGEvent.tapEnable(tap: tapResult.tap, enable: true) - NSLog( - "[SwitchFix] KeyboardMonitor: event tap active (%@)", - tapResult.location == .cgSessionEventTap ? "session" : "HID" + SwitchFixLog.monitor.notice( + "KeyboardMonitor: event tap active (\(tapResult.location == .cgSessionEventTap ? "session" : "HID"))" ) return true } @@ -243,6 +240,7 @@ public final class KeyboardMonitor { private func handleTapReset(event: CGEvent) { tapResetCount &+= 1 + SwitchFixLog.monitor.notice("tap disabled by system, re-enabling (count=\(tapResetCount))") let input = captureState.capture( timestamp: event.timestamp, kind: .tapReset, diff --git a/Sources/Core/LayoutDetector.swift b/Sources/Core/LayoutDetector.swift index a5f23a9..edca8c0 100644 --- a/Sources/Core/LayoutDetector.swift +++ b/Sources/Core/LayoutDetector.swift @@ -1,5 +1,6 @@ import Foundation import Dictionary +import Utils /// Represents a detection result — the target layout and converted word. public struct DetectionResult { @@ -95,9 +96,6 @@ public class LayoutDetector { private static let latinLowercaseRange: ClosedRange = 0x0061...0x007A private static let latinUppercaseRange: ClosedRange = 0x0041...0x005A private static let cyrillicRange: ClosedRange = 0x0400...0x052F - private static let ukrainianTypoOverrides: [String: String] = [ - "дуе": "дує" - ] /// The currently active keyboard layout (set externally by InputSourceManager). public var currentLayout: Layout = .english @@ -204,6 +202,7 @@ public class LayoutDetector { // Skip if the word contains mixed scripts (both Latin and Cyrillic) if containsMixedScripts(word) { + SwitchFixLog.detector.debug("mixed scripts, skipping '\(word)'") state = .buffering return nil } @@ -211,9 +210,10 @@ public class LayoutDetector { // Check if the word is valid in the current layout's language let currentWordParts = splitTokenForValidation(word) let currentValidationInput = currentWordParts.core.isEmpty ? word : currentWordParts.core - + let currentLanguage = languageForLayout(sourceLayout) if validator.validate(currentValidationInput, language: currentLanguage, allowSuggestion: false).isValid { + SwitchFixLog.detector.debug("valid in \(currentLanguage.rawValue): '\(word)' — no correction") consecutiveWrongCount = 0 lastDetectionResult = nil pendingSwitchLayout = nil @@ -233,25 +233,6 @@ public class LayoutDetector { return nil } - if sourceLayout == .ukrainian, - let override = ukrainianTypoOverride(for: word) { - let correctedWord = applyCase(from: word, to: override) - let result = DetectionResult( - sourceLayout: sourceLayout, - targetLayout: sourceLayout, - convertedWord: correctedWord, - originalWord: word, - shouldSwitchLayout: false - ) - lastDetectionResult = result - pendingSwitchLayout = nil - pendingSwitchCount = 0 - consecutiveWrongCount = 0 - recordOutcome(.corrected) - state = .buffering - return result - } - // Try converting to alternative layouts let alternatives = LayoutMapper.convertToAlternatives( word, @@ -310,6 +291,7 @@ public class LayoutDetector { isLowConfidence: isLowConfidence, shouldSwitch: shouldSwitch ) { + SwitchFixLog.detector.info("suppressed short word '\(word)' -> '\(finalWord)' (weak evidence, deferring)") consecutiveWrongCount = 0 lastDetectionResult = nil if let boundary = pendingBoundaryCharacter, !boundary.isEmpty { @@ -400,6 +382,7 @@ public class LayoutDetector { } // No valid alternative found — unknown word, do nothing + SwitchFixLog.detector.debug("unknown word '\(word)' — no valid alternative in any layout") pendingSwitchLayout = nil pendingSwitchCount = 0 recordOutcome(.unknown) @@ -702,11 +685,6 @@ public class LayoutDetector { return (prefix, core, suffix) } - private func ukrainianTypoOverride(for word: String) -> String? { - let normalized = word.lowercased() - return LayoutDetector.ukrainianTypoOverrides[normalized] - } - /// Split trailing punctuation/symbols from a word. private func splitTrailingBoundary(from text: String) -> (core: String, trailing: String) { var core = text diff --git a/Sources/Core/TextCorrector.swift b/Sources/Core/TextCorrector.swift index c93662e..254a1f8 100644 --- a/Sources/Core/TextCorrector.swift +++ b/Sources/Core/TextCorrector.swift @@ -2,6 +2,7 @@ import AppKit import CoreGraphics import Foundation import os +import Utils public struct CorrectionPlan: Equatable { public let boundarySequence: UInt64 @@ -113,19 +114,22 @@ public final class TextCorrector { return [] } var events: [CorrectionEventDescriptor] = [] - events.reserveCapacity(plan.deleteCount * 2 + 2) + events.reserveCapacity(plan.deleteCount * 2 + plan.replacementText.count * 2) for _ in 0.. CaptureStateSnapshot ) -> Bool { - guard let undo = undoState.withLock({ $0 }) else { return false } + guard let undo = undoState.withLock({ $0 }) else { + logger.info("undo skipped: no recorded correction") + return false + } let latest = latestCaptureState() guard Self.isUndoEligible( recordedPlan: undo.plan, @@ -207,6 +216,7 @@ public final class TextCorrector { context: context, latest: latest ) else { + logger.info("undo skipped: state stale since correction '\(undo.plan.correctedText)'") undoState.withLock { $0 = nil } return false } @@ -228,15 +238,19 @@ public final class TextCorrector { ) guard let events = makeCorrectionEvents(plan: inverse), inverse.isEligible(using: latestCaptureState()) else { + logger.debug("undo rejected: could not build inverse events or state changed") return false } post(events, targetPID: inverse.targetPID) undoState.withLock { $0 = nil } + logger.notice( + "revert APPLIED '\(inverse.correctedText)' <- '\(inverse.originalText)' deletes=\(inverse.deleteCount) pid=\(inverse.targetPID)" + ) if inverse.isEligible(using: latestCaptureState()) { - let layout = undo.plan.originalLayout + let undoLayout = undo.plan.originalLayout // TIS APIs are main-thread-only; undo() runs on the correction queue. DispatchQueue.main.async { [inputSourceManager] in - inputSourceManager.switchTo(layout) + inputSourceManager.switchTo(undoLayout) } } return true @@ -265,9 +279,13 @@ public final class TextCorrector { latest.context.secureFocus == .notSecure, latest.context.appAllowed, latest.correctionAllowed else { + logger.debug("selection correction skipped: state changed before paste") return } + logger.notice( + "selection paste '\(convertedText)' <- '\(selectedText)' pid=\(context.frontmostPID) layoutSwitch=\(shouldSwitchLayout ? targetLayout.rawValue : "none")" + ) let pasteboard = NSPasteboard.general // Snapshot item data into fresh items: items read from a pasteboard are // invalidated by clearContents() and cannot be written back. @@ -328,7 +346,7 @@ public final class TextCorrector { private func makeCorrectionEvents(plan: CorrectionPlan) -> [CGEvent]? { guard eventSource != nil, !plan.replacementText.isEmpty else { return nil } var events: [CGEvent] = [] - events.reserveCapacity(plan.deleteCount * 2 + 2) + events.reserveCapacity(plan.deleteCount * 2 + plan.replacementText.count * 2) for _ in 0.. DictionaryIndex? { if let binURL = findDictionaryURL(for: language, ext: "bin") { if let mapped = MappedDictionary(url: binURL) { - NSLog("[SwitchFix] Dictionary: using mmap binary %@", binURL.path) + SwitchFixLog.dictionary.debug("using mmap binary \(binURL.path)") return mapped } - NSLog("[SwitchFix] Dictionary: failed to parse %@.bin", language.rawValue) + SwitchFixLog.dictionary.error("failed to parse \(language.rawValue).bin") } if textFallbackEnabledForTesting, let txtURL = findDictionaryURL(for: language, ext: "txt") { - NSLog("[SwitchFix] Dictionary: using text fallback %@", txtURL.path) + SwitchFixLog.dictionary.debug("using text fallback \(txtURL.path)") return TextDictionary(url: txtURL) } - NSLog("[SwitchFix] Dictionary: missing or invalid binary resource for %@", language.rawValue) + SwitchFixLog.dictionary.error("missing or invalid binary resource for \(language.rawValue)") return nil } diff --git a/Sources/InputPipelineTestRunner/main.swift b/Sources/InputPipelineTestRunner/main.swift index 42f8c76..e5ec6a0 100644 --- a/Sources/InputPipelineTestRunner/main.swift +++ b/Sources/InputPipelineTestRunner/main.swift @@ -119,6 +119,54 @@ run("autorepeat preserved") { check(word == "cc", "autorepeat characters must not be deduplicated") } +run("manual hotkey resyncs buffer") { + let current = context() + var machine = automaticMachine(current) + for (index, character) in ["g", "h", "b", "d", "t", "n"].enumerated() { + _ = machine.consume(input( + sequence: UInt64(index + 1), + kind: .character(character), + context: current + )) + } + let hotkeyCommands = machine.consume(input(sequence: 7, kind: .hotkey, context: current)) + guard case .requestManualCorrection(let word?, _, _) = hotkeyCommands.first else { + check(false, "hotkey must request manual correction with the buffered word") + return + } + check(word == "ghbdtn", "hotkey must hand the buffered word to correction") + check(machine.currentBuffer.isEmpty, "buffer must drop the word handed to correction") + + // The correction rewrites the word via tagged events the pipeline ignores, + // so only post-hotkey typing may remain in the buffer. + for (index, character) in ["d", "r", "u", "g"].enumerated() { + _ = machine.consume(input( + sequence: UInt64(8 + index), + kind: .character(character), + context: current + )) + } + let flushed = machine.consume(input(sequence: 12, kind: .boundary(" "), context: current)) + .compactMap { command -> String? in + if case .flush(let word, _, _, _) = command { return word } + return nil + } + check(flushed == ["drug"], "stale pre-correction text must never be re-deleted at the next boundary") +} + +run("revert hotkey resyncs buffer") { + let current = context() + var machine = automaticMachine(current) + _ = machine.consume(input(sequence: 1, kind: .character("x"), context: current)) + let commands = machine.consume(input(sequence: 2, kind: .revertHotkey, context: current)) + guard case .requestRevert(let word?, _, _) = commands.first else { + check(false, "revert hotkey must request revert with the buffered word") + return + } + check(word == "x", "revert must hand the buffered word to the undo path") + check(machine.currentBuffer.isEmpty, "revert must drop the buffered word to avoid desync") +} + run("generated identity ignored") { let current = context() var machine = automaticMachine(current) @@ -377,7 +425,7 @@ run("bounded tagged event batch") { return false } check(deletes.count == 8, "N deletes must produce exactly N tagged key pairs") - check(unicode.count == 2, "replacement must produce one Unicode key pair") + check(unicode.count == plan.replacementText.count * 2, "replacement of N chars must produce N Unicode key pairs") check(events.allSatisfy { $0.sourceUserData == switchFixEventMarker }, "every generated event must carry the marker") } @@ -586,12 +634,12 @@ run("100,000 event stress") { if sequence.isMultiple(of: 128) { let drained = DispatchSemaphore(value: 0) engine.drain { drained.signal() } - check(drained.wait(timeout: .now() + 1) == .success, "engine batch must drain without loss") + check(drained.wait(timeout: .now() + 3) == .success, "engine batch must drain without loss") } } let drained = DispatchSemaphore(value: 0) engine.drain { drained.signal() } - check(drained.wait(timeout: .now() + 1) == .success, "final engine batch must drain") + check(drained.wait(timeout: .now() + 3) == .success, "final engine batch must drain") check(detectionsComplete.wait(timeout: .now() + 30) == .success, "all boundary detections must complete") let sequences = recorder.snapshot() diff --git a/Sources/SwitchFixApp/AppDelegate.swift b/Sources/SwitchFixApp/AppDelegate.swift index 24db1d6..7869a8a 100644 --- a/Sources/SwitchFixApp/AppDelegate.swift +++ b/Sources/SwitchFixApp/AppDelegate.swift @@ -17,6 +17,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var previousInputSourceID = "unknown" func applicationDidFinishLaunching(_ notification: Notification) { + SwitchFixLog.app.notice("launched, pid=\(ProcessInfo.processInfo.processIdentifier)") statusBarController = StatusBarController() inputSourceManager.refreshCurrentInputSource() previousLayout = inputSourceManager.currentLayout() @@ -107,15 +108,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { unavailable.append(layout.rawValue) } } - DispatchQueue.main.async { - if !unavailable.isEmpty { - NSLog( - "[SwitchFix] Automatic correction unavailable for layouts: %@", - unavailable.joined(separator: ", ") - ) - } - completion(readyLayouts) + DispatchQueue.main.async { + if !unavailable.isEmpty { + SwitchFixLog.app.notice( + "automatic correction unavailable for layouts: \(unavailable.joined(separator: ", "))" + ) } + completion(readyLayouts) + } } } @@ -130,12 +130,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { keyboardMonitor = monitor guard monitor.start() else { - NSLog("[SwitchFix] Monitoring failed to start") + SwitchFixLog.app.error("Monitoring failed to start (event tap creation failed)") return } let context = state.snapshot().context focusCoordinator?.observeApplication(pid: context.frontmostPID, epoch: context.epoch) - NSLog("[SwitchFix] Monitoring started") + SwitchFixLog.app.notice("monitoring started pid=\(context.frontmostPID) layout=\(context.layout.rawValue) appAllowed=\(context.appAllowed)") } private func registerConfigurationObservers() { @@ -195,6 +195,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { inputEngine?.updateContext(context) updateDetectionConfiguration(allowedLayouts: readyLayouts) focusCoordinator?.observeApplication(pid: context.frontmostPID, epoch: context.epoch) + SwitchFixLog.app.notice( + "frontmost changed pid=\(context.frontmostPID) bundle=\(application.bundleIdentifier ?? "nil") allowed=\(allowed) layout=\(layout.rawValue)" + ) } @objc private func selectedInputSourceChanged() { @@ -211,6 +214,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let current = state.snapshot().context guard oldSourceID != newSourceID || oldLayout != newLayout else { return } + SwitchFixLog.app.notice( + "layout changed old=\(oldLayout.rawValue) new=\(newLayout.rawValue) generated=\(expectedGeneratedSelection)" + ) let context = state.replaceContext( frontmostPID: current.frontmostPID, appAllowed: current.appAllowed, @@ -268,6 +274,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } @objc private func preferencesDidUpdate() { + SwitchFixLog.preferences.notice( + "preferences updated enabled=\(PreferencesManager.shared.isEnabled) mode=\(PreferencesManager.shared.correctionMode.rawValue)" + ) captureState?.updateHotkeys(currentHotkeyConfiguration()) inputEngine?.updatePreferences(currentPreferencesSnapshot()) } @@ -338,6 +347,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { ) else { return } + SwitchFixLog.app.debug("focus resolved pid=\(resolution.pid) state=\(String(describing: secureFocus))") inputEngine?.updateContext(context) } diff --git a/Sources/TestRunner/main.swift b/Sources/TestRunner/main.swift index cee18ea..8386602 100644 --- a/Sources/TestRunner/main.swift +++ b/Sources/TestRunner/main.swift @@ -483,7 +483,7 @@ runSuite("LayoutDetector: Convert Ukrainian 'ершиЖ' to English 'this:'") { } } -runSuite("LayoutDetector: Correct typo in Ukrainian without layout switch") { +runSuite("LayoutDetector: Convert Ukrainian 'дуе' to English 'let'") { let detector = LayoutDetector() let mockDelegate = MockDetectorDelegate() detector.delegate = mockDelegate @@ -494,12 +494,11 @@ runSuite("LayoutDetector: Correct typo in Ukrainian without layout switch") { } detector.flushBuffer(boundaryCharacter: " ") - assertEqual(mockDelegate.results.count, 1, "should detect typo in Ukrainian word") + assertEqual(mockDelegate.results.count, 1, "should detect wrong layout for 'дуе'") if let result = mockDelegate.results.first { - assertEqual(result.sourceLayout, .ukrainian, "source should stay Ukrainian") - assertEqual(result.targetLayout, .ukrainian, "target should stay Ukrainian") - assertEqual(result.convertedWord, "дує", "should correct to 'дує'") - assert(!result.shouldSwitchLayout, "typo correction should not switch layout") + assertEqual(result.sourceLayout, .ukrainian, "source should be Ukrainian") + assertEqual(result.targetLayout, .english, "target should be English") + assertEqual(result.convertedWord, "let", "should convert 'дуе' to 'let'") } } diff --git a/Sources/UI/PreferencesManager.swift b/Sources/UI/PreferencesManager.swift index 92b6090..8831953 100644 --- a/Sources/UI/PreferencesManager.swift +++ b/Sources/UI/PreferencesManager.swift @@ -1,6 +1,7 @@ import Foundation import CoreGraphics import ServiceManagement +import Utils public enum CorrectionMode: String { case automatic @@ -46,7 +47,7 @@ public class PreferencesManager { try SMAppService.mainApp.unregister() } } catch { - NSLog("[PreferencesManager] Failed to toggle launch at login: \(error)") + SwitchFixLog.preferences.error("Failed to toggle launch at login: \(error)") } } } diff --git a/Sources/Utils/AppFilter.swift b/Sources/Utils/AppFilter.swift index 0688fcb..810ac5e 100644 --- a/Sources/Utils/AppFilter.swift +++ b/Sources/Utils/AppFilter.swift @@ -74,24 +74,26 @@ public class AppFilter { } public func addToBlacklist(_ bundleID: String) { - state.withLock { value in + let snapshot = state.withLock { value -> State in value.userRemoved.remove(bundleID) if !AppFilter.defaultBlacklist.contains(bundleID) { value.userAdded.insert(bundleID) } - save(value) + return value } + save(snapshot) NotificationCenter.default.post(name: .appFilterDidChange, object: nil) } public func removeFromBlacklist(_ bundleID: String) { - state.withLock { value in + let snapshot = state.withLock { value -> State in value.userAdded.remove(bundleID) if AppFilter.defaultBlacklist.contains(bundleID) { value.userRemoved.insert(bundleID) } - save(value) + return value } + save(snapshot) NotificationCenter.default.post(name: .appFilterDidChange, object: nil) } diff --git a/Sources/Utils/Permissions.swift b/Sources/Utils/Permissions.swift index 80d6518..1ec441c 100644 --- a/Sources/Utils/Permissions.swift +++ b/Sources/Utils/Permissions.swift @@ -6,8 +6,9 @@ import os public class Permissions { public static func ensureRequiredPermissions(completion: @escaping () -> Void) { ensureAccessibility { - ensureInputMonitoring { - completion() + completion() + if !isInputMonitoringGranted() { + _ = requestInputMonitoring() } } } @@ -38,7 +39,7 @@ public class Permissions { return } - NSLog("[SwitchFix] Permissions: Accessibility not granted, requesting access") + SwitchFixLog.permissions.notice("Permissions: Accessibility not granted, requesting access") NSApplication.shared.activate(ignoringOtherApps: true) requestAccessibility() openAccessibilitySettings() @@ -51,7 +52,7 @@ public class Permissions { return } - NSLog("[SwitchFix] Permissions: Input Monitoring not granted, requesting access") + SwitchFixLog.permissions.notice("Permissions: Input Monitoring not granted, requesting access") NSApplication.shared.activate(ignoringOtherApps: true) _ = requestInputMonitoring() openInputMonitoringSettings() @@ -78,7 +79,7 @@ public class Permissions { private static func pollForAccessibilityAccess(completion: @escaping () -> Void) { guard !isAccessibilityGranted() else { - NSLog("[SwitchFix] Permissions: Accessibility granted") + SwitchFixLog.permissions.info("Permissions: Accessibility granted") completion() return } @@ -89,7 +90,7 @@ public class Permissions { private static func pollForInputMonitoringAccess(completion: @escaping () -> Void) { guard !isInputMonitoringGranted() else { - NSLog("[SwitchFix] Permissions: Input Monitoring granted") + SwitchFixLog.permissions.info("Permissions: Input Monitoring granted") completion() return } diff --git a/Sources/Utils/SwitchFixLog.swift b/Sources/Utils/SwitchFixLog.swift new file mode 100644 index 0000000..d621d1f --- /dev/null +++ b/Sources/Utils/SwitchFixLog.swift @@ -0,0 +1,47 @@ +import Foundation +import os + +public enum SwitchFixLog { + public static let subsystem = "com.switchfix" + + public static let app = SwitchFixLogger(category: "app") + public static let monitor = SwitchFixLogger(category: "monitor") + public static let engine = SwitchFixLogger(category: "engine") + public static let state = SwitchFixLogger(category: "state") + public static let detector = SwitchFixLogger(category: "detector") + public static let corrector = SwitchFixLogger(category: "corrector") + public static let source = SwitchFixLogger(category: "source") + public static let permissions = SwitchFixLogger(category: "permissions") + public static let dictionary = SwitchFixLogger(category: "dictionary") + public static let preferences = SwitchFixLogger(category: "preferences") +} + +/// Every message is prefixed "[SwitchFix]" so it survives filtering with +/// `eventMessage CONTAINS "[SwitchFix]"`, and all dynamic values are logged +/// public so they are readable in Console.app and `log stream`. +public struct SwitchFixLogger { + private let logger: Logger + + init(category: String) { + self.logger = Logger(subsystem: SwitchFixLog.subsystem, category: category) + } + + /// Visible only with `log stream --level debug`. + public func debug(_ message: String) { + logger.debug("[SwitchFix] \(message, privacy: .public)") + } + + /// Visible only with `--level info` or higher verbosity. + public func info(_ message: String) { + logger.info("[SwitchFix] \(message, privacy: .public)") + } + + /// Default level: visible in plain `log stream` with no --level flag. + public func notice(_ message: String) { + logger.notice("[SwitchFix] \(message, privacy: .public)") + } + + public func error(_ message: String) { + logger.error("[SwitchFix] \(message, privacy: .public)") + } +} diff --git a/install.sh b/install.sh index 53ec2aa..6f17d59 100755 --- a/install.sh +++ b/install.sh @@ -64,12 +64,78 @@ echo "✅ SwitchFix has been added to your startup items." # 5. Handle Permissions echo "" -echo "🛡️ Step 4: Setting up macOS Permissions..." -echo "Because SwitchFix intercepts keyboard input to fix layouts, macOS requires you to grant it explicit permissions." -echo "" -read -p "Press [Enter] to begin the permission setup..." -./scripts/regrant-permissions.sh "/Applications/SwitchFix.app" +# Decide whether to walk through permission setup: +# - ad-hoc signed builds (no stable certificate) always need a fresh grant, +# - stable-signed builds keep their grants across rebuilds, except on a +# first install when no grant exists yet. +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +NEEDS_PERMISSION_SETUP=false +if [ ! -f "$SCRIPT_DIR/.codesign-identity" ]; then + NEEDS_PERMISSION_SETUP=true +else + read -p " Is this the first time SwitchFix is installed on this Mac? (y/N) " FIRST_INSTALL || FIRST_INSTALL="N" + if [[ "$FIRST_INSTALL" =~ ^[Yy]$ ]]; then + NEEDS_PERMISSION_SETUP=true + fi +fi + +if [ "$NEEDS_PERMISSION_SETUP" = true ]; then + echo "🛡️ Step 4: Setting up macOS Permissions..." + echo "SwitchFix intercepts keyboard input to fix layouts, so macOS requires you to grant it explicit permissions." + echo "" + + # Reset stale TCC cache for SwitchFix to avoid stale signature mismatches + tccutil reset Accessibility com.switchfix.app >/dev/null 2>&1 || true + tccutil reset ListenEvent com.switchfix.app >/dev/null 2>&1 || true + + # Open Accessibility settings & Finder + open "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" || true + open -R "/Applications/SwitchFix.app" + + cat < **Status**: ✅ Implementation-ready after council amendments +> **Priority**: High +> **Date created**: 2026-09-01 (Amended: 2026-09-01) +> **Target Module**: `Core`, `UI`, `SwitchFixApp`, `TestRunner` +> **Plan ID**: `004_per_app_default_language` + +--- + +## 1. Context & Motivation + +SwitchFix currently operates primarily as a reactive keyboard layout detector and text corrector. When users type in the wrong layout, SwitchFix automatically detects the mistake and fixes both the typed text and the active system keyboard layout. + +However, many users work across multilingual contexts where specific applications have distinct language expectations: +- **Messengers & Chat Apps** (e.g., Telegram, Slack, WhatsApp, Messages) are predominantly used in the user's native language (e.g., **Ukrainian**). +- **Browsers & Dev Tools** (e.g., Arc, Chrome, Terminal, VS Code) are predominantly used in **English**. +- **Email Clients & Note Apps** (e.g., Apple Mail, Obsidian, Notion) often follow specific language workflows. + +Currently, when users switch between Telegram and Arc, they must manually press hotkeys or rely on typing and auto-correction. Adding **Per-App Default Language** enables SwitchFix to automatically switch the macOS keyboard layout as soon as a target application gains focus, creating a seamless multilingual experience. + +--- + +## 2. Goals & Non-Goals + +### Goals +1. **Per-App Language Rules**: Allow users to assign a default keyboard layout (e.g., *English*, *Ukrainian*, *Russian*) to any supported installed application by its Bundle Identifier. Supported targets are applications that activate with `NSApplication.ActivationPolicy.regular`. +2. **Proactive Layout Switching**: When an application with a configured rule becomes frontmost, automatically switch macOS to that layout via `InputSourceManager`. +3. **Settings UI**: Add a clean, native SwiftUI configuration section in the Settings window with: + - List of configured apps showing App Icon, App Name, Bundle ID, and a Language Picker dropdown. + - Add button (`+`) with options: "Choose from Running Apps…" and "Choose from Applications Folder…". + - Remove button (`-`) to delete rules. + - Full scrollability to prevent clipping alongside the Excluded Apps section. +4. **Status Bar Menu Integration**: Provide a fast contextual submenu in the Menu Bar item to view or change the default language for the currently active app without opening Settings. +5. **Zero-Lag & Pipeline Safety**: Ensure automatic layout switching on app activation does not interfere with the zero-lag event tap, does not corrupt detector state, and does not trigger spurious text corrections. +6. **Persistence & Thread-Safety**: Persist all rules in `UserDefaults` with one lock covering the in-memory mutation and snapshot write (`OSAllocatedUnfairLock`); expose an injectable defaults store for isolated tests. + +### Non-Goals (for this phase) +1. Window-specific or tab-specific rules within the same application (macOS accessibility limits make per-app bundle ID the standard and robust scope). +2. Modifying native macOS input sources beyond existing supported layouts (`Layout.english`, `Layout.ukrainian`, `Layout.russian`). + +--- + +## 3. Architecture & Data Design + +``` ++-------------------------------------------------------------------------+ +| Settings UI | +| (SettingsView -> AppDefaultLanguagesView -> AppPickerView) | ++------------------------------------+------------------------------------+ + | + v (Modifies rules) ++------------------------------------+------------------------------------+ +| PerAppLanguageManager (Sources/Core) | +| - Thread-safe storage: OSAllocatedUnfairLock<[String: Layout]> | +| - Persistence: UserDefaults ("SwitchFix_perAppDefaultLanguages") | +| - Notifications: .perAppLanguageDidChange | ++------------------------------------+------------------------------------+ + | + v (Queried on app activation) ++------------------------------------+------------------------------------+ +| AppDelegate | +| - NSWorkspace.didActivateApplicationNotification | +| - Main-thread coordinator + (bundleID, PID) activation identity | +| - Context invalidation for every activation; rules only for .regular | +| - Checks: PerAppLanguageManager.shared.defaultLayout(for: bundleID) | +| - Correlated selection token rejects stale TIS notifications | ++------------------------------------+------------------------------------+ + | + v (If configured layout has no matching current source) ++------------------------------------+------------------------------------+ +| InputSourceManager | +| - TISSelectInputSource(preferredSources[targetLayout]) | +| - Coalesced, tokenized pending selection | +| - Mismatched/stale notifications do not consume current expectation | ++-------------------------------------------------------------------------+ +``` + +### 3.1 Data Model: `PerAppLanguageManager` + +**Location:** `Sources/Core/PerAppLanguageManager.swift` +*(Note: Placed in `Core` because `Layout` is defined in `Core`, preserving `Utils` as a zero-dependency foundational module).* + +The manager is the only owner of rule mutation. Production uses the singleton; tests use the public initializer with an isolated `UserDefaults(suiteName:)` store. + +Required behavior: + +- Parse `UserDefaults.dictionary(forKey:)` as `[String: Any]`; preserve valid string/layout entries even when other entries have invalid types or raw values. +- Ignore empty bundle identifiers and no-op when a write would not change the current rule. +- Mutate state and write the complete `[String: String]` snapshot under the same lock. Post notifications after releasing the lock, on the main thread. +- Keep rules for uninstalled apps so reinstalling the same bundle can recover the rule. A missing layout is retained but treated as unavailable by the UI and activation path. + +```swift +import Foundation +import os + +public final class PerAppLanguageManager { + public static let shared = PerAppLanguageManager(defaults: .standard) + + private let defaults: UserDefaults + private static let storageKey = "SwitchFix_perAppDefaultLanguages" + + private struct State { + /// Map of bundle identifier -> Layout + var rules: [String: Layout] + } + + private let state: OSAllocatedUnfairLock + + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + let rawDict = defaults.dictionary(forKey: Self.storageKey) ?? [:] + let initialRules = rawDict.compactMapValues { value in + (value as? String).flatMap(Layout.init(rawValue:)) + } + self.state = OSAllocatedUnfairLock(initialState: State(rules: initialRules)) + } + + public func defaultLayout(for bundleID: String) -> Layout? { + state.withLock { $0.rules[bundleID] } + } + + public func setDefaultLayout(_ layout: Layout?, for bundleID: String) { + guard !bundleID.isEmpty else { return } + let changed = state.withLock { state -> Bool in + let oldLayout = state.rules[bundleID] + if let layout = layout { + state.rules[bundleID] = layout + } else { + state.rules.removeValue(forKey: bundleID) + } + let newLayout = state.rules[bundleID] + guard oldLayout != newLayout else { return false } + defaults.set(state.rules.mapValues(\.rawValue), forKey: Self.storageKey) + return true + } + guard changed else { return } + let post = { + NotificationCenter.default.post(name: .perAppLanguageDidChange, object: nil) + } + if Thread.isMainThread { + post() + } else { + DispatchQueue.main.async(execute: post) + } + } + + public func removeDefaultLayout(for bundleID: String) { + setDefaultLayout(nil, for: bundleID) + } + + public var allRules: [(bundleID: String, layout: Layout)] { + state.withLock { + $0.rules.map { (bundleID: $0.key, layout: $0.value) } + .sorted { $0.bundleID.localizedCaseInsensitiveCompare($1.bundleID) == .orderedAscending } + } + } + +} + +public extension Notification.Name { + static let perAppLanguageDidChange = Notification.Name("SwitchFix_PerAppLanguageDidChange") +} +``` + +### 3.2 App Activation Flow (`AppDelegate.swift`) + +All activation handling and TIS selection requests run on the main thread. Add a small, testable activation coordinator (in `Core`) that accepts an `ActivationIdentity` (`bundleID`, PID, and `isRegular`) and returns whether to apply a rule. It tracks the last observed identity, not only a bundle ID: a relaunch with a new PID is a new activation, while same-process window changes do not reset the user's layout. This is app-level retention; the plan does not claim to identify individual windows. + +Use this exact sequence for both startup and `NSWorkspace.didActivateApplicationNotification`: + +1. Read the application identity and increment an activation generation. Update the last observed identity even for non-regular apps, overlays, and SwitchFix itself. +2. Refresh the current input source and immediately replace `CaptureStateStore` context with the new PID, current layout, current source ID, and unknown focus. This invalidates stale detector/correction state for every activation; skipping a default rule must never skip context invalidation. +3. Queue `InputEngine.updateContext` before any selection request. Events captured against the old epoch are rejected by the existing stale-context guard. +4. For a regular, non-SwitchFix app with a rule, compare the actual current source ID against `InputSourceManager`'s layout match/preferred-source contract. Do not compare only `Layout`, because unsupported sources currently fall back to `.english`. +5. If a switch is needed, call a new tokenized `InputSourceManager.requestSwitch(to: activationGeneration:)` API on the main thread. The manager coalesces to the newest request and invokes callbacks with the token, target source ID, and result. +6. `willSelect` may publish a generated-layout transition only if its token still matches the current activation generation and PID. A delayed selection from an earlier app is ignored/reconciled, never applied to the new app. +7. On a matching TIS notification, update the context to the confirmed source and call `handleGeneratedLayoutContext`. On failure or a stale/mismatched notification, refresh the actual source, update context, clear the pending expectation, and do not invoke layout-switch autocorrection. + +`InputSourceManager` must return an explicit `.matched`, `.superseded`, or `.manual` result when observing a source notification. A mismatched source must never silently consume the expectation and then be reported as manual: it either remains pending until the matching source arrives or is atomically cancelled as superseded, with its token returned so the app delegate can reconcile without correction. The API must make it impossible for two rapid requests to overwrite a token without the observer being able to distinguish the stale notification. + +At launch, initialize the identity and process the already-frontmost application through the same routine after the initial context is created. This covers a configured app that was frontmost before SwitchFix launched. + +--- + +## 4. UI / UX Design + +### 4.1 Settings View Integration + +A new dedicated section **"App Default Languages"** will be added to `SettingsView.swift` above the "Excluded Apps" section. + +#### Visual Layout Specification: + +``` ++-----------------------------------------------------------------------+ +| App Default Languages | +| Automatically switch keyboard layout when an app becomes active. | +| | +| +-----------------------------------------------------------------+ | +| | [Icon] Telegram [ Ukrainian v ] | | +| | ru.keepcoder.Telegram | | +| |-----------------------------------------------------------------| | +| | [Icon] Arc [ English v ] | | +| | company.thebrowser.Browser | | +| |-----------------------------------------------------------------| | +| | [Icon] Mail [ English v ] | | +| | com.apple.mail | | +| +-----------------------------------------------------------------+ | +| | [ + v ] | [ - ] | | +| +-----------------------------------------------------------------+ | ++-----------------------------------------------------------------------+ +``` + +#### Key UI Elements: +1. **List of App Rules**: + - **App Icon**: 18x18 icon retrieved via `NSWorkspace.shared.icon(forFile:)` or fallback to `NSRunningApplication.icon`. + - **App Display Name**: `FileManager.default.displayName` or `NSRunningApplication.localizedName`. + - **Bundle Identifier**: Displayed in `caption2` style under the app name. + - **Language Picker**: Dropdown menu inline with each row listing available installed layouts (`English`, `Ukrainian`, `Russian`). Changing the picker updates the rule immediately. +2. **Toolbar (`+` / `-`)**: + - **`+` Button (Menu)**: + - `Choose from Running Apps…`: Opens reusable `AppPickerView` sheet. + - `Choose from Applications Folder…`: Opens `NSOpenPanel` defaulting to `/Applications`. + - **Default Heuristic**: Use the current active layout when it is non-English and installed; otherwise prefer installed Ukrainian, then Russian, then English. This is deterministic and does not invent a new preference. + - **Duplicate apps**: Existing rules are preserved; configured bundle IDs are excluded from both pickers and duplicate filesystem selections are ignored. + - **`-` Button**: Enabled when a row is selected; removes the rule for that app. +3. **Window Size & Scrolling**: + - Configure `SettingsWindowController` to `width: 480, height: 720`, with a `minSize` height of 600. + - Wrap the settings content in one outer `ScrollView`; replace the fixed-height nested `List` controls with bounded `ScrollView`/`LazyVStack` sections so the window has one predictable scroll owner and never clips populated sections. +4. **Reusable `AppPickerView` Sheet**: + - Refactor the existing running app picker into a shared `AppPickerView` that receives app records, `excludedBundleIDs: Set`, a title/empty-state string, and an `onSelect: (Set) -> Void` callback. It owns selection and dismissal; the parent owns persistence. + +Rules with an uninstalled app remain visible using the bundle ID as the name and a placeholder icon. Rules whose layout is no longer installed remain visible as `Unavailable`, do not trigger a switch, and become active automatically if that layout is later installed. The row picker lists only `InputSourceManager.availableLayouts()`. + +### 4.2 Status Bar Menu Quick Toggle + +In `StatusBarController.swift`, add a contextual item for the last regular frontmost app: +- Menu Item: `Default Language (Telegram)` -> Submenu: + - `None (Keep Active Layout)` (checked if no rule) + - `✓ Ukrainian` + - `English` + - `Russian` +- `menuWillOpen` snapshots bundle ID, PID, and display name before the status menu can make SwitchFix frontmost. Menu actions use that snapshot, never a fresh unvalidated `frontmostApplication` lookup. +- Selecting an item immediately updates `PerAppLanguageManager`. `None` only removes the rule and never changes the current layout. A language selection switches immediately only if the snapshot app is still frontmost; if the app changed while the menu was open, persist the rule but skip the immediate switch. +- Show only installed layouts and disable the submenu with a clear message when there is no eligible frontmost app. + +--- + +## 5. Edge Cases & Robustness + +| Edge Case | Impact | Mitigation / Solution | +|---|---|---| +| **Intra-App Window Switch (`⌘ + \`` / Chat switch)** | User in Telegram manually switches to EN to type code, then clicks another Telegram window | Track `(bundleID, PID)` identity. Same-process activation does not reapply the rule; individual windows are intentionally out of scope. | +| **System Overlays & Daemons (Spotlight, Lock Screen)** | An ineligible app activates while the old app has buffered state | Always replace/invalidate capture context; skip only the default-layout action for non-regular apps. Update the observed identity so returning to the configured app is not suppressed. | +| **SwitchFix Self-Activation** | User opens SwitchFix Settings or clicks the menu bar | Invalidate context as usual, but never apply a rule to SwitchFix. The status menu uses its saved last-regular-app snapshot. | +| **Rapid App Switching (`⌘ + Tab` cycling)** | Multiple layout switches produce delayed/out-of-order TIS notifications | Serialize activation on main, coalesce to the latest tokenized request, return explicit mismatch/superseded results, and ignore/reconcile stale notifications. | +| **macOS Native "Switch to document source"** | macOS fires `kTISNotifySelectedKeyboardInputSourceChanged` after app activation | Match notifications by selection token and target source ID. Only a matching notification is generated; manual or stale notifications update actual state without correction. | +| **Unsupported current input source** | Current source is not in `Layout.allCases` but fallback mapping reports English | Compare `currentInputSourceID` against `Layout.matches(sourceID:)`; select the configured layout's preferred source when the source is unsupported. | +| **Excluded App with Default Language** | App is blacklisted from autocorrection (e.g. Terminal) but user wants English by default | Supported seamlessly. App filtering (correction disabled) and language switching are orthogonal. | +| **Corrupted / Invalid Layout in Storage** | Unknown string or non-string value in `UserDefaults` | Parse each value independently; preserve valid entries and drop only invalid values without throwing or crashing. | +| **Uninstalled / Missing Layout** | User configures Ukrainian, but later uninstalls the Ukrainian layout from macOS Settings | Retain the rule, show `Unavailable`, skip selection, and log a notice. Refresh installed sources at the normal readiness/startup boundary. | +| **Accidental Correction on App Switch** | Switching layout triggers a TIS notification while a word/correction is pending | New app context increments the epoch and resets detector state; stale queued correction requests are cancelled. A generated transition never enters layout-switch autocorrection. | +| **Persistence race** | Settings and status menu write different rules concurrently | Mutate and persist one complete snapshot inside the same lock; test concurrent writers with an isolated defaults suite. | +| **Menu target changes** | User opens the menu for Telegram, then activates another app before selecting a language | Persist the Telegram rule, but only switch immediately if the captured Telegram PID/bundle is still frontmost. | + +--- + +## 6. Implementation Steps (Phased) + +Each phase must leave the repository buildable. Keep TIS calls and all activation state transitions on the main thread; keep rule reads lock-bounded and free of AppKit dependencies. + +### Phase 1: Data Model & Persistence +- [ ] Create `Sources/Core/PerAppLanguageManager.swift` with `public init(defaults:)` and the production singleton. +- [ ] Implement `[String: String]` serialization under `SwitchFix_perAppDefaultLanguages`; parse mixed/invalid stored values individually. +- [ ] Add CRUD accessors and sorted `allRules`; ignore empty bundle IDs and suppress no-op notifications. +- [ ] Perform the state mutation and complete snapshot write under one `OSAllocatedUnfairLock` critical section to prevent lost updates. +- [ ] Deliver `.perAppLanguageDidChange` on the main thread after unlocking. + +### Phase 2: Testable Activation and Selection Contracts +- [ ] Add a pure `ActivationIdentity`/activation coordinator in `Sources/Core` that tracks `(bundleID, PID, isRegular)` and an activation generation. Same PID/bundle does not reapply a rule; every observed activation still invalidates context. +- [ ] Add a selection port/protocol or equivalent seam so tests can model success, failure, delayed notifications, mismatches, and coalescing without calling Carbon/TIS. +- [ ] Extend `InputSourceManager` with an atomic tokenized/coalesced request API. A mismatched notification must not clear the current expectation; stale tokens must be distinguishable from manual changes. +- [ ] Add an explicit `isCurrentSource(_:)`/preferred-source contract based on source IDs and `Layout.matches(sourceID:)`. + +### Phase 3: App Activation Integration +- [ ] Refactor `activeApplicationChanged(_:)` into the sequence defined in section 3.2: observe identity, replace context, queue engine reset, then request a guarded selection. +- [ ] Reuse the same activation routine for the initial frontmost app during launch. +- [ ] Apply rules only to non-SwitchFix regular applications; context invalidation must still occur for all activation notifications. +- [ ] Gate `willSelect`, confirmation, and failure callbacks by activation generation/PID; stale callbacks only reconcile actual input state and never trigger correction. +- [ ] Verify failed/missing source selection leaves `CaptureStateStore` and `InputEngine` synchronized with the actual source. + +### Phase 4: Settings UI & Component Refactoring +- [ ] Introduce a shared app-record type and refactor `RunningAppPickerView` into `AppPickerView` with parent-owned persistence and callback-based selection. +- [ ] Create `AppDefaultLanguagesView` and `AppLanguageRow`; show icon/name/bundle ID, installed-layout picker, unavailable-layout state, and duplicate suppression. +- [ ] Filter running and filesystem choices to supported regular applications, excluding SwitchFix; reject or explain unsupported bundles. +- [ ] Implement the deterministic default heuristic from section 4.1 and immediate persistence for add/change/remove. +- [ ] Replace nested fixed-height `List`s with bounded `LazyVStack` sections inside one outer settings `ScrollView`; set the window size/minimum from section 4.1. + +### Phase 5: Menu Bar Quick-Access +- [ ] Add the contextual default-language submenu and rebuild it from the last regular frontmost `(bundleID, PID, name)` snapshot. +- [ ] Capture the snapshot when the menu opens; use it as the action target and validate that the same app is still frontmost before switching. +- [ ] Persist `None` as rule removal without changing the current layout; persist a language selection even if the app changed, but skip immediate switching in that case. +- [ ] Observe `.perAppLanguageDidChange` or rebuild on open so Settings/menu changes are reflected without stale checkmarks. + +### Phase 6: Automated and Manual Verification +- [ ] Extend `Sources/TestRunner/main.swift` with isolated-defaults tests for CRUD, reload, mixed corruption, empty IDs, no-op writes, and concurrent writers. +- [ ] Test the pure activation coordinator with startup, regular/unconfigured, non-regular, self-activation, same-PID repeat, and relaunch/new-PID cases. +- [ ] Test the fake selection port with rapid A→B→A, delayed/out-of-order notifications, mismatches, failures, and stale callback rejection. +- [ ] Run `swift build`, `swift run TestRunner`, and `swift run InputPipelineTestRunner`. +- [ ] Manually test Telegram/Arc or equivalent regular apps, immediate typing around activation, manual in-app layout retention, missing layouts, menu target changes, and Excluded Apps coexistence. + +--- + +## 7. Verification & Acceptance Criteria + +1. **Configured regular app**: Activating a configured app selects the preferred installed source for its configured layout once, and the confirmed source/layout is reflected in `CaptureStateStore` and `InputEngine`. +2. **Startup**: Launching SwitchFix while a configured app is already frontmost applies that rule through the same activation path. +3. **Intra-app retention**: Same-process window changes do not reapply the rule; a relaunch with a new PID does. +4. **Unconfigured/ineligible apps**: Context is invalidated on every activation, but an app without a rule or with a non-regular policy does not cause a default-layout selection. +5. **Selection races**: Delayed, mismatched, or stale TIS notifications cannot update the wrong app, consume the latest expectation, or trigger layout-switch autocorrection. +6. **Source identity**: An unsupported current input source is not mistaken for a matching English source; equivalent supported variants are not switched unnecessarily. +7. **Settings persistence**: Rules added/changed/removed in Settings persist across restarts; valid entries survive mixed corrupted defaults; concurrent writes lose no rules. +8. **UI behavior**: Installed layouts, unavailable rules, duplicate app selection, one-owner scrolling, and missing app icons/names behave as specified. +9. **Status menu safety**: The menu edits the app captured at open time; it does not switch a different app if focus changes while the menu is open; `None` leaves the current layout unchanged. +10. **Pipeline safety**: Keystrokes around app activation are not delayed, duplicated, or corrected from a stale buffer. +11. **Automated verification**: `swift build`, `swift run TestRunner`, and `swift run InputPipelineTestRunner` pass with 0 failures. diff --git a/scripts/Install_SwitchFix.command b/scripts/Install_SwitchFix.command index 6a1275b..1e89c35 100755 --- a/scripts/Install_SwitchFix.command +++ b/scripts/Install_SwitchFix.command @@ -44,6 +44,9 @@ echo "" echo "🛡️ Setting up macOS Permissions..." echo "macOS requires explicit permissions for SwitchFix to intercept keyboard input." echo "" +echo "⚠️ If System Settings is currently open, CLOSE IT before proceeding." +echo " (This prevents a known macOS crash in the Privacy & Security pane.)" +echo "" read -p "Press [Enter] to begin..." echo "" diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 3961cb4..47cc33a 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -113,17 +113,46 @@ if [ -d "$PRODUCTS_DIR/SwitchFix_Dictionary.bundle" ]; then fi # Code sign -# Prefer a stable signing identity (set via SWITCHFIX_CODESIGN_IDENTITY) so -# macOS TCC permissions survive across rebuilds. -if [ -n "${SWITCHFIX_CODESIGN_IDENTITY:-}" ]; then - echo "Signing with identity: $SWITCHFIX_CODESIGN_IDENTITY" - codesign --force --deep --sign "$SWITCHFIX_CODESIGN_IDENTITY" "$APP_BUNDLE" +# Prefer a stable signing identity so macOS TCC permissions (Accessibility, +# Input Monitoring) survive across rebuilds. Resolution order: +# 1. SWITCHFIX_CODESIGN_IDENTITY env var (explicit override) +# 2. .codesign-identity file (created by scripts/setup-codesign.sh) +# 3. Ad-hoc signing (last resort — permissions break every rebuild) +IDENTITY="${SWITCHFIX_CODESIGN_IDENTITY:-}" +IDENTITY_FILE="$PROJECT_DIR/.codesign-identity" + +if [ -z "$IDENTITY" ] && [ -f "$IDENTITY_FILE" ]; then + IDENTITY="$(cat "$IDENTITY_FILE")" + # Verify the identity still exists in the keychain + if ! security find-identity -v -p codesigning 2>/dev/null | grep -qF "$IDENTITY"; then + echo "WARNING: Certificate \"$IDENTITY\" from .codesign-identity not found in keychain." + echo " Run scripts/setup-codesign.sh to recreate it." + IDENTITY="" + fi +fi + +if [ -n "$IDENTITY" ]; then + echo "Signing with identity: $IDENTITY" + codesign --force --deep --sign "$IDENTITY" "$APP_BUNDLE" else echo "Signing with ad-hoc identity..." codesign --force --deep --sign - "$APP_BUNDLE" - echo "WARNING: ad-hoc signature changes on each rebuild." - echo " Accessibility/Input Monitoring may need to be granted again." - echo " Use scripts/regrant-permissions.sh after rebuilding." + echo "" + echo "WARNING: Ad-hoc signature changes on each rebuild." + echo " Accessibility/Input Monitoring permissions will break." + echo "" + echo " ➜ Run scripts/setup-codesign.sh to create a stable certificate." + echo " This is a one-time setup that eliminates the re-grant cycle." + echo "" +fi + +# Unregister dist/SwitchFix.app from LaunchServices to avoid dual-registration +# with /Applications/SwitchFix.app. Two registrations for the same bundle ID +# cause ambiguous TCC resolution and can trigger a crash in Apple's +# SecurityPrivacyExtension when listing apps in Privacy & Security. +LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister" +if [ -x "$LSREGISTER" ] && [ -d "/Applications/SwitchFix.app" ]; then + "$LSREGISTER" -u "$APP_BUNDLE" 2>/dev/null || true fi echo "" diff --git a/scripts/cleanup-tcc.sh b/scripts/cleanup-tcc.sh new file mode 100755 index 0000000..fd5033c --- /dev/null +++ b/scripts/cleanup-tcc.sh @@ -0,0 +1,42 @@ +#!/bin/bash +set -euo pipefail + +# Keep SwitchFix's own LaunchServices registration consistent. +# +# When both dist/SwitchFix.app (a local build) and /Applications/SwitchFix.app +# exist, LaunchServices may resolve com.switchfix.app to the dist copy, which +# confuses TCC and can crash Apple's SecurityPrivacyExtension when the +# Privacy & Security pane loads. This script unregisters the dist copy so +# only the installed app remains registered. +# +# Only com.switchfix.app is touched. We deliberately do NOT reset TCC entries +# for other applications: `tccutil reset` would revoke live permissions for +# apps that are still installed, and macOS cleans up orphaned TCC records for +# apps you uninstalled automatically (or they remain inert). + +echo "Cleaning stale LaunchServices registrations..." +echo "" + +CLEANED=0 + +# ── Unregister dist/SwitchFix.app if /Applications copy exists ─────────────── + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +DIST_APP="$PROJECT_DIR/dist/SwitchFix.app" +LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister" + +if [ -x "$LSREGISTER" ] && [ -d "$DIST_APP" ] && [ -d "/Applications/SwitchFix.app" ]; then + echo "" + echo "Unregistering dist/SwitchFix.app from LaunchServices..." + "$LSREGISTER" -u "$DIST_APP" 2>/dev/null || true + echo " ✓ Only /Applications/SwitchFix.app is now registered" + CLEANED=$((CLEANED + 1)) +fi + +echo "" +if [ "$CLEANED" -gt 0 ]; then + echo "✅ Cleaned $CLEANED stale registration(s)." +else + echo "✅ No stale registrations found — LaunchServices is clean." +fi diff --git a/scripts/regrant-permissions.sh b/scripts/regrant-permissions.sh index 0f0bdbd..622c0a0 100755 --- a/scripts/regrant-permissions.sh +++ b/scripts/regrant-permissions.sh @@ -1,10 +1,50 @@ #!/bin/bash set -euo pipefail +# Re-grant TCC permissions after an ad-hoc rebuild. +# +# ⚠️ THIS SCRIPT IS A WORKAROUND, NOT THE FIX. +# Opening the Privacy & Security pane while TCC entries are stale can crash +# Apple's SecurityPrivacyExtension (SIGSEGV in objc_release during +# swift_arrayDestroy — a use-after-free in Apple's code). +# +# The real fix: run scripts/setup-codesign.sh ONCE to create a stable +# code-signing certificate. After that, permissions survive rebuilds +# and you never need this script again. + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" APP_BUNDLE="${1:-$PROJECT_DIR/dist/SwitchFix.app}" BUNDLE_ID="${2:-com.switchfix.app}" +IDENTITY_FILE="$PROJECT_DIR/.codesign-identity" + +# ── Suggest the proper fix ─────────────────────────────────────────────────── + +if [ -f "$IDENTITY_FILE" ]; then + IDENTITY="$(cat "$IDENTITY_FILE")" + echo "✅ You have a code-signing certificate configured: \"$IDENTITY\"" + echo " If you just rebuilt with build-app.sh, permissions should still work." + echo " You probably don't need to re-grant." + echo "" + read -p "Continue anyway? (y/N) " CONTINUE + if [[ ! "$CONTINUE" =~ ^[Yy]$ ]]; then + exit 0 + fi +else + echo "╔══════════════════════════════════════════════════════════════════╗" + echo "║ ⚠️ You're using ad-hoc signing — permissions break every build ║" + echo "║ ║" + echo "║ Run scripts/setup-codesign.sh to create a stable certificate. ║" + echo "║ This is a ONE-TIME setup that eliminates this re-grant cycle. ║" + echo "╚══════════════════════════════════════════════════════════════════╝" + echo "" +fi + +# ── Clean stale entries first to reduce pane crash risk ────────────────────── + +echo "Cleaning stale TCC entries to reduce crash risk..." +"$SCRIPT_DIR/cleanup-tcc.sh" 2>/dev/null || true +echo "" echo "Stopping running SwitchFix..." pkill -x SwitchFixApp || true @@ -13,6 +53,15 @@ echo "Resetting TCC permissions for $BUNDLE_ID..." tccutil reset Accessibility "$BUNDLE_ID" || true tccutil reset ListenEvent "$BUNDLE_ID" || true +echo "" +echo "╔══════════════════════════════════════════════════════════════════╗" +echo "║ IMPORTANT: Close any Privacy & Security windows BEFORE ║" +echo "║ proceeding. Opening the pane while entries are being ║" +echo "║ modified can crash Apple's SecurityPrivacyExtension. ║" +echo "╚══════════════════════════════════════════════════════════════════╝" +echo "" +read -p "Press [Enter] when you've closed System Settings... " + echo "Opening Privacy settings (Accessibility)..." open "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" || true @@ -21,19 +70,14 @@ open -R "$APP_BUNDLE" cat < "$IDENTITY_FILE" + echo " Saved to .codesign-identity — build-app.sh will use it automatically." + exit 0 +fi + +# ── Create the certificate via Keychain Access certificate assistant ───────── +# +# macOS has no reliable CLI path to create code-signing certificates: +# `security` creates key pairs (not certificates), and `certtool c` is +# interactive-only, takes a destination keychain via k= (not a config +# file), and its self-signed certs don't satisfy the codesigning policy. +# The only reliable method is the Certificate Assistant built into +# Keychain Access, so we guide the user through it. + +echo "Creating self-signed code-signing certificate: \"$CERT_NAME\"" +echo "" +echo "A Keychain Access window will open — follow the 5 steps below." +echo "" + +if ! security find-identity -v -p codesigning 2>/dev/null | grep -qF "$CERT_NAME"; then + echo "Creating via Keychain Access (this takes 10 seconds)..." + echo "" + + # Open Keychain Access and guide the user + open -a "Keychain Access" + sleep 1 + + cat </dev/null | grep -qF "$CERT_NAME"; then + echo "" + echo "❌ Certificate \"$CERT_NAME\" not found." + echo " Please verify you followed the steps above, or check:" + echo " security find-identity -v -p codesigning" + exit 1 + fi +fi + +# ── Save and confirm ──────────────────────────────────────────────────────── + +echo "" +echo "✅ Certificate \"$CERT_NAME\" is ready for code signing." +echo "$CERT_NAME" > "$IDENTITY_FILE" +echo " Saved to .codesign-identity — build-app.sh will use it automatically." +echo "" +echo " TCC permissions will now survive rebuilds. No more regrant-permissions.sh!"