Skip to content

feat(capslock): HID-based short/long-press parity with macOS native CapsLock toggle#12

Closed
hiking90 wants to merge 4 commits into
mainfrom
feat/capslock-hid-toggle
Closed

feat(capslock): HID-based short/long-press parity with macOS native CapsLock toggle#12
hiking90 wants to merge 4 commits into
mainfrom
feat/capslock-hid-toggle

Conversation

@hiking90
Copy link
Copy Markdown
Owner

Summary

Implements macOS-native parity for the CapsLock toggle key:

Apple's official Input Sources option text: "Use the Caps Lock key to switch to and from U.S. Press and hold to enable typing in all uppercase."

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 (defensive maskAlphaShift strip) — see § Dependency on #11 below.

Design references

Phases

Commit Phase Content
65e20e7 0 (same as #11) keyDown .maskAlphaShift strip — #10 hardening
926930b docs design 32 added; capslock.md and prefs.capsLockDelay tooltip corrected (macOS has no UI to disable Caps Lock delay; hidutil property --set '{"CapsLockDelayOverride":0}' is the actual mechanism, doesn't persist across reboot)
ce3b671 1 (doc 30) CapsLockSync.setState/shouldHandle/reset (expectedState + 100ms timeout guard); InputStateCoordinator.setMode(_, syncCapsLock:) + toggleEngineMode LED sync; setModeFromCapsLockPress(korean:for:); KeyEventTap flagsChanged CapsLock branch refactored to SET semantics; OngeulInputController.performCapsLockModeSet; IMK fallback path migrated to SET
806bf57 2 (doc 32) CapsLockHIDMonitor.swift + CapsLockMode enum (single-source-of-truth state); HID monitor matched to Caps Lock usage 0x39 only; 800ms long-press timer; short tap → performToggleFromTap, long press → performEnterRealCapsLock; KeyEventTap flagsChanged and keyDown-strip both gated on CapsLockHIDMonitor.shared.mode; setMode LED sync gated on mode != .hidRealLockOn; permission sheet with IOHIDCheckAccess + AXIsProcessTrusted gating (only shown when actually missing); menu-bar health item when HID can't start; deep links to Privacy_ListenEvent / Privacy_Accessibility

Opt-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.swift adds two single-line conditions (mode == checks) that are no-ops unless toggleKey == .capsLock
  • InputStateCoordinator.setMode/toggleEngineMode add LED sync gated on toggleKey == .capsLock
  • capslock.md / tooltip wording correction (always-on, harmless)

⚠ Phase 1.5 spike gate — not yet performed

performEnterRealCapsLock() calls CapsLockSync.setState(true) with the assumption that subsequent keyDowns actually reach apps with .maskAlphaShift applied (i.e., real uppercase). SokIM's setKeyboardCapsLock source suggests this may not work reliably on Sonoma+ — SokIM keeps the system state always false and synthesizes uppercase via its own QwertyEngine, 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:

  • Scenario A — apps receive uppercase → merge as-is
  • Scenario B — LED/state set but apps receive lowercase → add a small follow-up for English-passthrough uppercase synthesis (HID monitor itself remains correct)
  • Scenario C — OS stomps state → drop long-press support; HID would still be useful for short-tap reliability

[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 -typecheck of all sources (Generated + OngeulApp/Sources/*.swift) passes with zero warnings after both Phase 1 and Phase 2 commits
  • Runtime verification gated on Phase 1.5 spike (see above)

Dependency on #11

This branch was created from fix/capslock-alpha-shift-leak (the #11 PR branch) and therefore includes commit 65e20e7. When #11 merges to main first, this branch rebases cleanly (Git detects the identical commit and drops it). If this PR merges first instead, #11 becomes redundant and can be closed.

Recommended order: merge #11 first (it's an independent low-risk hardening), then this PR.

What's NOT in this PR

  • Phase 0.5 — Tahoe (macOS 26.x) repro VM construction (infrastructure)
  • Phase 1.5 — runtime spike on Sequoia+Tahoe (manual, see above)
  • Phase 3install.sh SIP-off auto-grant extension for kTCCServiceListenEvent
  • Phase 4 — Developer ID + notarization pipeline (doc 50)
  • Open questions in doc 32 § 미해결 — restartIfIdle for HID callback silence, NSInputMonitoringUsageDescription IMK applicability test, multi-keyboard simultaneity, realCapsLockActive per-app vs global is settled as global (matches macOS Caps Lock semantics)

🤖 Generated with Claude Code

hiking90 and others added 4 commits May 20, 2026 21:18
…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>
@hiking90
Copy link
Copy Markdown
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 feat/capslock-hid-toggle are preserved in:

  • feat/capslock-doc30 (Phase 1 — cherry-picks 1, 2, 3)
  • feat/capslock-hid (Phase 2 — cherry-picks 4, 5, 6 + 1 new commit migrating forceOff to setState)

Suggested merge order: #11#13 → spike → #14.

@hiking90 hiking90 closed this May 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

캡스락으로 변경시 캡스락 활성화가 짧게 눌러더 변경됩니다.(비간혈적이지만 매우 높은 빈도로 변경됩니다.)

1 participant