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
2 changes: 1 addition & 1 deletion .github/workflows/ocr-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,16 @@ DerivedData/
.vscode/
.claude/settings.local.json
.grepai/
.codesign-identity

# Generated dictionary artifacts
.build/dictionary-bin/
plan/artifacts/*.json
plan/artifacts/*.txt
scripts/__pycache__/
Sources/Dictionary/Resources/uk_full.txt

# Local / temporary files and tooling
.pi/
temp.txt
*.key
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ let package = Package(
),
.target(
name: "Dictionary",
dependencies: [],
dependencies: ["Utils"],
path: "Sources/Dictionary",
exclude: ["Resources/uk_full.txt"],
resources: [
Expand Down
51 changes: 35 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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))
```
Expand Down
61 changes: 47 additions & 14 deletions Sources/Core/InputEngine.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import os
import Utils

public struct DetectionRequest: Equatable {
public let word: String
Expand Down Expand Up @@ -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
Expand All @@ -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
}

Expand All @@ -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))")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · low
Dropping privacy: .public means dynamic fields like reason render as <private> in release builds, so field diagnostics collected from users will be redacted exactly where the new unified-debug-logging effort needs visibility. It also diverges from the SwitchFixLog convention used elsewhere in this change, which marks all values public. Annotate non-sensitive diagnostic enums/values with privacy: .public.

Suggestion:

Suggested change
logger.debug("buffer invalidated reason=\(String(describing: reason))")
logger.debug("buffer invalidated reason=\(String(describing: reason), privacy: .public)")

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,
Expand All @@ -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(
Expand Down Expand Up @@ -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)"
)
Comment on lines +353 to +356

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · high
User-typed words now flow into durable logs: SwitchFixLogger marks its entire message privacy: .public (readable in Console.app/log stream per its own doc comment) and .notice entries are persisted to the unified logging store on disk. For a keyboard-monitoring tool, this durably records typed text — search queries, message drafts, even credentials entered into fields not detected as secure — on user machines. Prefer keeping the notice-level line free of word content (shape/timing only) and emitting the actual words only at .debug (memory-only) behind an explicit opt-in.

Suggestion:

Suggested change
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)"
)
if let result {
SwitchFixLog.detector.notice(
"detect source=\(result.sourceLayout.rawValue) target=\(result.targetLayout.rawValue) switch=\(result.shouldSwitchLayout) ms=\(Double(duration) / 1_000_000.0)"
)
SwitchFixLog.detector.debug(
"detect '\(result.originalWord)' -> '\(result.convertedWord)'"
)

} 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)
Expand All @@ -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
}
Comment on lines +389 to 392

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
cancelReason! is currently unreachable-failure because the preceding chain guarantees non-nil, but the invariant is purely positional: reordering branches or editing the guard later turns this into a runtime crash on a user-facing correction path. Prefer optional binding over force unwrap here.

Suggestion:

Suggested change
guard cancelReason == nil else {
logger.debug("correction cancelled reason=\(cancelReason!) word='\(result.originalWord)'")
return
}
if let cancelReason {
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,
Expand All @@ -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 {
Expand Down
8 changes: 6 additions & 2 deletions Sources/Core/InputSourceManager.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Carbon
import Foundation
import os
import Utils

public final class InputSourceManager {
public static let shared = InputSourceManager()
Expand Down Expand Up @@ -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
}

Expand All @@ -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
}
Expand Down
8 changes: 8 additions & 0 deletions Sources/Core/InputStateMachine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 5 additions & 7 deletions Sources/Core/KeyboardMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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,
Expand Down
Loading