feat(capslock): HID-based short/long-press parity with macOS native CapsLock toggle#12
Closed
hiking90 wants to merge 4 commits into
Closed
feat(capslock): HID-based short/long-press parity with macOS native CapsLock toggle#12hiking90 wants to merge 4 commits into
hiking90 wants to merge 4 commits into
Conversation
…capsLock When CapsLock is configured as the Korean/English toggle key, the IOKit alpha-lock state can briefly remain ON between the physical press and CapsLockSync.forceOff() taking effect. A keyDown arriving in that race window carries .maskAlphaShift, and in English passthrough mode the event reaches the focused app as-is — the first English character after a Korean→English toggle is uppercased, then later keys auto-correct. This is the symptom reported in #10. Strip .maskAlphaShift directly from the event in the CGEventTap callback right after forceOff(). The tap runs before HIToolbox/IME/app, so both the IMK path and the English passthrough path see the corrected flags regardless of forceOff()'s IOKit propagation latency. event.keyboardGetUnicodeString() also picks up the stripped flags, keeping the focus-steal recorded character consistent with what the app receives. Scope is unchanged: only active when toggleKey == .capsLock and maskAlphaShift is set. The flagsChanged CapsLock branch is intentionally left as passthrough so forceOff()'s OFF correction remains visible to downstream state tracking. This is defensive hardening, not a definitive fix for #10. The reported case is environment-dependent (not reproducible on a clean maintainer setup) and likely involves coexisting low-level keyboard monitors (e.g. SokIM) or macOS 26 Tahoe-specific timing. A planned HID-based CapsLock handler will need to gate this strip on a future realCapsLockActive flag. Verified: swiftc -typecheck of all sources passes with no warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ration)
User-facing capslock.md was telling users to "disable CapsLock delay in
System Settings → Keyboard" — but no such UI toggle exists in macOS
(Sequoia/Tahoe). The only legitimate path is the `hidutil` command
`hidutil property --set '{"CapsLockDelayOverride":0}'` from Terminal,
which doesn't persist across reboots (LaunchAgent needed for that).
Replace the misleading instruction with the actual `hidutil` command +
note on persistence. Also clarify the "Use Caps Lock to switch to and
from ABC" disable location (System Settings → Keyboard → Input Sources
→ Edit…) and reframe the LED behavior to match doc 30's planned SET
semantics (LED ON = Korean, LED OFF = English) — implemented in the
follow-up commit on this branch.
Adds design/32-hid-capslock-press-duration.md (design v2) — covers
HID-based CapsLock press-duration detection for macOS platform parity
("short tap = language switch, long press = uppercase", per the Apple
official UI text "Press and hold to enable typing in all uppercase").
This is a forward-looking design that gates on a Phase 1.5 spike
verifying that IOHIDSetModifierLockState(true) actually delivers
uppercase to apps on Tahoe (SokIM evidence suggests it may not, hence
the spike). The doc enumerates A/B/C scenario branches.
Other design/*.md files remain untracked per existing repo convention;
broader tracking decision deferred.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements design/30-capslock-mode-sync.md: CapsLock LED mirrors the current input mode via IOHIDSetModifierLockState. Replaces the previous "always force off" stance with bidirectional SET — LED ON = Korean, LED OFF = English. Mode changes from any path (CapsLock press, other toggle keys, app switch, ESC→English, focus-steal correction, English Lock) auto-sync the LED. Changes: CapsLockSync (rewrite) - Add `setState(_: Bool)` — IOKit SET for either direction - Add `shouldHandle(capsLockOn:)` — `expectedState` + 100ms timeout guard to filter our own setState() echoes from user input - `reset()` for settings-change LED off - `forceOff()` retained as thin alias for backward compat with existing callers (KeyEventTap PR #11 strip path, preferences-panel toggleKey-change defense) InputStateCoordinator - `setMode(_, syncCapsLock: Bool = true)` — LED sync built into every mode change. Default true; explicit `false` for the CapsLock-press path where hardware already toggled state - `toggleEngineMode` — same LED sync (this path skips setMode) - New public `setModeFromCapsLockPress(korean:for:)` — single setter-style entry for CapsLock-press SET semantics (calls setMode with syncCapsLock=false). Per-app mode store updated, Lock guarded. Korean→English transition flushes composing jamo. KeyEventTap flagsChanged CapsLock branch - Replaces "only handle capsLockOn=true + always force off + toggle" with SET semantics: `shouldHandle()` filters echoes, then `performCapsLockModeSet(korean: capsLockOn)`. Event still passed through (hardware already toggled — consuming would desync app's modifier tracking). OngeulInputController - New `performCapsLockModeSet(korean:)` for the CGEventTap callback entry point - `handleFlagsChanged` IMK-fallback CapsLock branch migrated to the same SET semantics (uses `shouldHandle` + `setModeFromCapsLockPress` instead of `toggleMode + forceOff`) Localizable strings (ko/en) - `prefs.capsLockDelay` tooltip corrected: macOS has no UI to disable the Caps Lock delay; hidutil is required (matches docs/.../capslock.md) Note on TICapsLockLanguageSwitchCapable: doc 30 prerequisite step "remove from Info.plist" — verified key is already absent. No change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…percase)
Implements design/32-hid-capslock-press-duration.md: macOS-native parity
for the CapsLock toggle key — "tap to switch language, press-and-hold to
activate the conventional Caps Lock (uppercase)". This matches the Apple
official Input Sources UI behavior text ("Press and hold to enable
typing in all uppercase") that Korean macOS users have used since Sierra.
Opt-in: HID monitor activates only when `toggleKey == .capsLock`. Other
toggle key users see zero change — no new permissions, no new code paths.
CapsLockHIDMonitor.swift (new)
- IOHIDManager keyboard monitor matched to Caps Lock usage 0x39 only
- 800ms long-press timer (hardcoded — SokIM-verified value)
- State machine via single `CapsLockMode` enum:
.cgEventTapAuthority — HID inactive (fallback to doc 30 path)
.hidToggleAuthority — HID active, short-tap mode
.hidRealLockOn — long-press engaged real Caps Lock
- Short tap (<800ms) → coordinator.toggleMode (via performToggleFromTap)
- Long press (>=800ms, key still down) → enterRealCapsLock:
force English mode + setState(true) (LED on, alpha-lock on)
- Subsequent short tap while in realLockOn → exitRealCapsLock:
setState(false), mode stays as-is
- Tracks `pressTriggeredLockTransition` so the keyUp of an entry press
doesn't double-fire as a toggle
- start() throws StartError.{notPermitted, exclusiveAccess, other(IOReturn)}
KeyEventTap gating
- flagsChanged CapsLock branch (doc 30 SET) skipped when HID is the
authority (mode != .cgEventTapAuthority) — HID drives instead
- keyDown .maskAlphaShift strip (PR #11) skipped when realLockOn so the
user's deliberate uppercase actually propagates to apps
InputStateCoordinator gating
- setMode LED sync and toggleEngineMode LED sync both skip when
realLockOn — the user's explicit lock state is respected; other mode
changes (per-app restore, English Lock, etc.) don't touch the LED
OngeulInputController
- performEnterRealCapsLock(): forces English via setModeFromCapsLockPress
(syncCapsLock=false) then setState(true) directly (gate would skip
doc 30 LED sync)
- activateServer: try HID start when toggleKey == .capsLock (idempotent)
- okClicked + commitSettings: refactored; presents permission sheet when
transitioning to .capsLock AND IOHIDCheckAccess/AXIsProcessTrusted
shows a missing permission. Sheet dynamically composes buttons based
on which permissions are missing
- presentCapsLockPermissionAlert: NSAlert as sheet on prefs panel with
"Open Input Monitoring Settings" / "Open Accessibility Settings" /
"Cancel". Confirm opens the pane (deep link) and commits; cancel
reverts the popup selection
- menu(): prepends "⚠ CapsLock: permission required" item when
toggleKey == .capsLock and HID monitor isn't started; click opens
the appropriate pane and retries HID start
PrivacyPane deep links
- x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent
- x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility
Localized strings (ko/en)
- prefs.capsLockSheet.{title,intro,needInput,needAccess,restart,openInput,openAccess}
- menu.capsLockNeedsPermission
Phase 1.5 spike gate (NOT addressed by this PR — separate work item):
The performEnterRealCapsLock path assumes scenario A from doc 32 — i.e.,
IOHIDSetModifierLockState(handle, kIOHIDCapsLockState, true) actually
causes apps to receive uppercase characters via the normal text input
path. SokIM evidence suggests this may not work reliably on Sonoma+ (OS
stomp behavior). The spike (doc 32 § scenario A/B/C) must verify before
this PR ships; if scenario B, English-passthrough uppercase synthesis
will need to be added as a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Owner
Author
|
Superseded by split into:
Re-review of this PR identified that Phase 1 + Phase 2 in one ~990-line PR coupled merge decisions that should be independent, and surfaced 4 concrete code/doc defects (mode restore on exit, controller injection race, doc 32 missing spike measurement #7, forceOff alias cleanup) which are addressed in PR #14. The original 6 commits on
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements macOS-native parity for the CapsLock toggle key:
Currently Ongeul's CapsLock-toggle mode forfeits conventional Caps Lock entirely ("CapsLock = toggle = no uppercase"). This PR brings Ongeul in line with the platform standard (and with SokIM/구름/Apple Korean IM): tap = language toggle, press-and-hold (≥800ms) = real Caps Lock uppercase.
Branch contains 4 commits stacked over
main. Three are net-new for this PR; the first (65e20e7) is the same change as #11 (defensivemaskAlphaShiftstrip) — see § Dependency on #11 below.Design references
design/30-capslock-mode-sync.md— LED-as-mode-indicator SET semantics (was "review done, awaiting impl" — implemented here)design/32-hid-capslock-press-duration.md— this PR's HID monitor design (added in this PR)Phases
65e20e7.maskAlphaShiftstrip — #10 hardening926930bcapslock.mdandprefs.capsLockDelaytooltip corrected (macOS has no UI to disable Caps Lock delay;hidutil property --set '{"CapsLockDelayOverride":0}'is the actual mechanism, doesn't persist across reboot)ce3b671CapsLockSync.setState/shouldHandle/reset(expectedState + 100ms timeout guard);InputStateCoordinator.setMode(_, syncCapsLock:)+toggleEngineModeLED sync;setModeFromCapsLockPress(korean:for:);KeyEventTapflagsChanged CapsLock branch refactored to SET semantics;OngeulInputController.performCapsLockModeSet; IMK fallback path migrated to SET806bf57CapsLockHIDMonitor.swift+CapsLockModeenum (single-source-of-truth state); HID monitor matched to Caps Lock usage 0x39 only; 800ms long-press timer; short tap →performToggleFromTap, long press →performEnterRealCapsLock;KeyEventTapflagsChanged and keyDown-strip both gated onCapsLockHIDMonitor.shared.mode;setModeLED sync gated onmode != .hidRealLockOn; permission sheet withIOHIDCheckAccess+AXIsProcessTrustedgating (only shown when actually missing); menu-bar health item when HID can't start; deep links toPrivacy_ListenEvent/Privacy_AccessibilityOpt-in scope
HID monitor activates only when
toggleKey == .capsLock. Other toggle key users see zero behavior change, no new permissions, no code path activation. The only files materially touched outside that gate are:KeyEventTap.swiftadds two single-line conditions (mode ==checks) that are no-ops unlesstoggleKey == .capsLockInputStateCoordinator.setMode/toggleEngineModeadd LED sync gated ontoggleKey == .capsLockcapslock.md/ tooltip wording correction (always-on, harmless)⚠ Phase 1.5 spike gate — not yet performed
performEnterRealCapsLock()callsCapsLockSync.setState(true)with the assumption that subsequentkeyDowns actually reach apps with.maskAlphaShiftapplied (i.e., real uppercase). SokIM'ssetKeyboardCapsLocksource suggests this may not work reliably on Sonoma+ — SokIM keeps the system state alwaysfalseand synthesizes uppercase via its ownQwertyEngine, with explicit 11-times-stomp counter-handling for "Sonoma 이후 커서 밑에 생기는 '버블'/HUD/Indicator/Accessory 방지".Before merging, run the Phase 1.5 spike (doc 32 § 스파이크 계획 — 6 measurement items, ~5–8 hours including Tahoe VM setup) to determine:
[PR #11's keyDown strip is the safety net that closes #10's visible symptom regardless of which scenario hits.]
Distribution dependency (Phase 4)
Ad-hoc-signed builds (
codesign --force --sign -in scripts/build.sh, scripts/package.sh, release.yml) will see the Input Monitoring permission silently revoked on every update because TCC's designated requirement binds to an unstable identity. The HID feature works under ad-hoc, but with chronic "re-grant after every update" friction. Doc 50 (Developer ID + notarization) should land in tandem for general availability.For development/repro VMs, scripts/install.sh's existing SIP-off TCC auto-grant trick can be extended to
kTCCServiceListenEvent— not in this PR (separate Phase 3 work).Verification
swiftc -typecheckof all sources (Generated +OngeulApp/Sources/*.swift) passes with zero warnings after both Phase 1 and Phase 2 commitsDependency on #11
This branch was created from
fix/capslock-alpha-shift-leak(the #11 PR branch) and therefore includes commit65e20e7. When#11merges to main first, this branch rebases cleanly (Git detects the identical commit and drops it). If this PR merges first instead,#11becomes redundant and can be closed.Recommended order: merge
#11first (it's an independent low-risk hardening), then this PR.What's NOT in this PR
install.shSIP-off auto-grant extension forkTCCServiceListenEventrestartIfIdlefor HID callback silence,NSInputMonitoringUsageDescriptionIMK applicability test, multi-keyboard simultaneity,realCapsLockActiveper-app vs global is settled as global (matches macOS Caps Lock semantics)🤖 Generated with Claude Code