Compare commits
No commits in common. "main" and "backup" have entirely different histories.
22 changed files with 421 additions and 297 deletions
43
README.md
43
README.md
|
|
@ -1,16 +1,12 @@
|
|||
# macoc
|
||||
|
||||
<p align="center">
|
||||
<img src="Resources/AppIcon-preview.png" alt="macoc icon - a furious programmer on a burning office chair" width="172">
|
||||
</p>
|
||||
|
||||
Native macOS menu-bar layout switcher for macOS Sonoma (14+) without
|
||||
the stock Caps Lock delay. Plain **Caps** switches to the next configured
|
||||
input source on **release**, including during fast typing. **Option+Caps**
|
||||
in either order toggles the real system Caps Lock state.
|
||||
|
||||
macoc keeps input switching simple. The icon is an SFW illustration
|
||||
of exactly how frustrating the old Caps Lock behavior can feel.
|
||||
The icon is a SFW cartoon of someone thoroughly fed up with keyboard
|
||||
layout switching, complete with a literally hot seat.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
|
@ -20,38 +16,6 @@ root daemons, third-party input libraries or hidutil permission required.
|
|||
|
||||
Apple technical note: https://developer.apple.com/library/archive/technotes/tn2450/
|
||||
|
||||
## App icon
|
||||
|
||||
The icon uses a 1024 x 1024 RGBA master with a centered 824 x 824
|
||||
superellipse body and transparent margins. This matches the visual
|
||||
balance of the macOS application icon grid without accidentally
|
||||
shipping a square, white-backed image.
|
||||
|
||||
- [Full-resolution master](Resources/AppIcon.png) - the single source for
|
||||
the shipped classic Sonoma AppIcon.icns.
|
||||
- [Editable source layout](Resources/AppIconArtwork.svg) - includes the
|
||||
artwork and the clipping geometry, without requiring a third-party
|
||||
image tool during app builds.
|
||||
- [Non-shipping design grid](Resources/AppIconGrid.svg) - alignment lines,
|
||||
guides and 100px margins; never embedded in the icon.
|
||||
- [Layout measurements](Resources/AppIconLayout.json) - canvas, silhouette,
|
||||
crop and inset values.
|
||||
- [Illustration source](Resources/IconArtwork.jpg) - editable raster scene.
|
||||
|
||||
When running bash scripts/icon.sh (or task icon) on macOS, the build
|
||||
uses sips to generate all ten Apple iconset PNG representations:
|
||||
16, 32, 128, 256 and 512 points, each at 1x and 2x. iconutil
|
||||
combines them into Contents/Resources/AppIcon.icns. The shipped master
|
||||
already has transparency, a subtle edge shadow, and safe spacing.
|
||||
|
||||
This is a classic precomposed macOS 14 .icns, not the layered Icon
|
||||
Composer format introduced for newer icon appearances.
|
||||
|
||||
Apple design references:
|
||||
- [Apple Human Interface Guidelines - app icons](https://developer.apple.com/design/human-interface-guidelines/app-icons)
|
||||
- [Apple Design Resources - grids and templates](https://developer.apple.com/design/resources/)
|
||||
- [Apple archive - iconset filenames and scales](https://developer.apple.com/library/archive/documentation/Xcode/Reference/xcode_ref-Asset_Catalog_Format/IconSetType.html)
|
||||
|
||||
## Build and install
|
||||
|
||||
Requires Apple's Command Line Tools (not the Xcode IDE). Task is optional.
|
||||
|
|
@ -150,7 +114,8 @@ nonexistent macOS binaries.
|
|||
For a release archive, check out the tag on a real Mac and run
|
||||
bash scripts/package.sh (or task package), then execute
|
||||
bash scripts/publish-release.sh with a repository-scoped Forgejo token.
|
||||
Fully automated macOS binary releases require a real Mac runner or another Mac build service.
|
||||
See [CI and release instructions](docs/ci.md). Fully automated macOS
|
||||
binary releases require a real Mac runner or another Mac build service.
|
||||
|
||||
Ad-hoc artifacts are suited to local evaluation; public distribution
|
||||
requires Developer ID signing and Apple notarization.
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 60 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 485 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 36 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 13 KiB |
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"canvasPixels": 1024,
|
||||
"bodyPixels": 824,
|
||||
"bodyOrigin": [
|
||||
100,
|
||||
100
|
||||
],
|
||||
"superellipseExponent": 4.4,
|
||||
"sourceCrop": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
"sourceFile": "IconArtwork.jpg",
|
||||
"legacyFormat": "icns"
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.3 KiB |
BIN
Resources/IconSource.jpg
Normal file
BIN
Resources/IconSource.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4 KiB |
|
|
@ -11,11 +11,11 @@ final class InputSourceManager {
|
|||
|
||||
var currentDisplayName: String {
|
||||
guard let source = currentSource() else {
|
||||
return "-"
|
||||
return "—"
|
||||
}
|
||||
return property(source, key: kTISPropertyLocalizedName)
|
||||
?? property(source, key: kTISPropertyInputSourceID)
|
||||
?? "-"
|
||||
?? "—"
|
||||
}
|
||||
|
||||
var currentShortName: String {
|
||||
|
|
|
|||
|
|
@ -23,9 +23,9 @@ struct LoginItemManager {
|
|||
case .enabled, .notRegistered:
|
||||
return "Launch at Login"
|
||||
case .requiresApproval:
|
||||
return "Launch at Login - approval required"
|
||||
return "Launch at Login — approval required"
|
||||
case .notFound:
|
||||
return "Launch at Login - unavailable"
|
||||
return "Launch at Login — unavailable"
|
||||
@unknown default:
|
||||
return "Launch at Login"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ final class MacOSInputController {
|
|||
return "Unknown (check Keyboard settings)"
|
||||
}
|
||||
|
||||
return number.intValue == 0 ? "Off" : "On - disable for Capswitch"
|
||||
return number.intValue == 0 ? "Off" : "On — disable for Capswitch"
|
||||
}
|
||||
|
||||
var currentInputSourceName: String {
|
||||
|
|
|
|||
|
|
@ -211,7 +211,7 @@ final class StatusBarController: NSObject, NSMenuDelegate {
|
|||
statusItem.button?.title = inputController.isRunning
|
||||
? inputController.currentInputSourceShortName
|
||||
: "Caps!"
|
||||
statusItem.button?.toolTip = "macoc - \(name)"
|
||||
statusItem.button?.toolTip = "macoc — \(name)"
|
||||
|
||||
currentLayoutItem.title = "Current layout: \(name)"
|
||||
diagnosticsItem.title = inputController.diagnosticsSummary
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ case .doctor:
|
|||
let remapper = HIDRemappingController()
|
||||
do {
|
||||
try remapper.inspect()
|
||||
print("macoc - native HID diagnostic")
|
||||
print("macoc — native HID diagnostic")
|
||||
print("Keyboard services: \(remapper.keyboardServiceCount)")
|
||||
print("Caps → F19 mappings: \(remapper.mappedServiceCount)")
|
||||
if remapper.mappedServiceCount == remapper.keyboardServiceCount {
|
||||
|
|
|
|||
66
docs/ci.md
Normal file
66
docs/ci.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Forgejo Actions CI: existing Docker runner
|
||||
|
||||
The only registered Forgejo runner has the **docker** label. Workflow:
|
||||
.forgejo/workflows/ci.yml. It uses runs-on: docker with the official
|
||||
swift:6.2-noble Linux container image.
|
||||
|
||||
## CI checks
|
||||
|
||||
- Main branch / manual dispatch: obtain the exact triggering commit, validate
|
||||
source and shell scripts, and run the **real** XCTest suite for the pure
|
||||
Swift CapswitchCore module.
|
||||
- Tags v*: same tests plus Info.plist-version/tag consistency checking.
|
||||
The v0.3.0 tag must match CFBundleShortVersionString 0.3.0.
|
||||
|
||||
scripts/test-linux-core.sh creates a temporary SwiftPM package containing
|
||||
the actual Sources/Capswitch source files and Tests/CapswitchCoreTests suite.
|
||||
The standard Package.swift is macOS-specific and cannot be compiled on Linux.
|
||||
|
||||
## Why Docker CI does not produce a macOS binary
|
||||
|
||||
Linux Docker does not include the Apple macOS SDK, AppKit, Carbon or IOKit.
|
||||
It cannot generate a signed functional macoc.app. This workflow does not run
|
||||
scripts/package.sh on Linux and does not create a misleading binary release.
|
||||
|
||||
For a release with the current infrastructure:
|
||||
|
||||
1. Push tag v0.3.0 (or the version in Info.plist); verify Docker CI passed.
|
||||
2. On a real Mac with Command Line Tools, check out that precise tag.
|
||||
3. Run bash scripts/package.sh (equivalent to task package). This creates
|
||||
dist/macoc-v0.3.0-macos-<arch>.zip and the SHA-256 checksum file.
|
||||
4. Set FORGEJO_SERVER_URL, FORGEJO_REPOSITORY=catsona/macoc,
|
||||
FORGEJO_REF_NAME=v0.3.0, and FORGEJO_TOKEN with repository release write
|
||||
access; then run bash scripts/publish-release.sh to publish the Mac
|
||||
generated ZIP/checksum to Forgejo.
|
||||
|
||||
That is a manual macOS packaging step. For fully automated builds of
|
||||
macOS binaries, a real Mac runner or a trusted Mac build service is needed.
|
||||
|
||||
Code signing / Developer ID notarization is separate; ad-hoc builds may
|
||||
require explicit Gatekeeper approval.
|
||||
|
||||
## No Node checkout dependency
|
||||
|
||||
Forgejo Runner's default Docker image might supply Node, but the official
|
||||
Swift image does not promise Node.js, so actions/checkout is not used.
|
||||
The workflow obtains an authenticated archive at the triggering SHA using
|
||||
Forgejo's own API, with the short-lived repository-scoped forgejo.token.
|
||||
The token is only placed in a mode-0600 temporary curl configuration,
|
||||
not in clone URLs, source files or process arguments.
|
||||
|
||||
As tar archives have no Git index, the job creates a local Git index for
|
||||
git diff --cached --check. No CI commit or push occurs.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The registered runner must advertise the docker label to this repository.
|
||||
It must be allowed to pull swift:6.2-noble from Docker Hub and reach the
|
||||
Forgejo API. No Docker socket inside the job is necessary.
|
||||
|
||||
Forgejo documentation:
|
||||
https://forgejo.org/docs/latest/admin/actions/configuration/
|
||||
https://forgejo.org/docs/latest/user/actions/reference/
|
||||
https://forgejo.org/docs/latest/user/actions/security/
|
||||
|
||||
Swift Docker:
|
||||
https://www.swift.org/install/linux/docker/
|
||||
55
docs/reviews/2026-10-25-code-review.md
Normal file
55
docs/reviews/2026-10-25-code-review.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# Capswitch code review — 2026-10-25
|
||||
|
||||
## Scope
|
||||
|
||||
Review of the already-working Caps->F19 HID remapping, Quartz event tap,
|
||||
TIS input source switching, menu bar lifecycle, installer and login setup.
|
||||
|
||||
## Findings and changes
|
||||
|
||||
1. High: no stable installation path. Launching a bundle in build/ makes
|
||||
privacy permissions and login registration fragile. Added a user-scoped
|
||||
installer to ~/Applications with staged copying, identity validation and
|
||||
safeguards against replacing a different or running application.
|
||||
2. High: unsafe restart could strand a remapped Caps. Restart restores the
|
||||
physical key first, refuses to exit if restoration fails, launches a new
|
||||
copy of the same executable and waits for the previous PID to disappear
|
||||
before creating AppKit, the event tap or HID manager.
|
||||
3. Medium: no native login management. SMAppService.mainApp now provides a
|
||||
user-toggleable Launch at Login status with approval guidance in Settings.
|
||||
4. Medium: InputSourceManager and HID event-tap concerns were co-located.
|
||||
Extracted the input source adapter without changing the proven event path.
|
||||
5. Medium: startup argument parsing embedded in main. Added an independent
|
||||
LaunchArguments core type and tests for doctor, install and wait-for-PID.
|
||||
6. Medium: distribution required manual copying. Taskfile now provides an
|
||||
install task that packages and starts the installed copy without sudo.
|
||||
|
||||
## Protected behavior
|
||||
|
||||
The existing HID IOHIDServiceClientSetProperty and CoreGraphics CGEventTap
|
||||
approach is hardware-verified on macOS 14.8.9. The normal Caps->F19 event
|
||||
path and Option+Caps gesture state machine are deliberately unchanged.
|
||||
|
||||
## Remaining verification
|
||||
|
||||
- SMAppService status/registration for ad-hoc signed apps and user Applications
|
||||
on this particular macOS Sonoma machine.
|
||||
- Restart of a copy launched via its signed bundle executable must be checked
|
||||
for macOS privacy identity continuity.
|
||||
- Staged app replacement rolls back ordinary filesystem errors but a power
|
||||
interruption between filesystem moves can leave a backup copy.
|
||||
- HID ownership persistence failure handling needs independent runtime tests.
|
||||
- Enabled input source ordering uses HIToolbox preferences. Preserve the
|
||||
current working behavior unless a future acceptance test demonstrates a bug.
|
||||
|
||||
## On-Mac acceptance
|
||||
|
||||
1. Quit the old app and run task test, task install.
|
||||
2. Confirm the launched bundle resides in ~/Applications.
|
||||
3. Verify the rapid Caps typing test still passes.
|
||||
4. Restart via menu; only one status item and running process remains.
|
||||
5. Verify Caps+Option and Pause/Quit restore normal Caps.
|
||||
6. Toggle Launch at Login on, approve when required, log out/in to verify.
|
||||
7. Toggle Launch at Login off, log out/in to verify it stays off.
|
||||
8. Reinstall an updated development build after closing the installed app.
|
||||
9. Confirm other pre-existing HID mappings survive every path.
|
||||
127
docs/superpowers/plans/2026-10-25-capswitch.md
Normal file
127
docs/superpowers/plans/2026-10-25-capswitch.md
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# Capswitch Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (\`- [ ]\`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a native Swift menu-bar app that suppresses physical Caps Lock, switches to the next enabled keyboard input source on Caps release, and preserves real Caps Lock on any Caps+Option gesture.
|
||||
|
||||
**Architecture:** A pure Swift gesture state machine owns Caps/Option classification. A macOS adapter uses CGEventTap for global flagsChanged events, Carbon TIS APIs for input source selection, and AppKit NSStatusItem for the menu bar. A Taskfile and shell-free Swift build command assemble an unsigned/ad-hoc signed \`.app\` without an Xcode project.
|
||||
|
||||
**Tech Stack:** Swift 5-compatible source; AppKit, CoreGraphics, ApplicationServices, Carbon; Taskfile v3; XCTest on macOS; no third-party dependencies.
|
||||
|
||||
**Spec:** \`docs/superpowers/specs/2026-10-25-capswitch-design.md\`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Target macOS 14.0+; validate on the user's macOS 14.8.9.
|
||||
- Work directly on \`main\`; repository is initially empty.
|
||||
- Use Taskfiles for build, test, lint, bundle, and clean scripts.
|
||||
- No Xcode project, Storyboard, CocoaPods, Homebrew, or runtime dependency.
|
||||
- Plain Caps switches only on Caps release when Option was not seen during the gesture.
|
||||
- Caps+Option preserves real Caps Lock in either key order.
|
||||
- Use a stable bundle identifier and LSUIElement menu-bar app configuration.
|
||||
- Accessibility is required for the global event tap; show its state in the menu.
|
||||
- Linux verification must distinguish host-unavailable Swift tooling from actual failures.
|
||||
|
||||
## Review Focus
|
||||
|
||||
- Caps release must switch exactly once and never on Caps press.
|
||||
- Option before Caps and Caps before Option must both toggle real Caps Lock.
|
||||
- Option released after Caps must complete the pending Caps toggle without switching layout.
|
||||
- Tap reset/sleep recovery must clear pending state safely.
|
||||
- Input source enumeration must ignore disabled/non-keyboard sources and keep the configured order.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Repository skeleton and plan
|
||||
|
||||
**Files:**
|
||||
- Create: \`docs/superpowers/specs/2026-10-25-capswitch-design.md\`
|
||||
- Create: \`docs/superpowers/plans/2026-10-25-capswitch.md\`
|
||||
- Create: \`Taskfile.yml\`
|
||||
- Create: \`.gitignore\`
|
||||
- Create: \`README.md\`
|
||||
|
||||
- [ ] Write the design/spec and repository workflow files.
|
||||
- [ ] Run Taskfile syntax/help checks.
|
||||
- [ ] Commit the skeleton.
|
||||
|
||||
### Task 2: Gesture state machine
|
||||
|
||||
**Files:**
|
||||
- Create: \`Sources/Capswitch/CapsGestureMachine.swift\`
|
||||
- Test: \`Tests/CapswitchTests/CapsGestureMachineTests.swift\`
|
||||
|
||||
**Interfaces:**
|
||||
- \`enum GestureEvent\`: \`capsDown(optionDown: Bool)\`, \`capsUp(optionDown: Bool)\`, \`optionDown\`, \`optionUp\`, \`reset\`.
|
||||
- \`enum GestureAction\`: \`none\`, \`switchToNextInputSource\`, \`toggleCapsLock\`.
|
||||
- \`struct CapsGestureMachine\` with \`mutating func handle(_ event: GestureEvent) -> GestureAction\`.
|
||||
|
||||
- [ ] Write failing XCTest cases for plain Caps, both Option orders, delayed Option release, and reset.
|
||||
- [ ] Run the targeted test on macOS and verify it fails before implementation; on Linux record Swift unavailable.
|
||||
- [ ] Implement the minimal state machine.
|
||||
- [ ] Run the targeted test and the full test task.
|
||||
|
||||
### Task 3: macOS event tap and input source adapter
|
||||
|
||||
**Files:**
|
||||
- Create: \`Sources/Capswitch/MacOSInputController.swift\`
|
||||
- Modify: \`Sources/Capswitch/CapsGestureMachine.swift\` only if adapter-facing visibility requires it.
|
||||
|
||||
**Interfaces:**
|
||||
- \`final class MacOSInputController\` with \`start() throws\`, \`stop()\`, and \`onInputSourceChanged: (() -> Void)?\`.
|
||||
- Uses keycode 57, flagsChanged transitions, CGEventTap recovery, TIS selection, and synthetic Caps events.
|
||||
- Synthetic Caps events must be marked/guarded so they do not re-enter the gesture state machine.
|
||||
|
||||
- [ ] Add adapter tests or compile-only checks where platform APIs are available.
|
||||
- [ ] Implement the event tap and Carbon input source enumeration.
|
||||
- [ ] Add tap timeout recovery and reset handling.
|
||||
- [ ] Build on macOS with Taskfile.
|
||||
|
||||
### Task 4: Status bar app and permissions
|
||||
|
||||
**Files:**
|
||||
- Create: \`Sources/Capswitch/AppDelegate.swift\`
|
||||
- Create: \`Sources/Capswitch/StatusBarController.swift\`
|
||||
- Create: \`Resources/Info.plist\`
|
||||
|
||||
- [ ] Add LSUIElement app delegate and status item.
|
||||
- [ ] Add current-layout display, permission status, pause, launch-at-login placeholder, and quit menu actions.
|
||||
- [ ] Add Accessibility check and System Settings URL.
|
||||
- [ ] Build the .app bundle.
|
||||
|
||||
### Task 5: CLI packaging and documentation
|
||||
|
||||
**Files:**
|
||||
- Modify: \`Taskfile.yml\`
|
||||
- Modify: \`README.md\`
|
||||
- Create: \`scripts/build-app.sh\` only if Taskfile cannot express the bundle operation clearly.
|
||||
|
||||
- [ ] Add \`task build\`, \`task bundle\`, \`task test\`, \`task lint\`, \`task clean\`, and \`task verify\`.
|
||||
- [ ] Document one-time Command Line Tools setup, Accessibility permission, and first launch.
|
||||
- [ ] Run all Linux-safe checks and report the Mac-only build limitation.
|
||||
|
||||
### Task 6: Final verification and delivery
|
||||
|
||||
- [ ] Inspect diff and repository status.
|
||||
- [ ] Run Taskfile verification and any available tests.
|
||||
- [ ] Commit on \`main\`.
|
||||
- [ ] Push \`main\` to \`origin\`.
|
||||
- [ ] Report commit, remote branch, and Mac-only checks still required.
|
||||
|
||||
|
||||
## 2026-10-25 follow-up: HID remediation
|
||||
|
||||
The event-tap flagsChanged design is superseded for physical Caps by:
|
||||
- docs/superpowers/specs/2026-10-25-caps-f19-remap.md
|
||||
|
||||
The original Caps Lock event-tap approach was not reliable during fast typing.
|
||||
The code now relies on a Caps-to-F19 HID mapping, direct F19 keyDown/keyUp,
|
||||
a managed remap lifecycle, and a small IOKit bridge for true Caps Lock state.
|
||||
The original spec is retained for historical debugging context.
|
||||
|
||||
## In-process HID release
|
||||
|
||||
The latest native design is in
|
||||
docs/superpowers/specs/2026-10-25-in-process-hid-remap.md.
|
||||
Capswitch now uses IOHIDEventSystemClient and IOHIDServiceClientSetProperty
|
||||
inside CapswitchHID, replacing all subprocess-based remapping.
|
||||
73
docs/superpowers/specs/2026-10-25-caps-f19-remap.md
Normal file
73
docs/superpowers/specs/2026-10-25-caps-f19-remap.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
> Historical design. Superseded by 2026-10-25-in-process-hid-remap.md.
|
||||
> The executable no longer starts hidutil or requests permission for it.
|
||||
|
||||
# Capswitch HID-Level Caps Lock Remapping
|
||||
|
||||
## Problem
|
||||
|
||||
macOS filters and toggles physical Caps Lock in the HID / WindowServer path.
|
||||
A Quartz flagsChanged event does not reliably represent physical key-down and
|
||||
key-up, especially for very short presses interleaved with normal typing.
|
||||
Fixing CGEventTap callback timing alone cannot recover events already discarded.
|
||||
|
||||
## Chosen approach
|
||||
|
||||
- Use Apple's hidutil UserKeyMapping to translate HID Caps Lock usage
|
||||
0x700000039 into HID F19 usage 0x70000006E.
|
||||
- Handle ordinary virtual F19 (keycode 80) keyDown and keyUp through a
|
||||
session-level CGEventTap, suppressing both.
|
||||
- Retain Option modifier tracking through flagsChanged and the pure state
|
||||
machine. Switching occurs synchronously on F19 release, never on F19 down.
|
||||
- Keep TIS source enumeration outside the event tap callback and periodically
|
||||
refresh the list. The TISSelectInputSource call remains synchronous.
|
||||
- Use a small IOKit C shim from SwiftPM to set the system Caps Lock lock state
|
||||
for the Option combination.
|
||||
- Expose F19 down/up and input-switch counts in the status bar for real-Mac
|
||||
acceptance testing without logging typed characters.
|
||||
|
||||
## Lifecycle and safety
|
||||
|
||||
- Create and enable the event tap before applying the global HID remap.
|
||||
- Read and validate all existing mappings first; refuse Caps or F19 conflicts.
|
||||
- Merge with any unrelated mappings and verify the result after writing.
|
||||
- On Pause or Quit, disable interception and remove only Capswitch's exact pair.
|
||||
- Persist an ownership marker for crash recovery; do not remove pre-existing
|
||||
Caps -> F19 mappings not owned by Capswitch.
|
||||
- Reconcile HID state on wake and periodically, since keyboard disconnects or
|
||||
reboot can reset UserKeyMapping.
|
||||
- Run only one Capswitch instance for the same bundle ID.
|
||||
- Refuse app bundle replacement while a Capswitch process is running.
|
||||
- Treat nonstandard/unparseable hidutil output as a hard error, not permission
|
||||
to erase foreign mappings.
|
||||
|
||||
## Tests
|
||||
|
||||
XCTest covers rapid F19 presses mixed with normal keys, key repeat suppression,
|
||||
Caps+Option in either order, pending Option release, router reset/orphan release,
|
||||
hidutil OpenStep mapping output, and foreign mapping preservation in JSON.
|
||||
|
||||
The real macOS acceptance suite must verify:
|
||||
|
||||
- Clean Command Line Tools compilation with no Xcode project.
|
||||
- Short Caps taps during continuous typing are observed as F19 down/up.
|
||||
- On Caps release, the next typed letter uses the next configured layout.
|
||||
- No stock input-source animation or HUD is triggered.
|
||||
- Option+Caps toggles real Caps Lock on and off including the LED.
|
||||
- Pause and Quit restore only the managed mapping.
|
||||
- USB/Bluetooth devices and wake/reconnect recovery work.
|
||||
- All Accessibility/TCC and old-process conditions are surfaced to the user.
|
||||
|
||||
Current Linux host cannot run AppKit or HID hardware tests.
|
||||
|
||||
## Diagnostic follow-up: no F19 despite stock Caps working
|
||||
|
||||
- The user's macOS native Caps input-source switch is currently enabled.
|
||||
Do not silently modify it; surface a menu warning with a link to Keyboard.
|
||||
- Global keyboard taps may need Input Monitoring. Active filtering taps can
|
||||
work with Accessibility alone, so do not block before trying tapCreate.
|
||||
Diagnose the Input Monitoring grant separately and request it if denied.
|
||||
- CGEvent.tapIsEnabled and CFMachPortIsValid provide a basic tap health check.
|
||||
- A global UserKeyMapping can exist while keyboard-service mappings are empty.
|
||||
Surface the distinction through task doctor, and allow safe user-triggered
|
||||
reapplication without dropping unrelated mappings.
|
||||
- task doctor does not record or print typed text.
|
||||
38
docs/superpowers/specs/2026-10-25-capswitch-design.md
Normal file
38
docs/superpowers/specs/2026-10-25-capswitch-design.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Capswitch Design
|
||||
|
||||
Capswitch is a small native macOS menu-bar application for a user who wants physical Caps Lock to cycle keyboard layouts without the stock input-source shortcut HUD or animation.
|
||||
|
||||
## Intent
|
||||
|
||||
On a plain Caps gesture, suppress the system Caps Lock event and switch to the next enabled keyboard input source on Caps release. Reserve Caps+Option for the real Caps Lock behavior.
|
||||
|
||||
## Goals
|
||||
|
||||
- Global Caps interception through Accessibility-authorized CGEventTap.
|
||||
- Plain Caps switches once on release.
|
||||
- Caps+Option toggles real Caps Lock in either key order.
|
||||
- Input sources are selected directly through Carbon TIS APIs.
|
||||
- Menu-bar indicator shows the current input source and permission state.
|
||||
- Build and bundle from Taskfile/CLI on macOS without an Xcode project.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Replacing or modifying macOS input-source configuration.
|
||||
- Supporting App Store sandbox or notarized distribution in the MVP.
|
||||
- Implementing a full preferences window.
|
||||
- Supporting arbitrary remapping beyond Caps and Caps+Option.
|
||||
|
||||
## Interaction Contract
|
||||
|
||||
The event tap suppresses Caps down/up events. It records whether Option was already down or became active while Caps is held. On Caps release, no Option seen selects the next source; any Option seen schedules a synthetic clean Caps down/up. If Option remains held, the synthetic Caps event is posted when Option is released.
|
||||
|
||||
## Platform Contract
|
||||
|
||||
- Swift 5-compatible source targeting macOS 14.0+.
|
||||
- AppKit status item, CoreGraphics event tap, ApplicationServices Accessibility check, Carbon TIS input source APIs.
|
||||
- Stable bundle identifier \`dev.local.Capswitch\`.
|
||||
- \`LSUIElement=true\` so the app has no Dock icon.
|
||||
|
||||
## Verification
|
||||
|
||||
Linux may run only platform-independent/static checks because the remote host has no Swift/macOS SDK. The authoritative build and XCTest run are on macOS 14.8.9 with Command Line Tools installed.
|
||||
46
docs/superpowers/specs/2026-10-25-in-process-hid-remap.md
Normal file
46
docs/superpowers/specs/2026-10-25-in-process-hid-remap.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# In-Process Native HID Remapping
|
||||
|
||||
## User contract
|
||||
|
||||
The app installs physical Caps->F19 mappings and requests any required input
|
||||
privileges under Capswitch's own identity. Never request that the user grant
|
||||
Input Monitoring or Accessibility to a system binary or helper.
|
||||
Do not launch /usr/bin/hidutil or other executables.
|
||||
|
||||
## Apple-supported mechanism
|
||||
|
||||
Technical Note TN2450 documents:
|
||||
- IOHIDEventSystemClientCreateSimpleClient
|
||||
- IOHIDEventSystemClientCopyServices
|
||||
- IOHIDServiceClientConformsTo(kHIDPage_GenericDesktop, kHIDUsage_GD_Keyboard)
|
||||
- IOHIDServiceClientCopyProperty/SetProperty with UserKeyMapping.
|
||||
|
||||
The IOKit adapter is compiled inside the existing CapswitchHID SwiftPM C
|
||||
target, linked to IOKit in the Capswitch executable.
|
||||
|
||||
## Safety and lifecycle
|
||||
|
||||
- Set up active Quartz event tap before changing HID keyboard mappings.
|
||||
- Enumerate connected keyboard services, read each current UserKeyMapping,
|
||||
and fail closed if malformed or conflicting with Caps/F19.
|
||||
- Preserve other mappings while appending Caps->F19 only when absent.
|
||||
- Verify the result per service, do not trust a successful setter alone.
|
||||
- Persist owned service registry IDs under Capswitch's own preferences so a
|
||||
crash/restart can restore exactly Capswitch-owned rules.
|
||||
- On Pause/Quit, remove only the exact pair for those owned services.
|
||||
- A disconnected service's mapping is discarded by macOS.
|
||||
- On wake/reconnect, re-enumerate and reconcile.
|
||||
- No privileged helpers, keyboard drivers, or external subprocesses.
|
||||
- Both Accessibility and Input Monitoring requests target Capswitch; the OS
|
||||
remains responsible for user consent.
|
||||
|
||||
## Acceptance tests
|
||||
|
||||
- Build without Xcode IDE using Taskfile and Command Line Tools on macOS 14.8.9.
|
||||
- First launch creates permission prompts where appropriate.
|
||||
- Native --doctor reports keyboard services and applied pair counts.
|
||||
- Short Caps taps between typed letters register as F19 down/up and switch
|
||||
exactly once on release.
|
||||
- Option+Caps toggles real Caps Lock including actual typing state.
|
||||
- Pause/Quit preserves foreign HID remappings.
|
||||
- Keyboard reconnect / sleep recovery works.
|
||||
|
|
@ -2,29 +2,22 @@
|
|||
source "$(dirname "$0")/common.sh"
|
||||
macos_only
|
||||
|
||||
# The composited Sonoma icon is committed as 1024px RGBA. The transparent
|
||||
# outer margins and squircle silhouette must be preserved at every scale.
|
||||
# The art grid in AppIconGrid.svg is never embedded in production images.
|
||||
master="$ROOT/Resources/AppIcon.png"
|
||||
if [[ ! -s "$master" ]]; then
|
||||
echo "Missing icon master: $master" >&2
|
||||
if [[ ! -f "$SOURCE_ICON" ]]; then
|
||||
echo "Missing icon source: $SOURCE_ICON" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
iconset="$ROOT/build/macoc.iconset"
|
||||
rm -rf "$iconset"
|
||||
mkdir -p "$iconset"
|
||||
|
||||
for size in 16 32 128 256 512; do
|
||||
sips -s format png -z "$size" "$size" "$master" \
|
||||
sips -s format png -z "$size" "$size" "$SOURCE_ICON" \
|
||||
--out "$iconset/icon_${size}x${size}.png" >/dev/null
|
||||
done
|
||||
|
||||
for specification in "16 32" "32 64" "128 256" "256 512" "512 1024"; do
|
||||
read -r name pixels <<<"$specification"
|
||||
sips -s format png -z "$pixels" "$pixels" "$master" \
|
||||
sips -s format png -z "$pixels" "$pixels" "$SOURCE_ICON" \
|
||||
--out "$iconset/icon_${name}x${name}@2x.png" >/dev/null
|
||||
done
|
||||
|
||||
mkdir -p "$APP_PATH/Contents/Resources"
|
||||
iconutil -c icns "$iconset" -o "$APP_PATH/Contents/Resources/AppIcon.icns"
|
||||
|
|
|
|||
|
|
@ -5,10 +5,7 @@ git diff --check
|
|||
git diff --cached --check
|
||||
test -f Package.swift
|
||||
test -f Resources/Info.plist
|
||||
test -s Resources/AppIcon.png
|
||||
test -s Resources/AppIcon-preview.png
|
||||
test -s Resources/IconArtwork.jpg
|
||||
python3 scripts/tests/test_icon_assets.py
|
||||
test -s Resources/IconSource.jpg
|
||||
test -f Sources/CapswitchApp/MacOSInputController.swift
|
||||
test -f Sources/CapswitchApp/InputSourceManager.swift
|
||||
test -f Sources/CapswitchApp/ApplicationRelauncher.swift
|
||||
|
|
|
|||
|
|
@ -1,182 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Portable macOS app icon checks. Uses only the Python standard library."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import unittest
|
||||
import zlib
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SOURCE = ROOT / "Resources/IconArtwork.jpg"
|
||||
MASTER = ROOT / "Resources/AppIcon.png"
|
||||
PREVIEW = ROOT / "Resources/AppIcon-preview.png"
|
||||
LAYOUT = ROOT / "Resources/AppIconLayout.json"
|
||||
SVG = ROOT / "Resources/AppIconArtwork.svg"
|
||||
ICONSET = ROOT / "build/macoc.iconset"
|
||||
|
||||
SIZE_NAMES = {
|
||||
"icon_16x16.png": 16,
|
||||
"icon_16x16@2x.png": 32,
|
||||
"icon_32x32.png": 32,
|
||||
"icon_32x32@2x.png": 64,
|
||||
"icon_128x128.png": 128,
|
||||
"icon_128x128@2x.png": 256,
|
||||
"icon_256x256.png": 256,
|
||||
"icon_256x256@2x.png": 512,
|
||||
"icon_512x512.png": 512,
|
||||
"icon_512x512@2x.png": 1024,
|
||||
}
|
||||
|
||||
|
||||
def jpeg_dimensions(path: Path) -> tuple[int, int]:
|
||||
data = path.read_bytes()
|
||||
if not data.startswith(b"\xff\xd8"):
|
||||
raise ValueError(f"not a JPEG: {path}")
|
||||
index = 2
|
||||
while index < len(data):
|
||||
if data[index] != 0xFF:
|
||||
index += 1
|
||||
continue
|
||||
while index < len(data) and data[index] == 0xFF:
|
||||
index += 1
|
||||
marker = data[index]
|
||||
index += 1
|
||||
if marker in (0xD8, 0xD9, 0x00) or 0xD0 <= marker <= 0xD7:
|
||||
continue
|
||||
if index + 2 > len(data):
|
||||
break
|
||||
segment_length = struct.unpack(">H", data[index:index+2])[0]
|
||||
if marker in (0xC0, 0xC1, 0xC2, 0xC3):
|
||||
height, width = struct.unpack(">HH", data[index+3:index+7])
|
||||
return width, height
|
||||
index += segment_length
|
||||
raise ValueError(f"no JPEG SOF marker: {path}")
|
||||
|
||||
|
||||
def png_info(path: Path) -> tuple[int, int, int]:
|
||||
header = path.read_bytes()[:26]
|
||||
if header[:8] != b"\x89PNG\r\n\x1a\n" or header[12:16] != b"IHDR":
|
||||
raise ValueError(f"not a PNG: {path}")
|
||||
width, height = struct.unpack(">II", header[16:24])
|
||||
return width, height, header[25]
|
||||
|
||||
|
||||
def alpha_at(path: Path, x: int, y: int) -> int:
|
||||
"""Decode an RGBA PNG scanline, supporting all five PNG row filters."""
|
||||
data = path.read_bytes()
|
||||
width, height, color = png_info(path)
|
||||
if color != 6 or data[24] != 8:
|
||||
raise ValueError("expected 8-bit RGBA PNG")
|
||||
if not (0 <= x < width and 0 <= y < height):
|
||||
raise ValueError("pixel outside PNG")
|
||||
|
||||
offset = 8
|
||||
compressed = []
|
||||
while offset < len(data):
|
||||
size = struct.unpack(">I", data[offset:offset+4])[0]
|
||||
kind = data[offset+4:offset+8]
|
||||
if kind == b"IDAT":
|
||||
compressed.append(data[offset+8:offset+8+size])
|
||||
offset += 12 + size
|
||||
if kind == b"IEND":
|
||||
break
|
||||
|
||||
raw = zlib.decompress(b"".join(compressed))
|
||||
stride = width * 4
|
||||
previous = bytearray(stride)
|
||||
position = 0
|
||||
|
||||
for row_number in range(y + 1):
|
||||
filter_type = raw[position]
|
||||
position += 1
|
||||
row = bytearray(raw[position:position+stride])
|
||||
position += stride
|
||||
|
||||
for i in range(stride):
|
||||
left = row[i-4] if i >= 4 else 0
|
||||
above = previous[i]
|
||||
upper_left = previous[i-4] if i >= 4 else 0
|
||||
|
||||
if filter_type == 1:
|
||||
predictor = left
|
||||
elif filter_type == 2:
|
||||
predictor = above
|
||||
elif filter_type == 3:
|
||||
predictor = (left + above) // 2
|
||||
elif filter_type == 4:
|
||||
prediction = left + above - upper_left
|
||||
da = abs(prediction - left)
|
||||
db = abs(prediction - above)
|
||||
dc = abs(prediction - upper_left)
|
||||
predictor = left if da <= db and da <= dc else (
|
||||
above if db <= dc else upper_left
|
||||
)
|
||||
elif filter_type == 0:
|
||||
predictor = 0
|
||||
else:
|
||||
raise ValueError(f"unsupported PNG filter: {filter_type}")
|
||||
|
||||
row[i] = (row[i] + predictor) & 0xFF
|
||||
|
||||
previous = row
|
||||
|
||||
return previous[x * 4 + 3]
|
||||
|
||||
|
||||
class AppIconAssetTests(unittest.TestCase):
|
||||
def test_artwork_source_is_square(self):
|
||||
w, h = jpeg_dimensions(SOURCE)
|
||||
self.assertEqual(w, h)
|
||||
self.assertGreaterEqual(w, 256)
|
||||
|
||||
def test_reference_layout_uses_centered_1024_grid(self):
|
||||
layout = json.loads(LAYOUT.read_text())
|
||||
self.assertEqual(layout["canvasPixels"], 1024)
|
||||
self.assertEqual(layout["bodyPixels"], 824)
|
||||
self.assertEqual(layout["bodyOrigin"], [100, 100])
|
||||
self.assertGreater(layout["superellipseExponent"], 3)
|
||||
self.assertLess(layout["superellipseExponent"], 6)
|
||||
self.assertEqual(len(layout["sourceCrop"]), 4)
|
||||
self.assertTrue(all(0 <= x <= 1 for x in layout["sourceCrop"]))
|
||||
self.assertLess(layout["sourceCrop"][0], layout["sourceCrop"][2])
|
||||
self.assertLess(layout["sourceCrop"][1], layout["sourceCrop"][3])
|
||||
|
||||
def test_master_has_transparent_corners(self):
|
||||
self.assertEqual(png_info(MASTER), (1024, 1024, 6))
|
||||
for point in [(0, 0), (1023, 0), (0, 1023), (1023, 1023)]:
|
||||
with self.subTest(corner=point):
|
||||
self.assertEqual(alpha_at(MASTER, *point), 0)
|
||||
self.assertEqual(alpha_at(MASTER, 512, 512), 255)
|
||||
|
||||
def test_preview_is_real_png_and_linked_from_readme(self):
|
||||
w, h, color = png_info(PREVIEW)
|
||||
self.assertEqual((w, h, color), (256, 256, 6))
|
||||
self.assertEqual(alpha_at(PREVIEW, 0, 0), 0)
|
||||
readme = (ROOT / "README.md").read_text()
|
||||
self.assertIn("Resources/AppIcon-preview.png", readme)
|
||||
|
||||
def test_svg_is_editable_and_pipeline_uses_master(self):
|
||||
svg = SVG.read_text()
|
||||
self.assertIn('viewBox="0 0 1024 1024"', svg)
|
||||
self.assertIn('clipPath id="icon-body"', svg)
|
||||
self.assertIn('IconArtwork.jpg', svg)
|
||||
script = (ROOT / "scripts/icon.sh").read_text()
|
||||
self.assertIn("Resources/AppIcon.png", script)
|
||||
self.assertIn("iconutil -c icns", script)
|
||||
self.assertIn("sips -s format png", script)
|
||||
|
||||
@unittest.skipUnless(ICONSET.exists(), "generated macOS iconset unavailable")
|
||||
def test_iconset_contains_all_ten_variants(self):
|
||||
self.assertEqual({p.name for p in ICONSET.glob("*.png")}, set(SIZE_NAMES))
|
||||
for name, pixels in SIZE_NAMES.items():
|
||||
with self.subTest(filename=name):
|
||||
w, h, color = png_info(ICONSET / name)
|
||||
self.assertEqual((w, h), (pixels, pixels))
|
||||
self.assertIn(color, (2, 6))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Loading…
Reference in a new issue