Compare commits

...

No commits in common. "main" and "backup" have entirely different histories.
main ... backup

22 changed files with 421 additions and 297 deletions

View file

@ -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

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

View file

@ -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 {

View file

@ -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"
}

View file

@ -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 {

View file

@ -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

View file

@ -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
View 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/

View 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.

View 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.

View 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.

View 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.

View 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.

View file

@ -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"

View file

@ -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

View file

@ -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)