feat: brand macoc add icon stable restart scripts and Forgejo CI

This commit is contained in:
casona 2026-10-26 00:22:35 +03:00
parent a0bf759abf
commit 7325c10469
27 changed files with 531 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/ .swiftpm/
.task/ .task/
.build/ .build/
dist/

View file

@ -2,10 +2,10 @@
import PackageDescription import PackageDescription
let package = Package( let package = Package(
name: "Capswitch", name: "macoc",
platforms: [.macOS(.v14)], platforms: [.macOS(.v14)],
products: [ products: [
.executable(name: "Capswitch", targets: ["Capswitch"]), .executable(name: "macoc", targets: ["macoc"]),
], ],
targets: [ targets: [
.target( .target(
@ -13,7 +13,7 @@ let package = Package(
path: "Sources/Capswitch" path: "Sources/Capswitch"
), ),
.executableTarget( .executableTarget(
name: "Capswitch", name: "macoc",
dependencies: ["CapswitchCore", "CapswitchHID"], dependencies: ["CapswitchCore", "CapswitchHID"],
path: "Sources/CapswitchApp", path: "Sources/CapswitchApp",
linkerSettings: [ 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**, The icon is a SFW cartoon of someone thoroughly fed up with keyboard
bypassing the stock Caps delay and input-source HUD. layout switching, complete with a literally hot seat.
- Caps + Option in either order: toggle the real system Caps Lock state.
## Architecture ## Architecture
The signed Capswitch executable itself uses IOKit to remap physical Caps Caps Lock is remapped to F19 via in-process Apple IOKit APIs, then suppressed
(HID usage 0x39) to F19 (usage 0x6E). Its active CoreGraphics event tap by a CoreGraphics event tap. Layouts switch through Carbon TIS. No drivers,
suppresses F19 down/up events and switches through Carbon TIS on F19 release. root daemons, third-party input libraries or hidutil permission required.
The small native C bridge is compiled into the app; there is no external
hidutil process, driver, helper, LaunchAgent or root component.
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 Requires Apple's Command Line Tools (not the Xcode IDE). Task is optional.
required. Quit any running Capswitch from its status-bar menu before rebuilding.
For development: Quit any running **macoc** or legacy **Capswitch** before rebuilding.
From this repository:
git pull origin main git pull origin main
task test bash scripts/test.sh
task bundle bash scripts/install.sh
open build/Capswitch.app
For daily use, install without sudo into ~/Applications/Capswitch.app: With Task installed:
git pull origin main
task test task test
task install 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 For development without installing: bash scripts/bundle.sh or task bundle.
~/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.
From the installed copy, click **Launch at Login** in the menu. This uses The menu offers **Restart macoc**, **Install to ~/Applications…** and
Apple's SMAppService.mainApp, not an ad-hoc LaunchAgent. If the menu says **Launch at Login**, registered via Apple's SMAppService.mainApp. If macOS
approval required, open **General -> Login Items** in System Settings and requests approval, open System Settings -> General -> Login Items.
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 also has **Restart Capswitch**. It restores the Caps HID mapping ### Migrating from Capswitch
before exiting and launches a new copy of the same executable after the
old process is gone.
The app has a stable bundle identifier (dev.local.Capswitch) and is signed The name and executable are now macoc; the existing bundle identifier
ad-hoc. macOS may require re-approval for Accessibility/Input Monitoring remains dev.local.Capswitch to preserve old Accessibility grants and HID
when moving from build/ to ~/Applications or replacing an ad-hoc signature. 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: Restart now uses LaunchServices (open -n -a) instead of executing a raw
- Accessibility to suppress F19. binary under Contents/MacOS. That preserves the macOS .app attribution.
- Input Monitoring when macOS needs it to observe keyboard events.
Grant permissions to Capswitch only. No system executable needs extra For identical app binaries at the same path, Accessibility approval normally
permissions. Apple requires the user to approve them in System Settings. 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 For stable identity across **rebuilt versions**, create one persistent
**System Settings -> Keyboard -> Text Input -> Edit -> local Code Signing certificate in Keychain Access named
Use Caps Lock to switch to/from ABC**. Keep the key assigned to its normal **macoc Local Signing** (Certificate Assistant -> Create a Certificate ->
Caps Lock role in Modifier Keys (not No Action). Self Signed Root -> Code Signing). Run bash scripts/setup-signing.sh or
Capswitch does not secretly change these preferences. 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 Reference: https://developer.apple.com/documentation/technotes/tn3127-inside-code-signing-requirements
mapping count, event tap status, permission status and diagnostics.
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, Disable macOS's own "Use Caps Lock to switch to/from ABC" under
inspect enabled TIS input sources. If counts remain zero, inspect the System Settings -> Keyboard -> Text Input -> Edit. Keep physical Caps
HID mapping and event tap status. Pause/normal Quit restore the physical assigned its normal modifier role rather than No Action.
Caps key and preserve unrelated keyboard mappings.
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 **A real Mac Forgejo runner labelled macos is required.** The Linux server
its added Caps-to-F19 pair per keyboard service. It removes only owned cannot build AppKit or IOKit. See [CI setup](docs/ci.md). Until a Mac runner
pairs when paused or quit. A forcibly terminated app may leave a pair is registered and its job succeeds, no release build has been produced.
active until next clean Capswitch launch/quit, keyboard service removal,
or reboot. To recover, reopen Capswitch and Quit normally.
If installation or restart fails to restore Caps, the app displays an A release archive signed ad-hoc is intended for local evaluation.
error instead of silently quitting. If macOS loses permission, it surfaces To distribute broadly without Gatekeeper warnings, use Developer ID
that status in the menu. signing and Apple's notarization service (not yet configured here).
Development tasks: ## Diagnostics and recovery
task verify The status-bar menu shows layout, keyboard HID mapping count, event-tap state,
task lint permission grants, and F19 down/up counts (no typed characters are recorded).
task test Run bash scripts/doctor.sh for read-only native HID diagnostics.
task build
task bundle
task install
task doctor
task clean
The Linux RSB host can run static checks but has no Apple AppKit/IOKit SDK. Pause or Quit normally restores Caps and preserves unrelated remappings.
The macOS build and real keyboard behavior must be verified on macOS. 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.

View file

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

View file

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

View file

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

View file

@ -2,41 +2,56 @@ import Darwin
import Foundation import Foundation
enum RelaunchError: LocalizedError { enum RelaunchError: LocalizedError {
case missingExecutable case missingBundle
case failedToLaunch(Int32)
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .missingExecutable: case .missingBundle:
return "The application executable was not found." 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 /// Relaunch through LaunchServices, never by directly executing
/// disappear before starting AppKit or the HID remapper. /// 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 { enum ApplicationRelauncher {
static func spawn(executableURL: URL) throws { static func spawn(executableURL: URL) throws {
guard FileManager.default.isExecutableFile(atPath: executableURL.path) else { let appURL = executableURL.deletingLastPathComponent()
throw RelaunchError.missingExecutable .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() let launcher = Process()
child.executableURL = executableURL launcher.executableURL = URL(fileURLWithPath: "/usr/bin/open")
child.arguments = [ launcher.arguments = [
"--wait-for-pid=\(ProcessInfo.processInfo.processIdentifier)" "-n", "-a", appURL.path,
"--args", "--wait-for-pid=\(ProcessInfo.processInfo.processIdentifier)"
] ]
child.standardOutput = FileHandle.nullDevice launcher.standardOutput = FileHandle.nullDevice
child.standardError = FileHandle.nullDevice launcher.standardError = FileHandle.nullDevice
try child.run() try launcher.run()
launcher.waitUntilExit()
guard launcher.terminationStatus == 0 else {
throw RelaunchError.failedToLaunch(launcher.terminationStatus)
}
} }
static func waitForPreviousProcess(_ parentPID: pid_t) -> Bool { static func waitForPreviousProcess(_ parentPID: pid_t) -> Bool {
guard parentPID > 1 else { guard parentPID > 1 else { return false }
return false
}
// Bounded wait: never start a second keyboard remapper when the // New instance must not own the HID mapping until the old one exits.
// original application refuses to terminate.
for _ in 0..<200 { for _ in 0..<200 {
if kill(parentPID, 0) == -1 { if kill(parentPID, 0) == -1 {
return errno == ESRCH return errno == ESRCH

View file

@ -7,7 +7,7 @@ enum LoginItemError: LocalizedError {
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .installFirst: 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? { var errorDescription: String? {
switch self { switch self {
case .accessibilityRequired: 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: 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: case .eventTapUnavailable:
return "macOS did not allow the keyboard event tap. Check Accessibility and Input Monitoring permissions." 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 { func stop() -> Bool {
// Restore the actual Caps key before disabling the F19 interceptor. // Restore the actual Caps key before disabling the F19 interceptor.
// If restoration fails, refuse a normal Pause/Quit so F19 cannot be // 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 { do {
try remapper.restoreIfOwned() try remapper.restoreIfOwned()
} catch { } catch {
@ -257,10 +257,9 @@ final class MacOSInputController {
_ = AXIsProcessTrustedWithOptions(options) _ = AXIsProcessTrustedWithOptions(options)
} }
if !inputMonitoringTrusted { // Input Monitoring is optional for an Accessibility-authorized active
// Request Input Monitoring for our own application identity. // event tap. Do not prompt on every launch when it is not needed.
_ = CGRequestListenEventAccess() // If creating the tap fails, the menu exposes an explicit grant path.
}
} }
func requestInputMonitoringPermission() { func requestInputMonitoringPermission() {

View file

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

View file

@ -11,7 +11,7 @@ case .doctor:
let remapper = HIDRemappingController() let remapper = HIDRemappingController()
do { do {
try remapper.inspect() try remapper.inspect()
print("Capswitch - 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 {
@ -21,23 +21,23 @@ case .doctor:
} }
exit(0) exit(0)
} catch { } catch {
fputs("Capswitch HID check: \(error.localizedDescription)\n", stderr) fputs("macoc HID check: \(error.localizedDescription)\n", stderr)
exit(1) exit(1)
} }
case .install: case .install:
do { do {
let location = try AppInstallationManager().install() let location = try AppInstallationManager().install()
print("Installed Capswitch: \(location.path)") print("Installed macoc: \(location.path)")
exit(0) exit(0)
} catch { } catch {
fputs("Capswitch install: \(error.localizedDescription)\n", stderr) fputs("macoc install: \(error.localizedDescription)\n", stderr)
exit(1) exit(1)
} }
case let .waitForParent(pid): case let .waitForParent(pid):
guard ApplicationRelauncher.waitForPreviousProcess(pid) else { 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) exit(1)
} }

View file

@ -1,124 +1,71 @@
version: '3' version: '3'
vars:
APP_NAME: Capswitch
APP_DIR: build/Capswitch.app
RELEASE_BINARY: .build/release/Capswitch
tasks: tasks:
default: default:
cmds: cmds:
- task: verify - bash scripts/verify.sh
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"
lint: lint:
desc: Run host-independent source and bundle checks desc: Run shell and source checks
cmds: cmds:
- git diff --check - bash scripts/lint.sh
- test -f Package.swift
- test -f Resources/Info.plist test:
- test -f Sources/Capswitch/CapsGestureMachine.swift desc: Run Swift XCTest on macOS
- test -f Sources/CapswitchApp/MacOSInputController.swift cmds:
- grep -q "LSUIElement" Resources/Info.plist - bash scripts/test.sh
- grep -q "CGEventType.keyDown" Sources/CapswitchApp/MacOSInputController.swift
- grep -q "CGEventType.keyUp" Sources/CapswitchApp/MacOSInputController.swift build:
- grep -q "keyboardEventKeycode" Sources/CapswitchApp/MacOSInputController.swift desc: Compile macoc with SwiftPM
- grep -q "CapsInputRouter.f19VirtualKeyCode" Sources/CapswitchApp/MacOSInputController.swift cmds:
- test -f Sources/CapswitchApp/HIDRemappingController.swift - bash scripts/build.sh
- test -f Sources/CapswitchApp/InputSourceManager.swift
- test -f Sources/CapswitchApp/ApplicationRelauncher.swift icon:
- test -f Sources/CapswitchApp/ApplicationLifecycleController.swift desc: Convert the icon source into an Apple .icns file
- test -f Sources/CapswitchApp/LoginItemManager.swift cmds:
- test -f Sources/CapswitchApp/AppInstallationManager.swift - bash scripts/icon.sh
- grep -q "SMAppService.mainApp" Sources/CapswitchApp/LoginItemManager.swift
- test -f Tests/CapswitchCoreTests/LaunchArgumentsTests.swift sign:
- test -f Sources/CapswitchHID/CapswitchHID.c desc: Sign the existing app bundle
- test -f Sources/CapswitchHID/CapswitchRemapping.c cmds:
- grep -q "IOHIDServiceClientSetProperty" Sources/CapswitchHID/CapswitchRemapping.c - bash scripts/sign.sh
- '! grep -R -F "/usr/bin/hidutil" Sources'
- grep -q "child.executableURL = executableURL" Sources/CapswitchApp/ApplicationRelauncher.swift bundle:
- test -f Tests/CapswitchCoreTests/CapsInputRouterTests.swift desc: Build a signed macoc.app
- grep -q "CGEvent.tapCreate" Sources/CapswitchApp/MacOSInputController.swift cmds:
- grep -q "CGEvent.tapEnable" Sources/CapswitchApp/MacOSInputController.swift - bash scripts/bundle.sh
- grep -q "CGPreflightListenEventAccess" Sources/CapswitchApp/MacOSInputController.swift
- grep -q "CGRequestListenEventAccess" Sources/CapswitchApp/MacOSInputController.swift install:
- grep -q "CGEvent.tapIsEnabled" Sources/CapswitchApp/MacOSInputController.swift desc: Build, install to ~/Applications and launch
- test "$(grep -c "CGEventTapCreate" Sources/CapswitchApp/MacOSInputController.swift || true)" -eq 0 cmds:
- test "$(grep -c "CGEventTapEnable" Sources/CapswitchApp/MacOSInputController.swift || true)" -eq 0 - bash scripts/install.sh
- grep -q "TISSelectInputSource" Sources/CapswitchApp/InputSourceManager.swift
- grep -q "switchToNextInputSource" Sources/Capswitch/CapsGestureMachine.swift
doctor: doctor:
desc: Inspect keyboard services through Capswitch's own native IOKit code desc: Inspect keyboard services through native IOKit
cmds: cmds:
- | - bash scripts/doctor.sh
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
clean: clean:
desc: Remove build outputs desc: Remove SwiftPM and packaged outputs
cmds: cmds:
- rm -rf build .build - bash scripts/clean.sh
verify: verify:
desc: Run all checks available on the current host desc: Run all available checks
cmds: cmds:
- task: lint - bash scripts/verify.sh
- task: test
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

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"