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
|
# 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
|
Native macOS menu-bar layout switcher for macOS Sonoma (14+) without
|
||||||
the stock Caps Lock delay. Plain **Caps** switches to the next configured
|
the stock Caps Lock delay. Plain **Caps** switches to the next configured
|
||||||
input source on **release**, including during fast typing. **Option+Caps**
|
input source on **release**, including during fast typing. **Option+Caps**
|
||||||
in either order toggles the real system Caps Lock state.
|
in either order toggles the real system Caps Lock state.
|
||||||
|
|
||||||
macoc keeps input switching simple. The icon is an SFW illustration
|
The icon is a SFW cartoon of someone thoroughly fed up with keyboard
|
||||||
of exactly how frustrating the old Caps Lock behavior can feel.
|
layout switching, complete with a literally hot seat.
|
||||||
|
|
||||||
## Architecture
|
## 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/
|
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
|
## Build and install
|
||||||
|
|
||||||
Requires Apple's Command Line Tools (not the Xcode IDE). Task is optional.
|
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
|
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/package.sh (or task package), then execute
|
||||||
bash scripts/publish-release.sh with a repository-scoped Forgejo token.
|
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
|
Ad-hoc artifacts are suited to local evaluation; public distribution
|
||||||
requires Developer ID signing and Apple notarization.
|
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 {
|
var currentDisplayName: String {
|
||||||
guard let source = currentSource() else {
|
guard let source = currentSource() else {
|
||||||
return "-"
|
return "—"
|
||||||
}
|
}
|
||||||
return property(source, key: kTISPropertyLocalizedName)
|
return property(source, key: kTISPropertyLocalizedName)
|
||||||
?? property(source, key: kTISPropertyInputSourceID)
|
?? property(source, key: kTISPropertyInputSourceID)
|
||||||
?? "-"
|
?? "—"
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentShortName: String {
|
var currentShortName: String {
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,9 @@ struct LoginItemManager {
|
||||||
case .enabled, .notRegistered:
|
case .enabled, .notRegistered:
|
||||||
return "Launch at Login"
|
return "Launch at Login"
|
||||||
case .requiresApproval:
|
case .requiresApproval:
|
||||||
return "Launch at Login - approval required"
|
return "Launch at Login — approval required"
|
||||||
case .notFound:
|
case .notFound:
|
||||||
return "Launch at Login - unavailable"
|
return "Launch at Login — unavailable"
|
||||||
@unknown default:
|
@unknown default:
|
||||||
return "Launch at Login"
|
return "Launch at Login"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ final class MacOSInputController {
|
||||||
return "Unknown (check Keyboard settings)"
|
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 {
|
var currentInputSourceName: String {
|
||||||
|
|
|
||||||
|
|
@ -211,7 +211,7 @@ final class StatusBarController: NSObject, NSMenuDelegate {
|
||||||
statusItem.button?.title = inputController.isRunning
|
statusItem.button?.title = inputController.isRunning
|
||||||
? inputController.currentInputSourceShortName
|
? inputController.currentInputSourceShortName
|
||||||
: "Caps!"
|
: "Caps!"
|
||||||
statusItem.button?.toolTip = "macoc - \(name)"
|
statusItem.button?.toolTip = "macoc — \(name)"
|
||||||
|
|
||||||
currentLayoutItem.title = "Current layout: \(name)"
|
currentLayoutItem.title = "Current layout: \(name)"
|
||||||
diagnosticsItem.title = inputController.diagnosticsSummary
|
diagnosticsItem.title = inputController.diagnosticsSummary
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ case .doctor:
|
||||||
let remapper = HIDRemappingController()
|
let remapper = HIDRemappingController()
|
||||||
do {
|
do {
|
||||||
try remapper.inspect()
|
try remapper.inspect()
|
||||||
print("macoc - native HID diagnostic")
|
print("macoc — native HID diagnostic")
|
||||||
print("Keyboard services: \(remapper.keyboardServiceCount)")
|
print("Keyboard services: \(remapper.keyboardServiceCount)")
|
||||||
print("Caps → F19 mappings: \(remapper.mappedServiceCount)")
|
print("Caps → F19 mappings: \(remapper.mappedServiceCount)")
|
||||||
if remapper.mappedServiceCount == remapper.keyboardServiceCount {
|
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"
|
source "$(dirname "$0")/common.sh"
|
||||||
macos_only
|
macos_only
|
||||||
|
|
||||||
# The composited Sonoma icon is committed as 1024px RGBA. The transparent
|
if [[ ! -f "$SOURCE_ICON" ]]; then
|
||||||
# outer margins and squircle silhouette must be preserved at every scale.
|
echo "Missing icon source: $SOURCE_ICON" >&2
|
||||||
# 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
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
iconset="$ROOT/build/macoc.iconset"
|
iconset="$ROOT/build/macoc.iconset"
|
||||||
rm -rf "$iconset"
|
|
||||||
mkdir -p "$iconset"
|
mkdir -p "$iconset"
|
||||||
|
|
||||||
for size in 16 32 128 256 512; do
|
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
|
--out "$iconset/icon_${size}x${size}.png" >/dev/null
|
||||||
done
|
done
|
||||||
|
|
||||||
for specification in "16 32" "32 64" "128 256" "256 512" "512 1024"; do
|
for specification in "16 32" "32 64" "128 256" "256 512" "512 1024"; do
|
||||||
read -r name pixels <<<"$specification"
|
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
|
--out "$iconset/icon_${name}x${name}@2x.png" >/dev/null
|
||||||
done
|
done
|
||||||
|
|
||||||
mkdir -p "$APP_PATH/Contents/Resources"
|
mkdir -p "$APP_PATH/Contents/Resources"
|
||||||
iconutil -c icns "$iconset" -o "$APP_PATH/Contents/Resources/AppIcon.icns"
|
iconutil -c icns "$iconset" -o "$APP_PATH/Contents/Resources/AppIcon.icns"
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,7 @@ git diff --check
|
||||||
git diff --cached --check
|
git diff --cached --check
|
||||||
test -f Package.swift
|
test -f Package.swift
|
||||||
test -f Resources/Info.plist
|
test -f Resources/Info.plist
|
||||||
test -s Resources/AppIcon.png
|
test -s Resources/IconSource.jpg
|
||||||
test -s Resources/AppIcon-preview.png
|
|
||||||
test -s Resources/IconArtwork.jpg
|
|
||||||
python3 scripts/tests/test_icon_assets.py
|
|
||||||
test -f Sources/CapswitchApp/MacOSInputController.swift
|
test -f Sources/CapswitchApp/MacOSInputController.swift
|
||||||
test -f Sources/CapswitchApp/InputSourceManager.swift
|
test -f Sources/CapswitchApp/InputSourceManager.swift
|
||||||
test -f Sources/CapswitchApp/ApplicationRelauncher.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