Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: CI
on:
push:
pull_request:
jobs:
test:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Test
run: swift test --parallel
- name: Strict Swift concurrency and warnings
run: swift build -Xswiftc -strict-concurrency=complete -Xswiftc -warnings-as-errors
- name: Repository policy checks
run: ./Scripts/ci-policy-checks.sh
- name: Reproducible app bundle and packaging
run: ./Scripts/check-reproducible-bundle.sh
- name: Release archive
run: ./Scripts/package-release.sh
- uses: actions/upload-artifact@v4
with:
name: keybridge-unsigned-${{ github.sha }}
path: |
dist/KeyboardSwitch-*.zip
dist/KeyboardSwitch-*.zip.sha256
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.build/
.swiftpm/
DerivedData/
*.xcarchive
*.dSYM/
dist/
.DS_Store
*.p12
*.provisionprofile
44 changes: 44 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"configurations": [
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:Magic Switch}",
"name": "Debug KeyboardSwitchApp",
"target": "KeyboardSwitchApp",
"configuration": "debug",
"preLaunchTask": "swift: Build Debug KeyboardSwitchApp"
},
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:Magic Switch}",
"name": "Release KeyboardSwitchApp",
"target": "KeyboardSwitchApp",
"configuration": "release",
"preLaunchTask": "swift: Build Release KeyboardSwitchApp"
},
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:Magic Switch}",
"name": "Debug keyboard-switch-diagnostics",
"target": "keyboard-switch-diagnostics",
"configuration": "debug",
"preLaunchTask": "swift: Build Debug keyboard-switch-diagnostics"
},
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:Magic Switch}",
"name": "Release keyboard-switch-diagnostics",
"target": "keyboard-switch-diagnostics",
"configuration": "release",
"preLaunchTask": "swift: Build Release keyboard-switch-diagnostics"
}
]
}
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Keyboard Switch contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
49 changes: 49 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// swift-tools-version: 6.0
import PackageDescription

let package = Package(
name: "KeyboardSwitch",
platforms: [.macOS(.v15)],
products: [
.library(name: "KeyboardSwitchCore", targets: ["KeyboardSwitchCore"]),
.library(name: "KeyboardSwitchBluetooth", targets: ["KeyboardSwitchBluetooth"]),
.library(name: "MagicSwitchNetworking", targets: ["MagicSwitchNetworking"]),
.executable(name: "KeyboardSwitchApp", targets: ["KeyboardSwitchApp"]),
.executable(name: "keyboard-switch-diagnostics", targets: ["KeyboardSwitchDiagnostics"]),
],
targets: [
.target(name: "KeyboardSwitchCore"),
.target(
name: "KeyboardSwitchBluetooth",
dependencies: ["KeyboardSwitchCore"],
linkerSettings: [.linkedFramework("IOBluetooth")]
),
.target(
name: "MagicSwitchNetworking",
linkerSettings: [.linkedFramework("Security")]
),
.executableTarget(
name: "KeyboardSwitchDiagnostics",
dependencies: ["KeyboardSwitchCore", "KeyboardSwitchBluetooth"]
),
.executableTarget(
name: "KeyboardSwitchApp",
dependencies: ["KeyboardSwitchCore", "KeyboardSwitchBluetooth", "MagicSwitchNetworking"],
linkerSettings: [
.linkedFramework("ServiceManagement"),
]
),
.testTarget(
name: "KeyboardSwitchCoreTests",
dependencies: ["KeyboardSwitchCore", "KeyboardSwitchBluetooth"]
),
.testTarget(
name: "MagicSwitchNetworkingTests",
dependencies: ["MagicSwitchNetworking"]
),
.testTarget(
name: "KeyboardSwitchAppTests",
dependencies: ["KeyboardSwitchApp", "KeyboardSwitchCore", "MagicSwitchNetworking"]
),
]
)
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Keyboard Switch

Keyboard Switch is an experimental, native macOS menu-bar app for coordinating a pre-paired Apple Magic Keyboard between up to three Macs on the same LAN. It uses local peer discovery and asks macOS to disconnect or connect the real Bluetooth HID device; it does not forward keystrokes.

## Feasibility status

The software builds and its switching, networking, security, and simulated Bluetooth paths are testable. The legacy public `IOBluetooth` framework can inventory the local paired Magic Keyboard and exposes connection operations. **A reliable physical A→B→A transfer has not yet been demonstrated.** macOS may decline or delay connection requests, and Touch ID association is not portable. Do not treat this repository as a production replacement until the hardware acceptance matrix passes on every intended Mac and OS version.

Normal switching never deletes Bluetooth pairing information. Cable-pair the keyboard with every Mac before testing. Keep another input device available during feasibility tests.

## Requirements

- Apple-silicon Mac with macOS 15 or later
- Xcode 16 or later and Swift 6
- USB-C rechargeable Apple Magic Keyboard
- All Macs awake and reachable on the same local network

## Build and test

```sh
swift test
swift run KeyboardSwitchApp
swift run keyboard-switch-diagnostics
```

Inventory is safe and redacts most of the Bluetooth identifier. Explicit diagnostics can interrupt keyboard input:

```sh
swift run keyboard-switch-diagnostics --disconnect FULL_DEVICE_ID
swift run keyboard-switch-diagnostics --connect FULL_DEVICE_ID
```

Run those commands only with a fallback mouse/keyboard available. A successful API return is not proof of usable HID input; verify by typing on the target.

## App bundle

`./Scripts/build-app.sh` creates `dist/KeyboardSwitch.app`. By default it is ad-hoc signed. Set `CODE_SIGN_IDENTITY` to a Developer ID Application identity for distribution; `./Scripts/sign-app.sh` can re-sign an existing bundle. `./Scripts/install.sh` installs to `~/Applications`; `./Scripts/uninstall.sh` removes that copy and local preferences.

For notarization, export `NOTARY_PROFILE` created with `xcrun notarytool store-credentials`, then run `./Scripts/notarize.sh`. See comments in each script for overrides.

`./Scripts/package-release.sh` verifies the bundle and creates a versioned ZIP plus SHA-256 checksum. CI builds twice and compares every non-signature file, enables complete Swift concurrency checking and warnings-as-errors, and runs license, secret-pattern, and undeclared-dependency checks. An ad-hoc archive is for local testing only; it is not notarized or Gatekeeper-ready.

## Hardware acceptance gate

On each supported macOS release, cable-pair once on Macs A, B, and C, unplug the cable, then demonstrate three consecutive A→B→C→A cycles. Record hardware metadata and verify actual typed input after every handoff. Repeat after sleep/wake, Bluetooth restart, app restart, Wi-Fi interruption, and keyboard power-cycle. Pairing must remain intact and recovery must restore the source or report manual action accurately.

No release should claim physical compatibility until those tests pass on real hardware. Touch ID, FileVault pre-login, wake-from-shutdown, iPad, Windows, pointing devices, and App Store distribution are outside v1.

Run `./Scripts/hardware-acceptance.sh` for a structured JSON Lines record. See `Tests/ACCEPTANCE.md`. A human must verify actual typed input; the harness never treats an API return as proof.

## Privacy and security

The app works locally, captures no keystrokes, and does not request Accessibility or Input Monitoring permission. Peer trust secrets belong in Keychain. Diagnostic identifiers and network addresses must remain redacted in exported logs.

## License

MIT. The project uses original branding and UI rather than copying Magic Switch assets.

`./Scripts/uninstall.sh` preserves Keychain identity and peer trust by default. The explicit `--remove-keychain` flag performs a full reset and removes only the two documented Keybridge generic-password services.
18 changes: 18 additions & 0 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleDevelopmentRegion</key><string>en</string>
<key>CFBundleExecutable</key><string>KeyboardSwitchApp</string>
<key>CFBundleIconFile</key><string>Keybridge</string>
<key>CFBundleIdentifier</key><string>dev.keyboardswitch.app</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundleName</key><string>Keyboard Switch</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>0.1.0</string>
<key>CFBundleVersion</key><string>1</string>
<key>LSMinimumSystemVersion</key><string>15.0</string>
<key>LSUIElement</key><true/>
<key>NSBluetoothAlwaysUsageDescription</key><string>Keyboard Switch connects and disconnects your pre-paired Magic Keyboard.</string>
<key>NSLocalNetworkUsageDescription</key><string>Keyboard Switch discovers and coordinates with your trusted Macs on the local network.</string>
<key>NSBonjourServices</key><array><string>_keyboardswitch._tcp</string></array>
</dict></plist>
6 changes: 6 additions & 0 deletions Resources/KeyboardSwitch.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<!-- Intentionally not sandboxed: legacy IOBluetooth connection control is the feasibility path. -->
<key>com.apple.security.app-sandbox</key><false/>
</dict></plist>
Binary file added Resources/Keybridge.icns
Binary file not shown.
18 changes: 18 additions & 0 deletions Scripts/build-app.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/bin/zsh
set -euo pipefail

ROOT="${0:A:h:h}"
CONFIGURATION="${CONFIGURATION:-release}"
OUTPUT="${OUTPUT:-$ROOT/dist}"
APP="$OUTPUT/KeyboardSwitch.app"

cd "$ROOT"
swift build -c "$CONFIGURATION" --product KeyboardSwitchApp
BIN_DIR="$(swift build -c "$CONFIGURATION" --show-bin-path)"
rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
cp "$BIN_DIR/KeyboardSwitchApp" "$APP/Contents/MacOS/KeyboardSwitchApp"
cp "$ROOT/Resources/Info.plist" "$APP/Contents/Info.plist"
cp "$ROOT/Resources/Keybridge.icns" "$APP/Contents/Resources/Keybridge.icns"
APP_SOURCE="$APP" "$ROOT/Scripts/sign-app.sh"
echo "$APP"
19 changes: 19 additions & 0 deletions Scripts/check-reproducible-bundle.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/bin/zsh
set -euo pipefail
ROOT="${0:A:h:h}"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
manifest() {
local app="$1"
find "$app/Contents" -type f ! -path '*/_CodeSignature/*' -print0 | LC_ALL=C sort -z |
while IFS= read -r -d '' file; do
printf '%s %s\n' "$(shasum -a 256 "$file" | awk '{print $1}')" "${file#$app/}"
done
}
OUTPUT="$TMP/one" "$ROOT/Scripts/build-app.sh" >/dev/null
OUTPUT="$TMP/two" "$ROOT/Scripts/build-app.sh" >/dev/null
manifest "$TMP/one/KeyboardSwitch.app" > "$TMP/one.manifest"
manifest "$TMP/two/KeyboardSwitch.app" > "$TMP/two.manifest"
diff -u "$TMP/one.manifest" "$TMP/two.manifest"
APP_SOURCE="$TMP/one/KeyboardSwitch.app" "$ROOT/Scripts/verify-bundle.sh"
echo "Two release bundles have identical non-signature contents"
16 changes: 16 additions & 0 deletions Scripts/ci-policy-checks.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/bin/zsh
set -euo pipefail
ROOT="${0:A:h:h}"
cd "$ROOT"
[[ -f LICENSE ]] || { echo "LICENSE is required" >&2; exit 1; }
grep -q "MIT License" LICENSE || { echo "LICENSE must contain the MIT License" >&2; exit 1; }
if git grep -nEI '(BEGIN (RSA|OPENSSH|EC) PRIVATE KEY|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36})' -- ':!Scripts/ci-policy-checks.sh'; then
echo "Potential committed secret detected" >&2; exit 1
fi
if git grep -nE 'blueutil|brew install' -- Sources Package.swift; then
echo "Undeclared external Bluetooth runtime dependency detected" >&2; exit 1
fi
if git grep -nE 'RegisterEventHotKey|GlobalHotKeyController|openPopoverShortcut|Control.Option.K' -- Sources Package.swift README.md; then
echo "Unsupported global-shortcut promise detected" >&2; exit 1
fi
echo "License, secret-pattern, and runtime-dependency policy checks passed"
48 changes: 48 additions & 0 deletions Scripts/hardware-acceptance.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/bin/zsh
set -euo pipefail
ROOT="${0:A:h:h}"
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)"
LOG="${ACCEPTANCE_LOG:-$ROOT/dist/hardware-acceptance-$RUN_ID.jsonl}"
CYCLES="${CYCLES:-3}"
MAC_A="${MAC_A:-Mac A}"; MAC_B="${MAC_B:-Mac B}"; MAC_C="${MAC_C:-Mac C}"
MAC_A_MODEL="${MAC_A_MODEL:-unknown}"; MAC_B_MODEL="${MAC_B_MODEL:-unknown}"; MAC_C_MODEL="${MAC_C_MODEL:-unknown}"
MAC_A_OS="${MAC_A_OS:-unknown}"; MAC_B_OS="${MAC_B_OS:-unknown}"; MAC_C_OS="${MAC_C_OS:-unknown}"
KEYBOARD_MODEL="${KEYBOARD_MODEL:-unknown}"; KEYBOARD_FIRMWARE="${KEYBOARD_FIRMWARE:-unknown}"
mkdir -p "${LOG:h}"
json_string() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; }
record() {
printf '{"run_id":"%s","timestamp":"%s","event":"%s","result":"%s","detail":"%s"}\n' \
"$RUN_ID" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$(json_string "$1")" "$(json_string "$2")" "$(json_string "$3")" >> "$LOG"
}
confirm() {
local answer
printf '%s [y/N]: ' "$2"; read -r answer
if [[ "$answer" == [yY] ]]; then record "$1" pass "$2"; return 0; fi
record "$1" fail "$2"; return 1
}
record session started "cycles=$CYCLES; mac_a=$MAC_A; mac_b=$MAC_B; mac_c=$MAC_C"
record hardware metadata "mac_a_model=$MAC_A_MODEL; mac_a_os=$MAC_A_OS; mac_b_model=$MAC_B_MODEL; mac_b_os=$MAC_B_OS; mac_c_model=$MAC_C_MODEL; mac_c_os=$MAC_C_OS; keyboard_model=$KEYBOARD_MODEL; keyboard_firmware=$KEYBOARD_FIRMWARE"
echo "Hardware acceptance log: $LOG"
echo "Keep a fallback input device attached. API returns alone never count as HID proof."
if [[ "$MAC_A_MODEL $MAC_B_MODEL $MAC_C_MODEL $MAC_A_OS $MAC_B_OS $MAC_C_OS $KEYBOARD_MODEL $KEYBOARD_FIRMWARE" == *unknown* ]]; then
record preflight fail "Hardware metadata contains unknown values"
echo "Set MAC_A_MODEL, MAC_B_MODEL, MAC_C_MODEL, MAC_A_OS, MAC_B_OS, MAC_C_OS, KEYBOARD_MODEL, and KEYBOARD_FIRMWARE." >&2
exit 1
fi
confirm preflight "All three Macs are awake, on the same LAN, and cable-paired once" || exit 1
for cycle in {1..$CYCLES}; do
for route in "$MAC_A→$MAC_B" "$MAC_B→$MAC_C" "$MAC_C→$MAC_A"; do
phrase="keybridge-$RUN_ID-cycle-$cycle-${route##*→}"
echo "Transfer $route, then type this exact phrase on the target: $phrase"
confirm handoff "Target received the exact phrase and source received no input ($route, cycle $cycle)" || exit 1
confirm pairing-preserved "Keyboard remains paired on both Macs" || exit 1
done
done
for scenario in "app restart" "sleep and wake" "Wi-Fi interruption" "Bluetooth off/on" "keyboard power-cycle"; do
echo "Exercise $scenario, then complete one A→B→C→A handoff."
confirm resilience "Transfer and typed input succeeded after $scenario" || exit 1
done
echo "Force one target-connection failure after the source releases the keyboard."
confirm recovery "The app either restored typed input on the source or accurately displayed manual recovery; Bluetooth Connected alone was not accepted" || exit 1
record session passed "All required observations passed"
echo "PASS: $LOG"
11 changes: 11 additions & 0 deletions Scripts/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/bin/zsh
set -euo pipefail

ROOT="${0:A:h:h}"
APP_SOURCE="${APP_SOURCE:-$ROOT/dist/KeyboardSwitch.app}"
DESTINATION="${DESTINATION:-$HOME/Applications}"
[[ -d "$APP_SOURCE" ]] || "$ROOT/Scripts/build-app.sh"
mkdir -p "$DESTINATION"
rm -rf "$DESTINATION/KeyboardSwitch.app"
cp -R "$APP_SOURCE" "$DESTINATION/KeyboardSwitch.app"
echo "Installed $DESTINATION/KeyboardSwitch.app"
13 changes: 13 additions & 0 deletions Scripts/notarize.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/bin/zsh
set -euo pipefail

ROOT="${0:A:h:h}"
APP="${APP_SOURCE:-$ROOT/dist/KeyboardSwitch.app}"
PROFILE="${NOTARY_PROFILE:?Set NOTARY_PROFILE to a notarytool Keychain profile}"
ZIP="$ROOT/dist/KeyboardSwitch-notarization.zip"

codesign --verify --deep --strict --verbose=2 "$APP"
ditto -c -k --keepParent "$APP" "$ZIP"
xcrun notarytool submit "$ZIP" --keychain-profile "$PROFILE" --wait
xcrun stapler staple "$APP"
xcrun stapler validate "$APP"
13 changes: 13 additions & 0 deletions Scripts/package-release.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/bin/zsh
set -euo pipefail
ROOT="${0:A:h:h}"
VERSION="${VERSION:-$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$ROOT/Resources/Info.plist")}"
APP="${APP_SOURCE:-$ROOT/dist/KeyboardSwitch.app}"
ARCHIVE="$ROOT/dist/KeyboardSwitch-$VERSION-macos.zip"
[[ -d "$APP" ]] || "$ROOT/Scripts/build-app.sh"
APP_SOURCE="$APP" "$ROOT/Scripts/verify-bundle.sh"
rm -f "$ARCHIVE" "$ARCHIVE.sha256"
COPYFILE_DISABLE=1 ditto -c -k --norsrc --noextattr --noqtn --noacl --keepParent "$APP" "$ARCHIVE"
(cd "$ROOT/dist" && shasum -a 256 "${ARCHIVE:t}" > "${ARCHIVE:t}.sha256")
echo "$ARCHIVE"
echo "$ARCHIVE.sha256"
9 changes: 9 additions & 0 deletions Scripts/sign-app.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/bin/zsh
set -euo pipefail

ROOT="${0:A:h:h}"
APP="${APP_SOURCE:-$ROOT/dist/KeyboardSwitch.app}"
IDENTITY="${CODE_SIGN_IDENTITY:--}"
[[ -d "$APP" ]] || { echo "App bundle not found: $APP" >&2; exit 1; }
codesign --force --options runtime --timestamp --entitlements "$ROOT/Resources/KeyboardSwitch.entitlements" --sign "$IDENTITY" "$APP"
codesign --verify --deep --strict --verbose=2 "$APP"
Loading
Loading