feat: brand macoc add icon stable restart scripts and Forgejo CI
Some checks failed
macoc macOS / build (push) Has been cancelled

This commit is contained in:
root 2026-10-26 00:22:35 +03:00
parent e16f9094bc
commit 009c7e5a33
29 changed files with 579 additions and 242 deletions

View file

@ -0,0 +1,37 @@
name: macoc macOS
on:
push:
branches:
- main
tags:
- 'v*'
workflow_dispatch:
jobs:
build:
# A dedicated macOS Forgejo runner is required; register label macos:host.
# It must have Command Line Tools. There is no Linux/macOS cross-build.
runs-on: macos
steps:
- name: Checkout
uses: https://code.forgejo.org/actions/checkout@v4
with:
persist-credentials: false
- name: Run tests and static checks
run: bash scripts/verify.sh
- name: Build and package macOS app
run: bash scripts/package.sh
env:
MACOC_CODESIGN_IDENTITY: "${{ vars.MACOC_CODESIGN_IDENTITY }}"
- name: Publish Forgejo release on version tags
if: startsWith(forgejo.ref, 'refs/tags/v')
env:
FORGEJO_TOKEN: "${{ secrets.FORGEJO_TOKEN }}"
FORGEJO_SERVER_URL: "${{ forgejo.server_url }}"
FORGEJO_REPOSITORY: "${{ forgejo.repository }}"
FORGEJO_REF_NAME: "${{ forgejo.ref_name }}"
run: bash scripts/publish-release.sh

1
.gitignore vendored
View file

@ -3,3 +3,4 @@ build/
.swiftpm/
.task/
.build/
dist/

View file

@ -2,10 +2,10 @@
import PackageDescription
let package = Package(
name: "Capswitch",
name: "macoc",
platforms: [.macOS(.v14)],
products: [
.executable(name: "Capswitch", targets: ["Capswitch"]),
.executable(name: "macoc", targets: ["macoc"]),
],
targets: [
.target(
@ -13,7 +13,7 @@ let package = Package(
path: "Sources/Capswitch"
),
.executableTarget(
name: "Capswitch",
name: "macoc",
dependencies: ["CapswitchCore", "CapswitchHID"],
path: "Sources/CapswitchApp",
linkerSettings: [

170
README.md
View file

@ -1,116 +1,128 @@
# Capswitch
# macoc
Native macOS menu-bar keyboard layout switcher, targeting macOS 14+.
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.
- Plain physical Caps: switch to the next enabled input source on **release**,
bypassing the stock Caps delay and input-source HUD.
- Caps + Option in either order: toggle the real system Caps Lock state.
The icon is a SFW cartoon of someone thoroughly fed up with keyboard
layout switching, complete with a literally hot seat.
## Architecture
The signed Capswitch executable itself uses IOKit to remap physical Caps
(HID usage 0x39) to F19 (usage 0x6E). Its active CoreGraphics event tap
suppresses F19 down/up events and switches through Carbon TIS on F19 release.
The small native C bridge is compiled into the app; there is no external
hidutil process, driver, helper, LaunchAgent or root component.
Caps Lock is remapped to F19 via in-process Apple IOKit APIs, then suppressed
by a CoreGraphics event tap. Layouts switch through Carbon TIS. No drivers,
root daemons, third-party input libraries or hidutil permission required.
Apple API reference: https://developer.apple.com/library/archive/technotes/tn2450/
Apple technical note: https://developer.apple.com/library/archive/technotes/tn2450/
## Build, install and autostart
## Build and install
Install Apple's Command Line Tools and the Task runner. Full Xcode is not
required. Quit any running Capswitch from its status-bar menu before rebuilding.
Requires Apple's Command Line Tools (not the Xcode IDE). Task is optional.
For development:
Quit any running **macoc** or legacy **Capswitch** before rebuilding.
From this repository:
git pull origin main
task test
task bundle
open build/Capswitch.app
bash scripts/test.sh
bash scripts/install.sh
For daily use, install without sudo into ~/Applications/Capswitch.app:
With Task installed:
git pull origin main
task test
task install
The Taskfile builds, installs and opens the installed copy.
Installation creates a signed macoc.app with the .icns icon in the stable
user-owned location ~/Applications/macoc.app, then starts it through
LaunchServices. No sudo is needed.
Alternatively, open the development build and choose **Install to
~/Applications...** in the menu. Capswitch copies itself and restarts into the
installed copy. If an older installed Capswitch is still running, quit it
before reinstalling. Only bundles with the same bundle ID can be replaced.
For development without installing: bash scripts/bundle.sh or task bundle.
From the installed copy, click **Launch at Login** in the menu. This uses
Apple's SMAppService.mainApp, not an ad-hoc LaunchAgent. If the menu says
approval required, open **General -> Login Items** in System Settings and
approve Capswitch. Click the same menu item to disable auto-start.
Launch at Login is disabled for development builds to avoid registering
a temporary build/ path.
The menu offers **Restart macoc**, **Install to ~/Applications…** and
**Launch at Login**, registered via Apple's SMAppService.mainApp. If macOS
requests approval, open System Settings -> General -> Login Items.
The menu also has **Restart Capswitch**. It restores the Caps HID mapping
before exiting and launches a new copy of the same executable after the
old process is gone.
### Migrating from Capswitch
The app has a stable bundle identifier (dev.local.Capswitch) and is signed
ad-hoc. macOS may require re-approval for Accessibility/Input Monitoring
when moving from build/ to ~/Applications or replacing an ad-hoc signature.
The name and executable are now macoc; the existing bundle identifier
remains dev.local.Capswitch to preserve old Accessibility grants and HID
ownership data where macOS permits. Do not run the old Capswitch and macoc
simultaneously. If the old ~/Applications/Capswitch.app is installed, quit
it and disable its Login Item before installing macoc. Then you may delete
the old app bundle.
## Privacy permissions
## Code signing and repeat Accessibility prompts
On first launch the app requests permissions under its own identity:
- Accessibility to suppress F19.
- Input Monitoring when macOS needs it to observe keyboard events.
Restart now uses LaunchServices (open -n -a) instead of executing a raw
binary under Contents/MacOS. That preserves the macOS .app attribution.
Grant permissions to Capswitch only. No system executable needs extra
permissions. Apple requires the user to approve them in System Settings.
For identical app binaries at the same path, Accessibility approval normally
persists through restarts. But ad-hoc signing does **not** provide a stable
designated requirement across code changes: each rebuild may require
approval again.
Disable the stock macOS Caps layout toggle via
**System Settings -> Keyboard -> Text Input -> Edit ->
Use Caps Lock to switch to/from ABC**. Keep the key assigned to its normal
Caps Lock role in Modifier Keys (not No Action).
Capswitch does not secretly change these preferences.
For stable identity across **rebuilt versions**, create one persistent
local Code Signing certificate in Keychain Access named
**macoc Local Signing** (Certificate Assistant -> Create a Certificate ->
Self Signed Root -> Code Signing). Run bash scripts/setup-signing.sh or
task signing-setup for the instructions. The build auto-detects that identity,
or accepts MACOC_CODESIGN_IDENTITY for an existing signing certificate.
It falls back to ad-hoc signing with an explicit warning otherwise.
## Menu and diagnostics
Moving to the installed path or changing the signer can still require
**one new approval**. Keep the signing identity and app path stable.
The status-bar menu shows current layout, native HID keyboard service
mapping count, event tap status, permission status and diagnostics.
Reference: https://developer.apple.com/documentation/technotes/tn3127-inside-code-signing-requirements
F19 down/up counts; input source switches
macoc cannot silently grant itself TCC permissions. macOS still asks the
user to allow Accessibility (and optionally Input Monitoring) for the app,
not for a system binary.
No typed characters are recorded. If F19 counts increase but switches do not,
inspect enabled TIS input sources. If counts remain zero, inspect the
HID mapping and event tap status. Pause/normal Quit restore the physical
Caps key and preserve unrelated keyboard mappings.
Disable macOS's own "Use Caps Lock to switch to/from ABC" under
System Settings -> Keyboard -> Text Input -> Edit. Keep physical Caps
assigned its normal modifier role rather than No Action.
Read-only native diagnostic mode:
## Taskfile commands and shell equivalents
task doctor
| Taskfile | No Task required |
| --- | --- |
| task lint | bash scripts/lint.sh |
| task test | bash scripts/test.sh |
| task verify | bash scripts/verify.sh |
| task build | bash scripts/build.sh |
| task icon | bash scripts/icon.sh |
| task sign | bash scripts/sign.sh |
| task bundle | bash scripts/bundle.sh |
| task install | bash scripts/install.sh |
| task doctor | bash scripts/doctor.sh |
| task clean | bash scripts/clean.sh |
| task package | bash scripts/package.sh |
| task publish | bash scripts/publish-release.sh |
| task signing-setup | bash scripts/setup-signing.sh |
This executes Capswitch's own binary, not hidutil, and does not change keys.
## Forgejo CI and releases
## HID recovery and limitations
The workflow at .forgejo/workflows/macos.yml builds and tests on pushes to
main. A pushed tag such as v0.3.0 also creates a Forgejo release with a
macOS .app ZIP and SHA-256 checksum, through the built-in scoped token.
Capswitch preserves pre-existing key mappings and records ownership of
its added Caps-to-F19 pair per keyboard service. It removes only owned
pairs when paused or quit. A forcibly terminated app may leave a pair
active until next clean Capswitch launch/quit, keyboard service removal,
or reboot. To recover, reopen Capswitch and Quit normally.
**A real Mac Forgejo runner labelled macos is required.** The Linux server
cannot build AppKit or IOKit. See [CI setup](docs/ci.md). Until a Mac runner
is registered and its job succeeds, no release build has been produced.
If installation or restart fails to restore Caps, the app displays an
error instead of silently quitting. If macOS loses permission, it surfaces
that status in the menu.
A release archive signed ad-hoc is intended for local evaluation.
To distribute broadly without Gatekeeper warnings, use Developer ID
signing and Apple's notarization service (not yet configured here).
Development tasks:
## Diagnostics and recovery
task verify
task lint
task test
task build
task bundle
task install
task doctor
task clean
The status-bar menu shows layout, keyboard HID mapping count, event-tap state,
permission grants, and F19 down/up counts (no typed characters are recorded).
Run bash scripts/doctor.sh for read-only native HID diagnostics.
The Linux RSB host can run static checks but has no Apple AppKit/IOKit SDK.
The macOS build and real keyboard behavior must be verified on macOS.
Pause or Quit normally restores Caps and preserves unrelated remappings.
If the process is killed during a remap, restart and Quit normally; macOS
usually resets mappings when a keyboard service disappears or after reboot.
The Linux development VM verifies shell syntax and static source checks,
not macOS compilation or live TCC behavior.

BIN
Resources/IconSource.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

View file

@ -3,19 +3,21 @@
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>Capswitch</string>
<string>macoc</string>
<key>CFBundleExecutable</key>
<string>Capswitch</string>
<string>macoc</string>
<key>CFBundleIdentifier</key>
<string>dev.local.Capswitch</string>
<key>CFBundleName</key>
<string>Capswitch</string>
<string>macoc</string>
<key>CFBundleIconFile</key>
<string>AppIcon.icns</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.2.0</string>
<string>0.3.0</string>
<key>CFBundleVersion</key>
<string>2</string>
<string>3</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>

View file

@ -12,13 +12,13 @@ enum AppInstallationError: LocalizedError {
var errorDescription: String? {
switch self {
case .invalidSource:
return "Capswitch must be launched from a valid .app bundle."
return "macoc must be launched from a valid .app bundle."
case .alreadyInstalled:
return "This copy of Capswitch is already installed."
return "This copy of macoc is already installed."
case .destinationContainsAnotherApp:
return "The destination contains another application. It will not be overwritten."
case .installedCopyRunning:
return "Quit the installed Capswitch first, then retry installation."
return "Quit the installed macoc first, then retry installation."
case .unsafeDestination:
return "The installation destination is a symbolic link. No files were changed."
case let .rollbackFailed(backupPath):
@ -36,7 +36,7 @@ final class AppInstallationManager {
var installedURL: URL {
fileManager.homeDirectoryForCurrentUser
.appendingPathComponent("Applications", isDirectory: true)
.appendingPathComponent("Capswitch.app", isDirectory: true)
.appendingPathComponent("macoc.app", isDirectory: true)
}
var isRunningInstalledCopy: Bool {
@ -86,10 +86,10 @@ final class AppInstallationManager {
let token = UUID().uuidString
let staging = parent.appendingPathComponent(
".Capswitch-stage-\(token).app", isDirectory: true
".macoc-stage-\(token).app", isDirectory: true
)
let backup = parent.appendingPathComponent(
".Capswitch-backup-\(token).app", isDirectory: true
".macoc-backup-\(token).app", isDirectory: true
)
defer {
@ -102,7 +102,7 @@ final class AppInstallationManager {
guard Bundle(url: staging)?.bundleIdentifier == expectedBundleID,
fileManager.isExecutableFile(
atPath: staging.appendingPathComponent("Contents/MacOS/Capswitch").path
atPath: staging.appendingPathComponent("Contents/MacOS/macoc").path
) else {
throw AppInstallationError.invalidSource
}

View file

@ -8,7 +8,7 @@ enum ApplicationLifecycleError: LocalizedError {
var errorDescription: String? {
switch self {
case .missingExecutable:
return "Unable to locate the bundled Capswitch executable."
return "Unable to locate the bundled macoc executable."
case let .couldNotRestoreCaps(details):
return "Caps Lock was not restored: \(details)"
}
@ -54,7 +54,7 @@ final class ApplicationLifecycleController {
}
func restartInstalledCopy(at applicationURL: URL) throws {
let executableURL = applicationURL.appendingPathComponent("Contents/MacOS/Capswitch")
let executableURL = applicationURL.appendingPathComponent("Contents/MacOS/macoc")
try relaunch(executableURL: executableURL)
}

View file

@ -2,41 +2,56 @@ import Darwin
import Foundation
enum RelaunchError: LocalizedError {
case missingExecutable
case missingBundle
case failedToLaunch(Int32)
var errorDescription: String? {
switch self {
case .missingExecutable:
return "The application executable was not found."
case .missingBundle:
return "The macoc .app bundle is not available."
case let .failedToLaunch(code):
return "LaunchServices could not restart macoc (open exit code \(code))."
}
}
}
/// Starts the same bundled executable. The child waits for the old PID to
/// disappear before starting AppKit or the HID remapper.
/// Relaunch through LaunchServices, never by directly executing
/// Contents/MacOS/macoc. Direct binary execution can lose macOS app/TCC
/// identity and cause fresh Accessibility prompts.
///
/// /usr/bin/open only launches the signed app; it is not granted input
/// permissions and never interacts with the HID remapping.
enum ApplicationRelauncher {
static func spawn(executableURL: URL) throws {
guard FileManager.default.isExecutableFile(atPath: executableURL.path) else {
throw RelaunchError.missingExecutable
let appURL = executableURL.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
guard appURL.pathExtension == "app",
FileManager.default.fileExists(atPath: appURL.path),
FileManager.default.isExecutableFile(atPath: executableURL.path) else {
throw RelaunchError.missingBundle
}
let child = Process()
child.executableURL = executableURL
child.arguments = [
"--wait-for-pid=\(ProcessInfo.processInfo.processIdentifier)"
let launcher = Process()
launcher.executableURL = URL(fileURLWithPath: "/usr/bin/open")
launcher.arguments = [
"-n", "-a", appURL.path,
"--args", "--wait-for-pid=\(ProcessInfo.processInfo.processIdentifier)"
]
child.standardOutput = FileHandle.nullDevice
child.standardError = FileHandle.nullDevice
try child.run()
launcher.standardOutput = FileHandle.nullDevice
launcher.standardError = FileHandle.nullDevice
try launcher.run()
launcher.waitUntilExit()
guard launcher.terminationStatus == 0 else {
throw RelaunchError.failedToLaunch(launcher.terminationStatus)
}
}
static func waitForPreviousProcess(_ parentPID: pid_t) -> Bool {
guard parentPID > 1 else {
return false
}
guard parentPID > 1 else { return false }
// Bounded wait: never start a second keyboard remapper when the
// original application refuses to terminate.
// New instance must not own the HID mapping until the old one exits.
for _ in 0..<200 {
if kill(parentPID, 0) == -1 {
return errno == ESRCH

View file

@ -7,7 +7,7 @@ enum LoginItemError: LocalizedError {
var errorDescription: String? {
switch self {
case .installFirst:
return "Install Capswitch to ~/Applications before enabling launch at login."
return "Install macoc to ~/Applications before enabling launch at login."
}
}
}

View file

@ -13,9 +13,9 @@ enum InputControllerError: LocalizedError {
var errorDescription: String? {
switch self {
case .accessibilityRequired:
return "Accessibility permission is required. Open the Capswitch menu to grant it."
return "Accessibility permission is required. Open the macoc menu to grant it."
case .inputMonitoringRequired:
return "Keyboard tap refused. Grant Input Monitoring from the Capswitch menu."
return "Keyboard tap refused. Grant Input Monitoring from the macoc menu."
case .eventTapUnavailable:
return "macOS did not allow the keyboard event tap. Check Accessibility and Input Monitoring permissions."
}
@ -159,7 +159,7 @@ final class MacOSInputController {
func stop() -> Bool {
// Restore the actual Caps key before disabling the F19 interceptor.
// If restoration fails, refuse a normal Pause/Quit so F19 cannot be
// left unhandled by an otherwise healthy running Capswitch.
// left unhandled by an otherwise healthy running macoc.
do {
try remapper.restoreIfOwned()
} catch {
@ -257,10 +257,9 @@ final class MacOSInputController {
_ = AXIsProcessTrustedWithOptions(options)
}
if !inputMonitoringTrusted {
// Request Input Monitoring for our own application identity.
_ = CGRequestListenEventAccess()
}
// Input Monitoring is optional for an Accessibility-authorized active
// event tap. Do not prompt on every launch when it is not needed.
// If creating the tap fails, the menu exposes an explicit grant path.
}
func requestInputMonitoringPermission() {

View file

@ -74,7 +74,7 @@ final class StatusBarController: NSObject, NSMenuDelegate {
keyEquivalent: ""
)
private let restartItem = NSMenuItem(
title: "Restart Capswitch",
title: "Restart macoc",
action: #selector(restartApplication),
keyEquivalent: ""
)
@ -143,7 +143,7 @@ final class StatusBarController: NSObject, NSMenuDelegate {
menu.addItem(NSMenuItem.separator())
let quitItem = NSMenuItem(
title: "Quit Capswitch",
title: "Quit macoc",
action: #selector(quit),
keyEquivalent: "q"
)
@ -165,7 +165,7 @@ final class StatusBarController: NSObject, NSMenuDelegate {
}
statusItem.menu = menu
statusItem.button?.toolTip = "Capswitch"
statusItem.button?.toolTip = "macoc"
}
private func refreshController() {
@ -211,7 +211,7 @@ final class StatusBarController: NSObject, NSMenuDelegate {
statusItem.button?.title = inputController.isRunning
? inputController.currentInputSourceShortName
: "Caps!"
statusItem.button?.toolTip = "Capswitch — \(name)"
statusItem.button?.toolTip = "macoc — \(name)"
currentLayoutItem.title = "Current layout: \(name)"
diagnosticsItem.title = inputController.diagnosticsSummary
@ -310,9 +310,9 @@ final class StatusBarController: NSObject, NSMenuDelegate {
@objc private func installApplication() {
let alert = NSAlert()
alert.messageText = "Install Capswitch?"
alert.messageText = "Install macoc?"
alert.informativeText =
"Copy this version to ~/Applications/Capswitch.app and launch it from there. " +
"Copy this version to ~/Applications/macoc.app and launch it from there. " +
"No administrator privileges are required."
alert.addButton(withTitle: "Install and Launch")
alert.addButton(withTitle: "Cancel")
@ -334,7 +334,7 @@ final class StatusBarController: NSObject, NSMenuDelegate {
} catch {
showError(
"Installed, but could not relaunch",
message: "Capswitch was installed to ~/Applications. " +
message: "macoc was installed to ~/Applications. " +
error.localizedDescription
)
}

View file

@ -11,7 +11,7 @@ case .doctor:
let remapper = HIDRemappingController()
do {
try remapper.inspect()
print("Capswitch — native HID diagnostic")
print("macoc — native HID diagnostic")
print("Keyboard services: \(remapper.keyboardServiceCount)")
print("Caps → F19 mappings: \(remapper.mappedServiceCount)")
if remapper.mappedServiceCount == remapper.keyboardServiceCount {
@ -21,23 +21,23 @@ case .doctor:
}
exit(0)
} catch {
fputs("Capswitch HID check: \(error.localizedDescription)\n", stderr)
fputs("macoc HID check: \(error.localizedDescription)\n", stderr)
exit(1)
}
case .install:
do {
let location = try AppInstallationManager().install()
print("Installed Capswitch: \(location.path)")
print("Installed macoc: \(location.path)")
exit(0)
} catch {
fputs("Capswitch install: \(error.localizedDescription)\n", stderr)
fputs("macoc install: \(error.localizedDescription)\n", stderr)
exit(1)
}
case let .waitForParent(pid):
guard ApplicationRelauncher.waitForPreviousProcess(pid) else {
fputs("Capswitch restart aborted: previous instance did not exit.\n", stderr)
fputs("macoc restart aborted: previous instance did not exit.\n", stderr)
exit(1)
}

View file

@ -1,124 +1,71 @@
version: '3'
vars:
APP_NAME: Capswitch
APP_DIR: build/Capswitch.app
RELEASE_BINARY: .build/release/Capswitch
tasks:
default:
cmds:
- task: verify
test:
desc: Run XCTest on macOS, or report the host limitation on Linux
cmds:
- |
if command -v swift >/dev/null 2>&1 && [ "$(uname -s)" = "Darwin" ]; then
swift test
else
echo "SKIP: Swift/XCTest requires macOS with Command Line Tools"
fi
build:
desc: Build the macOS executable with SwiftPM
cmds:
- |
if [ "$(uname -s)" != "Darwin" ]; then
echo "SKIP: macOS build requires Darwin with Command Line Tools"
exit 0
fi
swift build -c release
bundle:
desc: Assemble and ad-hoc sign the menu-bar .app
deps: [build]
cmds:
- |
if [ "$(uname -s)" != "Darwin" ]; then
echo "SKIP: macOS app bundle requires Darwin with Command Line Tools"
exit 0
fi
if pgrep -x Capswitch >/dev/null 2>&1; then
echo "ERROR: Quit running Capswitch from its menu before rebuilding." >&2
exit 1
fi
rm -rf "{{.APP_DIR}}"
mkdir -p "{{.APP_DIR}}/Contents/MacOS" "{{.APP_DIR}}/Contents/Resources"
cp "{{.RELEASE_BINARY}}" "{{.APP_DIR}}/Contents/MacOS/{{.APP_NAME}}"
cp Resources/Info.plist "{{.APP_DIR}}/Contents/Info.plist"
codesign --force --sign - "{{.APP_DIR}}"
install:
desc: Install Capswitch into ~/Applications and open it (no sudo)
deps: [bundle]
cmds:
- |
if [ "$(uname -s)" != "Darwin" ]; then
echo "SKIP: installation requires macOS."
exit 0
fi
"{{.APP_DIR}}/Contents/MacOS/{{.APP_NAME}}" --install
open "$HOME/Applications/{{.APP_NAME}}.app"
- bash scripts/verify.sh
lint:
desc: Run host-independent source and bundle checks
desc: Run shell and source checks
cmds:
- git diff --check
- test -f Package.swift
- test -f Resources/Info.plist
- test -f Sources/Capswitch/CapsGestureMachine.swift
- test -f Sources/CapswitchApp/MacOSInputController.swift
- grep -q "LSUIElement" Resources/Info.plist
- grep -q "CGEventType.keyDown" Sources/CapswitchApp/MacOSInputController.swift
- grep -q "CGEventType.keyUp" Sources/CapswitchApp/MacOSInputController.swift
- grep -q "keyboardEventKeycode" Sources/CapswitchApp/MacOSInputController.swift
- grep -q "CapsInputRouter.f19VirtualKeyCode" Sources/CapswitchApp/MacOSInputController.swift
- test -f Sources/CapswitchApp/HIDRemappingController.swift
- test -f Sources/CapswitchApp/InputSourceManager.swift
- test -f Sources/CapswitchApp/ApplicationRelauncher.swift
- test -f Sources/CapswitchApp/ApplicationLifecycleController.swift
- test -f Sources/CapswitchApp/LoginItemManager.swift
- test -f Sources/CapswitchApp/AppInstallationManager.swift
- grep -q "SMAppService.mainApp" Sources/CapswitchApp/LoginItemManager.swift
- test -f Tests/CapswitchCoreTests/LaunchArgumentsTests.swift
- test -f Sources/CapswitchHID/CapswitchHID.c
- test -f Sources/CapswitchHID/CapswitchRemapping.c
- grep -q "IOHIDServiceClientSetProperty" Sources/CapswitchHID/CapswitchRemapping.c
- '! grep -R -F "/usr/bin/hidutil" Sources'
- grep -q "child.executableURL = executableURL" Sources/CapswitchApp/ApplicationRelauncher.swift
- test -f Tests/CapswitchCoreTests/CapsInputRouterTests.swift
- grep -q "CGEvent.tapCreate" Sources/CapswitchApp/MacOSInputController.swift
- grep -q "CGEvent.tapEnable" Sources/CapswitchApp/MacOSInputController.swift
- grep -q "CGPreflightListenEventAccess" Sources/CapswitchApp/MacOSInputController.swift
- grep -q "CGRequestListenEventAccess" Sources/CapswitchApp/MacOSInputController.swift
- grep -q "CGEvent.tapIsEnabled" Sources/CapswitchApp/MacOSInputController.swift
- test "$(grep -c "CGEventTapCreate" Sources/CapswitchApp/MacOSInputController.swift || true)" -eq 0
- test "$(grep -c "CGEventTapEnable" Sources/CapswitchApp/MacOSInputController.swift || true)" -eq 0
- grep -q "TISSelectInputSource" Sources/CapswitchApp/InputSourceManager.swift
- grep -q "switchToNextInputSource" Sources/Capswitch/CapsGestureMachine.swift
- bash scripts/lint.sh
test:
desc: Run Swift XCTest on macOS
cmds:
- bash scripts/test.sh
build:
desc: Compile macoc with SwiftPM
cmds:
- bash scripts/build.sh
icon:
desc: Convert the icon source into an Apple .icns file
cmds:
- bash scripts/icon.sh
sign:
desc: Sign the existing app bundle
cmds:
- bash scripts/sign.sh
bundle:
desc: Build a signed macoc.app
cmds:
- bash scripts/bundle.sh
install:
desc: Build, install to ~/Applications and launch
cmds:
- bash scripts/install.sh
doctor:
desc: Inspect keyboard services through Capswitch's own native IOKit code
desc: Inspect keyboard services through native IOKit
cmds:
- |
if [ "$(uname -s)" != "Darwin" ]; then
echo "SKIP: native HID diagnostics require macOS."
exit 0
fi
if [ ! -x "{{.APP_DIR}}/Contents/MacOS/{{.APP_NAME}}" ]; then
echo "Build the Capswitch app first: task bundle" >&2
exit 1
fi
"{{.APP_DIR}}/Contents/MacOS/{{.APP_NAME}}" --doctor
- bash scripts/doctor.sh
clean:
desc: Remove build outputs
desc: Remove SwiftPM and packaged outputs
cmds:
- rm -rf build .build
- bash scripts/clean.sh
verify:
desc: Run all checks available on the current host
desc: Run all available checks
cmds:
- task: lint
- task: test
- bash scripts/verify.sh
package:
desc: Build a release zip and SHA-256 checksum
cmds:
- bash scripts/package.sh
signing-setup:
desc: Instructions for creating a stable local signing identity
cmds:
- bash scripts/setup-signing.sh
publish:
desc: Publish dist/ zip to Forgejo (requires FORGEJO_TOKEN)
cmds:
- bash scripts/publish-release.sh

48
docs/ci.md Normal file
View file

@ -0,0 +1,48 @@
# Forgejo Actions CI and macOS releases
The workflow is in .forgejo/workflows/macos.yml.
- Push to main: Swift tests, static checks, macOS app build and ZIP archive.
- Push a version tag like v0.3.0: same checks plus a Forgejo release with
macoc-vX.Y.Z-macos-<arch>.zip and a SHA-256 checksum file.
- Manual workflow run: test and build only.
Artifacts are for the runner's CPU architecture, not universal.
## One-time macOS runner setup
1. Enable Actions under repository Settings -> Units.
2. Register a dedicated runner on a real Mac with Command Line Tools, Swift,
iconutil, sips, codesign, ditto, curl, Git, Python 3 and Node for checkout.
3. Under Settings -> Actions -> Runners obtain the registration token.
4. Configure the runner label macos:host; workflow uses runs-on: macos.
5. Push a main-branch commit to check the workflow. Then push an exact version
tag matching Resources/Info.plist when the build is green.
Host runners have no isolation; allow only trusted contributors to edit
the workflow, protect release tags, and avoid running arbitrary PR code.
Official documentation:
https://forgejo.org/docs/latest/user/actions/overview/
https://forgejo.org/docs/latest/admin/actions/configuration/
https://forgejo.org/docs/latest/user/actions/basic-concepts/
## Signing and publishing
The optional Forgejo repository variable MACOC_CODESIGN_IDENTITY selects
a code-signing identity already available in the macOS runner keychain.
Otherwise builds use ad-hoc signing. Ad-hoc releases are useful for local
testing but are not notarized and may trigger Gatekeeper warnings.
For public distribution use Developer ID Application signing plus Apple
notarization. Do not commit private keys or notarization credentials.
Notarization has not been automated in this version.
scripts/package.sh builds the archive and SHA-256 checksum.
scripts/publish-release.sh creates the release and uploads artifacts over
Forgejo REST using the workflow's short-lived FORGEJO_TOKEN. No long-lived
access token is stored in this repository.
The workflow requires a separately provisioned macOS runner. It cannot
be completed with the current Linux development host alone, and merely
committing it does not prove that a Mac build/release has run.

5
scripts/build.sh Executable file
View file

@ -0,0 +1,5 @@
#!/usr/bin/env bash
source "$(dirname "$0")/common.sh"
macos_only
cd "$ROOT"
swift build -c release

25
scripts/bundle.sh Executable file
View file

@ -0,0 +1,25 @@
#!/usr/bin/env bash
source "$(dirname "$0")/common.sh"
macos_only
if pgrep -x macoc >/dev/null 2>&1 || pgrep -x Capswitch >/dev/null 2>&1; then
echo "Quit any running macoc/Capswitch before rebuilding." >&2
exit 1
fi
bash "$ROOT/scripts/build.sh"
bin_dir="$(cd "$ROOT" && swift build -c release --show-bin-path)"
binary="$bin_dir/macoc"
if [[ ! -x "$binary" ]]; then
echo "SwiftPM did not produce the macoc executable: $binary" >&2
exit 1
fi
rm -rf "$APP_PATH"
mkdir -p "$APP_PATH/Contents/MacOS" "$APP_PATH/Contents/Resources"
cp "$binary" "$APP_PATH/Contents/MacOS/macoc"
cp "$ROOT/Resources/Info.plist" "$APP_PATH/Contents/Info.plist"
bash "$ROOT/scripts/icon.sh"
bash "$ROOT/scripts/sign.sh"
echo "Built: $APP_PATH"

3
scripts/clean.sh Executable file
View file

@ -0,0 +1,3 @@
#!/usr/bin/env bash
source "$(dirname "$0")/common.sh"
rm -rf "$ROOT/build" "$ROOT/.build" "$ROOT/dist"

18
scripts/common.sh Executable file
View file

@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
APP_NAME=macoc
BUNDLE_ID=dev.local.Capswitch
APP_PATH="$ROOT/build/macoc.app"
SOURCE_ICON="$ROOT/Resources/IconSource.jpg"
DIST_PATH="$ROOT/dist"
if [[ "$(uname -s)" == Darwin ]]; then IS_MACOS=1; else IS_MACOS=0; fi
macos_only() {
if [[ "$IS_MACOS" != 1 ]]; then
echo "SKIP: this task requires macOS with Apple Command Line Tools."
exit 0
fi
}

8
scripts/doctor.sh Executable file
View file

@ -0,0 +1,8 @@
#!/usr/bin/env bash
source "$(dirname "$0")/common.sh"
macos_only
if [[ ! -x "$APP_PATH/Contents/MacOS/macoc" ]]; then
echo "Build the app with bash scripts/bundle.sh first." >&2
exit 1
fi
"$APP_PATH/Contents/MacOS/macoc" --doctor

23
scripts/icon.sh Executable file
View file

@ -0,0 +1,23 @@
#!/usr/bin/env bash
source "$(dirname "$0")/common.sh"
macos_only
if [[ ! -f "$SOURCE_ICON" ]]; then
echo "Missing icon source: $SOURCE_ICON" >&2
exit 1
fi
iconset="$ROOT/build/macoc.iconset"
mkdir -p "$iconset"
for size in 16 32 128 256 512; do
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" "$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"

6
scripts/install.sh Executable file
View file

@ -0,0 +1,6 @@
#!/usr/bin/env bash
source "$(dirname "$0")/common.sh"
macos_only
bash "$ROOT/scripts/bundle.sh"
"$APP_PATH/Contents/MacOS/macoc" --install
open -a "$HOME/Applications/macoc.app"

25
scripts/lint.sh Executable file
View file

@ -0,0 +1,25 @@
#!/usr/bin/env bash
source "$(dirname "$0")/common.sh"
cd "$ROOT"
git diff --check
git diff --cached --check
test -f Package.swift
test -f Resources/Info.plist
test -s Resources/IconSource.jpg
test -f Sources/CapswitchApp/MacOSInputController.swift
test -f Sources/CapswitchApp/InputSourceManager.swift
test -f Sources/CapswitchApp/ApplicationRelauncher.swift
test -f Sources/CapswitchApp/AppInstallationManager.swift
test -f Sources/CapswitchApp/LoginItemManager.swift
test -f Sources/CapswitchHID/CapswitchRemapping.c
grep -q 'CFBundleIconFile' Resources/Info.plist
grep -q 'dev.local.Capswitch' Resources/Info.plist
grep -q 'TISSelectInputSource' Sources/CapswitchApp/InputSourceManager.swift
grep -q 'IOHIDServiceClientSetProperty' Sources/CapswitchHID/CapswitchRemapping.c
grep -q '/usr/bin/open' Sources/CapswitchApp/ApplicationRelauncher.swift
grep -q 'SMAppService.mainApp' Sources/CapswitchApp/LoginItemManager.swift
test -f .forgejo/workflows/macos.yml
for name in build bundle test install doctor clean lint verify icon sign package publish-release setup-signing; do
bash -n "scripts/$name.sh"
done
echo "lint: PASS"

20
scripts/package.sh Executable file
View file

@ -0,0 +1,20 @@
#!/usr/bin/env bash
source "$(dirname "$0")/common.sh"
macos_only
version="$(/usr/libexec/PlistBuddy -c 'Print CFBundleShortVersionString' "$ROOT/Resources/Info.plist")"
tag="${FORGEJO_REF_NAME:-${GITHUB_REF_NAME:-}}"
if [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] && [[ "$tag" != "v$version" ]]; then
echo "Tag $tag does not match Info.plist version v$version" >&2
exit 1
fi
bash "$ROOT/scripts/bundle.sh"
mkdir -p "$DIST_PATH"
name="macoc-v${version}-macos-$(uname -m).zip"
ditto -c -k --keepParent "$APP_PATH" "$DIST_PATH/$name"
(
cd "$DIST_PATH"
shasum -a 256 "$name" > "$name.sha256"
)
echo "Release archive: $DIST_PATH/$name"

74
scripts/publish-release.sh Executable file
View file

@ -0,0 +1,74 @@
#!/usr/bin/env bash
source "$(dirname "$0")/common.sh"
macos_only
: "${FORGEJO_TOKEN:?Forgejo release requires FORGEJO_TOKEN}"
: "${FORGEJO_SERVER_URL:?Forgejo release requires FORGEJO_SERVER_URL}"
: "${FORGEJO_REPOSITORY:?Forgejo release requires FORGEJO_REPOSITORY}"
tag="${FORGEJO_REF_NAME:-${GITHUB_REF_NAME:-}}"
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Expected a version tag like v0.3.0, got: $tag" >&2
exit 1
fi
if [[ "$FORGEJO_REPOSITORY" != "catsona/macoc" ]]; then
echo "Unexpected repository: $FORGEJO_REPOSITORY" >&2
exit 1
fi
release_api="$FORGEJO_SERVER_URL/api/v1/repos/$FORGEJO_REPOSITORY/releases"
json_file="$(mktemp)"
auth_file="$(mktemp)"
chmod 600 "$auth_file"
# Avoid placing the short-lived repository token in curl's argv/process list.
printf 'header = "Authorization: token %s"\n' "$FORGEJO_TOKEN" > "$auth_file"
trap 'rm -f "$json_file" "$auth_file"' EXIT
read_release() {
curl -K "$auth_file" -fsS "$release_api/tags/$tag"
}
if ! read_release > "$json_file" 2>/dev/null; then
payload="$(python3 -c \
'import json,sys; print(json.dumps({"tag_name":sys.argv[1], "name":sys.argv[1], "body":"macoc release for macOS. Unsigned / ad-hoc builds may require additional Gatekeeper approval. See README for signing and notarization.", "draft":False, "prerelease":False}))' \
"$tag")"
curl -K "$auth_file" -fsS -X POST \
-H "Content-Type: application/json" \
-d "$payload" "$release_api" > "$json_file"
fi
release_id="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' < "$json_file")"
existing_assets="$(curl -K "$auth_file" -fsS \
"$release_api/$release_id/assets")"
shopt -s nullglob
archives=("$DIST_PATH/macoc-$tag-macos-"*.zip)
if [[ "${#archives[@]}" -ne 1 ]]; then
echo "Expected exactly one archive for $tag in $DIST_PATH" >&2
exit 1
fi
assets=("${archives[0]}" "${archives[0]}.sha256")
if [[ ! -s "${assets[0]}" || ! -s "${assets[1]}" ]]; then
echo "Missing archive/checksum pair for $tag" >&2
exit 1
fi
for asset in "${assets[@]}"; do
name="$(basename "$asset")"
if printf '%s' "$existing_assets" | python3 -c \
'import json,sys; name=sys.argv[1]; exit(0 if any(a.get("name")==name for a in json.load(sys.stdin)) else 1)' \
"$name"; then
echo "Already uploaded: $name"
continue
fi
safe_name="$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))' "$name")"
curl -K "$auth_file" -fsS -X POST \
-H "Content-Type: application/octet-stream" \
--data-binary "@$asset" \
"$release_api/$release_id/assets?name=$safe_name" >/dev/null
echo "Uploaded: $name"
done
echo "Published: $FORGEJO_SERVER_URL/$FORGEJO_REPOSITORY/releases/tag/$tag"

28
scripts/setup-signing.sh Executable file
View file

@ -0,0 +1,28 @@
#!/usr/bin/env bash
source "$(dirname "$0")/common.sh"
macos_only
if security find-identity -v -p codesigning 2>/dev/null |
grep -F '"macoc Local Signing"' >/dev/null; then
echo "macoc Local Signing is already available in your Keychain."
exit 0
fi
cat <<'INSTRUCTIONS'
One-time local signing identity setup (preserves Accessibility approval
across rebuilt versions, not needed for a plain app restart):
1. Open Keychain Access -> Certificate Assistant -> Create a Certificate.
2. Name: macoc Local Signing
3. Identity type: Self Signed Root; Certificate type: Code Signing.
4. Create it in your login keychain; permit codesign access when prompted.
5. Verify: security find-identity -v -p codesigning
The normal build automatically uses "macoc Local Signing" if available.
You can also set MACOC_CODESIGN_IDENTITY to a valid existing identity.
No private signing keys are generated/stored in this repository.
Apple:
https://developer.apple.com/library/archive/documentation/Security/Conceptual/CodeSigningGuide/Procedures/Procedures.html
INSTRUCTIONS
open -a "Keychain Access" || true

32
scripts/sign.sh Executable file
View file

@ -0,0 +1,32 @@
#!/usr/bin/env bash
source "$(dirname "$0")/common.sh"
macos_only
if [[ ! -d "$APP_PATH" ]]; then
echo "Bundle missing: $APP_PATH" >&2
exit 1
fi
identity="${MACOC_CODESIGN_IDENTITY:-}"
if [[ -z "$identity" ]]; then
if security find-identity -v -p codesigning 2>/dev/null |
grep -F '"macoc Local Signing"' >/dev/null; then
identity="macoc Local Signing"
else
identity="-"
echo "WARNING: ad-hoc signing may require Accessibility reapproval after rebuilding." >&2
echo "Run bash scripts/setup-signing.sh for a persistent local identity." >&2
fi
fi
echo "Signing identity: $identity"
if [[ "$identity" == Developer\ ID\ Application:* ]]; then
# Developer ID builds require Hardened Runtime and a secure timestamp.
# Notarization still requires a separate submission with Apple credentials.
codesign --force --sign "$identity" --identifier "$BUNDLE_ID" \
--options runtime --timestamp "$APP_PATH"
else
codesign --force --sign "$identity" --identifier "$BUNDLE_ID" \
--timestamp=none "$APP_PATH"
fi
codesign --verify --deep --strict "$APP_PATH"
codesign --display --requirements - "$APP_PATH" 2>&1 | head -15

5
scripts/test.sh Executable file
View file

@ -0,0 +1,5 @@
#!/usr/bin/env bash
source "$(dirname "$0")/common.sh"
macos_only
cd "$ROOT"
swift test

4
scripts/verify.sh Executable file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env bash
source "$(dirname "$0")/common.sh"
bash "$ROOT/scripts/lint.sh"
bash "$ROOT/scripts/test.sh"