Compare commits
No commits in common. "backup" and "main" have entirely different histories.
22 changed files with 297 additions and 421 deletions
43
README.md
43
README.md
|
|
@ -1,12 +1,16 @@
|
||||||
# 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.
|
||||||
|
|
||||||
The icon is a SFW cartoon of someone thoroughly fed up with keyboard
|
macoc keeps input switching simple. The icon is an SFW illustration
|
||||||
layout switching, complete with a literally hot seat.
|
of exactly how frustrating the old Caps Lock behavior can feel.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|
@ -16,6 +20,38 @@ 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.
|
||||||
|
|
@ -114,8 +150,7 @@ 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.
|
||||||
See [CI and release instructions](docs/ci.md). Fully automated macOS
|
Fully automated macOS binary releases require a real Mac runner or another Mac build service.
|
||||||
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.
|
||||||
|
|
|
||||||
BIN
Resources/AppIcon-preview.png
Normal file
BIN
Resources/AppIcon-preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
BIN
Resources/AppIcon.png
Normal file
BIN
Resources/AppIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 485 KiB |
19
Resources/AppIconArtwork.svg
Normal file
19
Resources/AppIconArtwork.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 36 KiB |
18
Resources/AppIconGrid.svg
Normal file
18
Resources/AppIconGrid.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 13 KiB |
17
Resources/AppIconLayout.json
Normal file
17
Resources/AppIconLayout.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"canvasPixels": 1024,
|
||||||
|
"bodyPixels": 824,
|
||||||
|
"bodyOrigin": [
|
||||||
|
100,
|
||||||
|
100
|
||||||
|
],
|
||||||
|
"superellipseExponent": 4.4,
|
||||||
|
"sourceCrop": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"sourceFile": "IconArtwork.jpg",
|
||||||
|
"legacyFormat": "icns"
|
||||||
|
}
|
||||||
BIN
Resources/IconArtwork.jpg
Normal file
BIN
Resources/IconArtwork.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
Binary file not shown.
|
Before 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
66
docs/ci.md
|
|
@ -1,66 +0,0 @@
|
||||||
# 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/
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
> 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.
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
# 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,22 +2,29 @@
|
||||||
source "$(dirname "$0")/common.sh"
|
source "$(dirname "$0")/common.sh"
|
||||||
macos_only
|
macos_only
|
||||||
|
|
||||||
if [[ ! -f "$SOURCE_ICON" ]]; then
|
# The composited Sonoma icon is committed as 1024px RGBA. The transparent
|
||||||
echo "Missing icon source: $SOURCE_ICON" >&2
|
# 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
|
||||||
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" "$SOURCE_ICON" \
|
sips -s format png -z "$size" "$size" "$master" \
|
||||||
--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" "$SOURCE_ICON" \
|
sips -s format png -z "$pixels" "$pixels" "$master" \
|
||||||
--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,7 +5,10 @@ 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/IconSource.jpg
|
test -s Resources/AppIcon.png
|
||||||
|
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
|
||||||
|
|
|
||||||
182
scripts/tests/test_icon_assets.py
Normal file
182
scripts/tests/test_icon_assets.py
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
#!/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