This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
⌘IME (Command IME) is a lightweight macOS menu bar app that switches between alphanumeric and kana input using left/right Command keys, with fully configurable key mappings.
Version: Defined in manifest.toml → [project].version (source of truth)
Language: Swift (Swift Package Manager)
Target: macOS 13.0+, arm64
All commands run from apps/cmd-ime-swift/:
cd apps/cmd-ime-swift
# Build debug
swift build
# Build release
swift build -c release
# Run all tests
swift test
# Run a single test class
swift test --filter KeyEventTests
# Run a single test method
swift test --filter KeyEventTests/testKeyDown_RemapsEvent_WhenMappingExists
# Lint (requires swiftlint installed)
swiftlint
# Create release .app bundle (reads version from manifest.toml)
CMDIME_BUILD_MODE=local ./scripts/package.shCGEvent tap (system-wide)
└── KeyEvent.eventCallback()
├── MediaKeyEvent? → mediaKeyDown/Up
├── flagsChanged → modifierKeyDown/Up (tracks lone-modifier gestures)
├── keyDown → keyDown → convertedEvent()
└── keyUp → keyUp → convertedEvent()
└── findMapping() → shortcutList[keyCode]
├── passThrough (no mapping)
├── disable (output keyCode == 999)
└── remap (mutate CGEvent fields)
KeyEvent reads only from global caches (shortcutList, exclusionAppsDict) in the hot path. These caches are written by AppSettings on the main thread whenever settings change.
AppSettings (singleton, @MainActor) is the single source of truth for user preferences. It:
- Wraps
UserDefaultsand publishes@Publishedproperties via Combine - Keeps the legacy globals (
keyMappingList,shortcutList,exclusionAppsDictinKeyMappings.swift/KeyEvent.swift) in sync - Handles UserDefaults key migration from legacy typos (
lunchAtStartup→launchAtStartup) - Manages
SMAppServicefor login item registration
SwiftUI settings views (Settings/) observe AppSettings.shared directly.
KeyMappings.swift declares module-level globals that KeyEvent reads on every keystroke:
| Global | Written by | Read by |
|---|---|---|
keyMappingList |
AppSettings.$keyMappings sink |
keyMappingListToShortcutList() |
shortcutList |
keyMappingListToShortcutList() |
KeyEvent.findMapping() |
exclusionAppsDict |
AppSettings.$exclusionApps sink |
KeyEvent.eventCallback() |
activeAppsList |
KeyEvent.setActiveApp() |
ExclusionsSettingsView (app picker) |
isRecordingShortcut |
KeyRecorderSheet |
KeyEvent.eventCallback() |
- keyCode 999 — sentinel meaning "disable / swallow this key"
- keyCode 1000+ — media keys (actual media keyCode + 1000)
- keyCode 54 — Right Command, 55 — Left Command
- keyCode 102 — Eisu (alphanumeric), 104 — Kana
The tap can be disabled by macOS (e.g., on input-source change). KeyEvent handles this with:
- A 5-second heartbeat timer (
tapHeartbeat) that re-enables the tap if found disabled - Retry logic (up to 30 attempts, 1s apart) when
CGEvent.tapCreatereturns nil post-Accessibility grant BundleWatcher— watches the main executable fordelete/renameevents to detectbrew upgradereplacing the bundle
- Update
manifest.toml→[project].version - Open a PR and merge to
main→ CI (release.yml) triggers on PR merge and detects the new version tag does not exist - CI runs
scripts/package.sh(builds, signs, bundlesCmdIME.app) - Creates DMG, optionally notarizes, creates GitHub Release
- Appends the release entry to
appcast.xmland force-pushes it to the dedicatedappcastbranch - Updates
agiletec-inc/homebrew-tapCasks/cmd-ime.rb via squash-merged PR
The Sparkle auto-update feed lives on the dedicated appcast branch, served via its
raw URL (SUFeedURL in Info.plist). Each release appends an <item>; the in-repo
appcast.xml on main is only the empty seed template.