diff --git a/.forgejo/workflows/macos.yml b/.forgejo/workflows/macos.yml new file mode 100644 index 0000000..d38bae5 --- /dev/null +++ b/.forgejo/workflows/macos.yml @@ -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 diff --git a/.gitignore b/.gitignore index 47590c0..21985ab 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ build/ .swiftpm/ .task/ .build/ +dist/ diff --git a/Package.swift b/Package.swift index 5f89b37..59a8d20 100644 --- a/Package.swift +++ b/Package.swift @@ -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: [ diff --git a/README.md b/README.md index cf7524b..90f3e6d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Resources/IconSource.jpg b/Resources/IconSource.jpg new file mode 100644 index 0000000..4a09cb7 Binary files /dev/null and b/Resources/IconSource.jpg differ diff --git a/Resources/Info.plist b/Resources/Info.plist index 79dbafe..cb26a58 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -3,19 +3,21 @@ CFBundleDisplayName - Capswitch + macoc CFBundleExecutable - Capswitch + macoc CFBundleIdentifier dev.local.Capswitch CFBundleName - Capswitch + macoc + CFBundleIconFile + AppIcon.icns CFBundlePackageType APPL CFBundleShortVersionString - 0.2.0 + 0.3.0 CFBundleVersion - 2 + 3 LSMinimumSystemVersion 14.0 LSUIElement diff --git a/Sources/CapswitchApp/AppInstallationManager.swift b/Sources/CapswitchApp/AppInstallationManager.swift index 18cb7f1..45425c5 100644 --- a/Sources/CapswitchApp/AppInstallationManager.swift +++ b/Sources/CapswitchApp/AppInstallationManager.swift @@ -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 } diff --git a/Sources/CapswitchApp/ApplicationLifecycleController.swift b/Sources/CapswitchApp/ApplicationLifecycleController.swift index 0db7062..424dd42 100644 --- a/Sources/CapswitchApp/ApplicationLifecycleController.swift +++ b/Sources/CapswitchApp/ApplicationLifecycleController.swift @@ -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) } diff --git a/Sources/CapswitchApp/ApplicationRelauncher.swift b/Sources/CapswitchApp/ApplicationRelauncher.swift index 700c612..3a03b0e 100644 --- a/Sources/CapswitchApp/ApplicationRelauncher.swift +++ b/Sources/CapswitchApp/ApplicationRelauncher.swift @@ -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 diff --git a/Sources/CapswitchApp/LoginItemManager.swift b/Sources/CapswitchApp/LoginItemManager.swift index 3298b74..462696d 100644 --- a/Sources/CapswitchApp/LoginItemManager.swift +++ b/Sources/CapswitchApp/LoginItemManager.swift @@ -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." } } } diff --git a/Sources/CapswitchApp/MacOSInputController.swift b/Sources/CapswitchApp/MacOSInputController.swift index 3b8c218..68aad42 100644 --- a/Sources/CapswitchApp/MacOSInputController.swift +++ b/Sources/CapswitchApp/MacOSInputController.swift @@ -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() { diff --git a/Sources/CapswitchApp/StatusBarController.swift b/Sources/CapswitchApp/StatusBarController.swift index cca03f3..f16055d 100644 --- a/Sources/CapswitchApp/StatusBarController.swift +++ b/Sources/CapswitchApp/StatusBarController.swift @@ -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 ) } diff --git a/Sources/CapswitchApp/main.swift b/Sources/CapswitchApp/main.swift index 55ad961..9abf17c 100644 --- a/Sources/CapswitchApp/main.swift +++ b/Sources/CapswitchApp/main.swift @@ -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) } diff --git a/Taskfile.yml b/Taskfile.yml index 3860226..9003765 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -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 diff --git a/docs/ci.md b/docs/ci.md new file mode 100644 index 0000000..f6234be --- /dev/null +++ b/docs/ci.md @@ -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-.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. diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..8ae5645 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/common.sh" +macos_only +cd "$ROOT" +swift build -c release diff --git a/scripts/bundle.sh b/scripts/bundle.sh new file mode 100755 index 0000000..68cc3ae --- /dev/null +++ b/scripts/bundle.sh @@ -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" diff --git a/scripts/clean.sh b/scripts/clean.sh new file mode 100755 index 0000000..f357078 --- /dev/null +++ b/scripts/clean.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/common.sh" +rm -rf "$ROOT/build" "$ROOT/.build" "$ROOT/dist" diff --git a/scripts/common.sh b/scripts/common.sh new file mode 100755 index 0000000..8294ace --- /dev/null +++ b/scripts/common.sh @@ -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 +} diff --git a/scripts/doctor.sh b/scripts/doctor.sh new file mode 100755 index 0000000..0193ac8 --- /dev/null +++ b/scripts/doctor.sh @@ -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 diff --git a/scripts/icon.sh b/scripts/icon.sh new file mode 100755 index 0000000..969714e --- /dev/null +++ b/scripts/icon.sh @@ -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" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..c49db2b --- /dev/null +++ b/scripts/install.sh @@ -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" diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100755 index 0000000..5fcb6a0 --- /dev/null +++ b/scripts/lint.sh @@ -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" diff --git a/scripts/package.sh b/scripts/package.sh new file mode 100755 index 0000000..40bd3cf --- /dev/null +++ b/scripts/package.sh @@ -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" diff --git a/scripts/publish-release.sh b/scripts/publish-release.sh new file mode 100755 index 0000000..54cff2f --- /dev/null +++ b/scripts/publish-release.sh @@ -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" diff --git a/scripts/setup-signing.sh b/scripts/setup-signing.sh new file mode 100755 index 0000000..7a94dfd --- /dev/null +++ b/scripts/setup-signing.sh @@ -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 diff --git a/scripts/sign.sh b/scripts/sign.sh new file mode 100755 index 0000000..cc01547 --- /dev/null +++ b/scripts/sign.sh @@ -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 diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..278c540 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/common.sh" +macos_only +cd "$ROOT" +swift test diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100755 index 0000000..cd42653 --- /dev/null +++ b/scripts/verify.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/common.sh" +bash "$ROOT/scripts/lint.sh" +bash "$ROOT/scripts/test.sh"