feat: brand macoc add icon stable restart scripts and Forgejo CI
Some checks failed
macoc macOS / build (push) Has been cancelled
Some checks failed
macoc macOS / build (push) Has been cancelled
This commit is contained in:
parent
e16f9094bc
commit
009c7e5a33
29 changed files with 579 additions and 242 deletions
37
.forgejo/workflows/macos.yml
Normal file
37
.forgejo/workflows/macos.yml
Normal 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
1
.gitignore
vendored
|
|
@ -3,3 +3,4 @@ build/
|
||||||
.swiftpm/
|
.swiftpm/
|
||||||
.task/
|
.task/
|
||||||
.build/
|
.build/
|
||||||
|
dist/
|
||||||
|
|
|
||||||
|
|
@ -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
170
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**,
|
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.
|
||||||
|
|
|
||||||
BIN
Resources/IconSource.jpg
Normal file
BIN
Resources/IconSource.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4 KiB |
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
161
Taskfile.yml
161
Taskfile.yml
|
|
@ -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
|
||||||
|
|
|
||||||
48
docs/ci.md
Normal file
48
docs/ci.md
Normal 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
5
scripts/build.sh
Executable 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
25
scripts/bundle.sh
Executable 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
3
scripts/clean.sh
Executable 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
18
scripts/common.sh
Executable 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
8
scripts/doctor.sh
Executable 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
23
scripts/icon.sh
Executable 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
6
scripts/install.sh
Executable 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
25
scripts/lint.sh
Executable 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
20
scripts/package.sh
Executable 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
74
scripts/publish-release.sh
Executable 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
28
scripts/setup-signing.sh
Executable 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
32
scripts/sign.sh
Executable 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
5
scripts/test.sh
Executable 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
4
scripts/verify.sh
Executable file
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
source "$(dirname "$0")/common.sh"
|
||||||
|
bash "$ROOT/scripts/lint.sh"
|
||||||
|
bash "$ROOT/scripts/test.sh"
|
||||||
Loading…
Reference in a new issue