diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..79e7612 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c43d8f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.build/ +.swiftpm/ +DerivedData/ +*.xcarchive +*.dSYM/ +dist/ +.DS_Store +*.p12 +*.provisionprofile diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..15256c4 --- /dev/null +++ b/.vscode/launch.json @@ -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" + } + ] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..abde6e3 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..0df9ccd --- /dev/null +++ b/Package.swift @@ -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"] + ), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..1c7ff5b --- /dev/null +++ b/README.md @@ -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. diff --git a/Resources/Info.plist b/Resources/Info.plist new file mode 100644 index 0000000..2306b7a --- /dev/null +++ b/Resources/Info.plist @@ -0,0 +1,18 @@ + + + + CFBundleDevelopmentRegionen + CFBundleExecutableKeyboardSwitchApp + CFBundleIconFileKeybridge + CFBundleIdentifierdev.keyboardswitch.app + CFBundleInfoDictionaryVersion6.0 + CFBundleNameKeyboard Switch + CFBundlePackageTypeAPPL + CFBundleShortVersionString0.1.0 + CFBundleVersion1 + LSMinimumSystemVersion15.0 + LSUIElement + NSBluetoothAlwaysUsageDescriptionKeyboard Switch connects and disconnects your pre-paired Magic Keyboard. + NSLocalNetworkUsageDescriptionKeyboard Switch discovers and coordinates with your trusted Macs on the local network. + NSBonjourServices_keyboardswitch._tcp + diff --git a/Resources/KeyboardSwitch.entitlements b/Resources/KeyboardSwitch.entitlements new file mode 100644 index 0000000..1c77540 --- /dev/null +++ b/Resources/KeyboardSwitch.entitlements @@ -0,0 +1,6 @@ + + + + + com.apple.security.app-sandbox + diff --git a/Resources/Keybridge.icns b/Resources/Keybridge.icns new file mode 100644 index 0000000..c6af63f Binary files /dev/null and b/Resources/Keybridge.icns differ diff --git a/Scripts/build-app.sh b/Scripts/build-app.sh new file mode 100755 index 0000000..48f2911 --- /dev/null +++ b/Scripts/build-app.sh @@ -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" diff --git a/Scripts/check-reproducible-bundle.sh b/Scripts/check-reproducible-bundle.sh new file mode 100755 index 0000000..206d2fa --- /dev/null +++ b/Scripts/check-reproducible-bundle.sh @@ -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" diff --git a/Scripts/ci-policy-checks.sh b/Scripts/ci-policy-checks.sh new file mode 100755 index 0000000..c484307 --- /dev/null +++ b/Scripts/ci-policy-checks.sh @@ -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" diff --git a/Scripts/hardware-acceptance.sh b/Scripts/hardware-acceptance.sh new file mode 100755 index 0000000..7020529 --- /dev/null +++ b/Scripts/hardware-acceptance.sh @@ -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" diff --git a/Scripts/install.sh b/Scripts/install.sh new file mode 100755 index 0000000..b29bf38 --- /dev/null +++ b/Scripts/install.sh @@ -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" diff --git a/Scripts/notarize.sh b/Scripts/notarize.sh new file mode 100755 index 0000000..14a6ae4 --- /dev/null +++ b/Scripts/notarize.sh @@ -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" diff --git a/Scripts/package-release.sh b/Scripts/package-release.sh new file mode 100755 index 0000000..f8aed00 --- /dev/null +++ b/Scripts/package-release.sh @@ -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" diff --git a/Scripts/sign-app.sh b/Scripts/sign-app.sh new file mode 100755 index 0000000..9f0f5a3 --- /dev/null +++ b/Scripts/sign-app.sh @@ -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" diff --git a/Scripts/uninstall.sh b/Scripts/uninstall.sh new file mode 100755 index 0000000..e90cca1 --- /dev/null +++ b/Scripts/uninstall.sh @@ -0,0 +1,15 @@ +#!/bin/zsh +set -euo pipefail + +DESTINATION="${DESTINATION:-$HOME/Applications}" +rm -rf "$DESTINATION/KeyboardSwitch.app" +defaults delete dev.keyboardswitch.app 2>/dev/null || true +echo "Removed Keyboard Switch from $DESTINATION and its preferences" + +if [[ "${1:-}" == "--remove-keychain" ]]; then + security delete-generic-password -s app.keybridge.identity 2>/dev/null || true + security delete-generic-password -s app.keybridge.trusted-peers 2>/dev/null || true + echo "Removed Keybridge identity and trusted-peer Keychain items" +else + echo "Keychain identity and peer trust were preserved. Pass --remove-keychain to remove only Keybridge-owned items." +fi diff --git a/Scripts/verify-bundle.sh b/Scripts/verify-bundle.sh new file mode 100755 index 0000000..a79ad78 --- /dev/null +++ b/Scripts/verify-bundle.sh @@ -0,0 +1,11 @@ +#!/bin/zsh +set -euo pipefail +ROOT="${0:A:h:h}" +APP="${APP_SOURCE:-$ROOT/dist/KeyboardSwitch.app}" +PLIST="$APP/Contents/Info.plist" +[[ -x "$APP/Contents/MacOS/KeyboardSwitchApp" ]] || { echo "Missing app executable" >&2; exit 1; } +[[ -f "$APP/Contents/Resources/Keybridge.icns" ]] || { echo "Missing Keybridge.icns" >&2; exit 1; } +[[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconFile' "$PLIST")" == "Keybridge" ]] || { echo "Icon is not declared" >&2; exit 1; } +[[ "$(/usr/libexec/PlistBuddy -c 'Print :LSMinimumSystemVersion' "$PLIST")" == "15.0" ]] || { echo "Unexpected deployment target" >&2; exit 1; } +codesign --verify --deep --strict "$APP" +echo "Verified bundle structure, icon, deployment target, and signature" diff --git a/Sources/KeyboardSwitchApp/AppConfiguration.swift b/Sources/KeyboardSwitchApp/AppConfiguration.swift new file mode 100644 index 0000000..fcc26f5 --- /dev/null +++ b/Sources/KeyboardSwitchApp/AppConfiguration.swift @@ -0,0 +1,75 @@ +import Foundation +import MagicSwitchNetworking + +struct AppConfiguration: Codable, Equatable { + var localMacName: String + var keyboardID: String? + var trustedPeerIDs: Set + var hasCompletedOnboarding: Bool + var guidedRoundTripConfirmed: Bool + + static var initial: Self { + .init(localMacName: Host.current().localizedName ?? "This Mac", keyboardID: nil, + trustedPeerIDs: [], hasCompletedOnboarding: false, + guidedRoundTripConfirmed: false) + } + + init(localMacName: String, keyboardID: String?, trustedPeerIDs: Set, + hasCompletedOnboarding: Bool, + guidedRoundTripConfirmed: Bool = false) { + self.localMacName = localMacName; self.keyboardID = keyboardID + self.trustedPeerIDs = trustedPeerIDs; self.hasCompletedOnboarding = hasCompletedOnboarding + self.guidedRoundTripConfirmed = guidedRoundTripConfirmed + } + + private enum CodingKeys: String, CodingKey { + case localMacName, keyboardID, trustedPeerIDs, hasCompletedOnboarding + case guidedRoundTripConfirmed + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + localMacName = try values.decode(String.self, forKey: .localMacName) + keyboardID = try values.decodeIfPresent(String.self, forKey: .keyboardID) + trustedPeerIDs = try values.decodeIfPresent(Set.self, forKey: .trustedPeerIDs) ?? [] + hasCompletedOnboarding = try values.decodeIfPresent(Bool.self, forKey: .hasCompletedOnboarding) ?? false + guidedRoundTripConfirmed = try values.decodeIfPresent(Bool.self, forKey: .guidedRoundTripConfirmed) ?? false + } +} + +protocol AppConfigurationPersisting: Sendable { + func load() -> AppConfiguration + func save(_ value: AppConfiguration) +} + +final class UserDefaultsConfigurationStore: AppConfigurationPersisting, @unchecked Sendable { + private let defaults: UserDefaults + private let key: String + init(defaults: UserDefaults = .standard, key: String = "Keybridge.AppConfiguration.v1") { + self.defaults = defaults; self.key = key + } + func load() -> AppConfiguration { + guard let data = defaults.data(forKey: key), let value = try? JSONDecoder().decode(AppConfiguration.self, from: data) else { return .initial } + return value + } + func save(_ value: AppConfiguration) { + guard let data = try? JSONEncoder().encode(value) else { return } + defaults.set(data, forKey: key) + } +} + +struct InstallationIdentity: Sendable { let id: UUID } + +func loadInstallationIdentity() -> InstallationIdentity { + do { return InstallationIdentity(id: try KeychainIdentityStore(service: "app.keybridge.identity").loadOrCreateIdentity().id) } + catch { + // Preserve the local UI/configuration identity if Keychain is temporarily unavailable. + // Secure peer enrollment still requires the Keychain-backed cryptographic identity. + let key = "Keybridge.InstallationFallbackID" + if let value = UserDefaults.standard.string(forKey: key), let id = UUID(uuidString: value) { + return InstallationIdentity(id: id) + } + let id = UUID(); UserDefaults.standard.set(id.uuidString, forKey: key) + return InstallationIdentity(id: id) + } +} diff --git a/Sources/KeyboardSwitchApp/AppModel.swift b/Sources/KeyboardSwitchApp/AppModel.swift new file mode 100644 index 0000000..84cbbb1 --- /dev/null +++ b/Sources/KeyboardSwitchApp/AppModel.swift @@ -0,0 +1,352 @@ +import AppKit +import Foundation +import Observation +import KeyboardSwitchCore +import MagicSwitchNetworking + +struct PeerMac: Identifiable, Codable, Hashable { + enum Availability: String, Codable { case current, online, offline } + let id: UUID + var name: String + var availability: Availability + var isTrusted: Bool +} + +struct KeyboardDevice: Identifiable, Codable, Hashable { + let id: UUID + var name: String + var detail: String + var isPaired: Bool + var isConnected: Bool +} + +enum TransferStage: Equatable { + case idle + case contacting(String) + case releasing + case connecting(String) + case verifying + case recovering + case complete(String) + case failed(String) + + var title: String { + switch self { + case .idle: "Ready" + case .contacting(let name): "Contacting \(name)…" + case .releasing: "Releasing keyboard…" + case .connecting(let name): "Connecting to \(name)…" + case .verifying: "Verifying connection…" + case .recovering: "Restoring previous connection…" + case .complete(let name): "Connected to \(name)" + case .failed: "Transfer failed" + } + } + + var isBusy: Bool { + switch self { + case .contacting, .releasing, .connecting, .verifying, .recovering: true + default: false + } + } +} + +@MainActor +@Observable +final class AppModel { + var localMacName: String { didSet { persist(); scheduleDisplayNameUpdate() } } + var keyboard = KeyboardDevice(id: UUID(), name: "Magic Keyboard", detail: "No keyboard selected", isPaired: false, isConnected: false) + var availableKeyboards: [KeyboardDevice] = [] + var peers: [PeerMac] = [] + var ownerID: UUID? + var transferStage: TransferStage = .idle + var hasCompletedOnboarding = false { didSet { persist() } } + var launchAtLogin = LaunchAtLoginController.isEnabled + var showAdvancedDiagnostics = false + var diagnosticEvents: [String] = [] + var setupPairingCode = "" + var guidedRoundTripConfirmed = false { didSet { persist() } } + var guidedTargetTransferCompleted = false + var guidedReturnTransferCompleted = false + + private let runtime: any AppRuntime + private let configurationStore: any AppConfigurationPersisting + private let identity: InstallationIdentity + private var configuredKeyboardID: KeyboardID? + private var peerTask: Task? + private var ownerTask: Task? + private var transferTask: Task? + private var lastTransferTarget: PeerMac? + private var enrollmentOffers: [UUID: EnrollmentOffer] = [:] + private var enrollmentCodes: [UUID: String] = [:] + private var guidedTransferTargetID: UUID? + private var nameUpdateTask: Task? + + init(runtime: (any AppRuntime)? = nil, + configurationStore: any AppConfigurationPersisting = UserDefaultsConfigurationStore()) { + self.configurationStore = configurationStore + self.identity = loadInstallationIdentity() + let configuration = configurationStore.load() + self.runtime = runtime ?? ProductionAppRuntime(displayName: configuration.localMacName) + self.localMacName = configuration.localMacName + self.hasCompletedOnboarding = configuration.hasCompletedOnboarding + self.guidedRoundTripConfirmed = configuration.guidedRoundTripConfirmed + self.configuredKeyboardID = configuration.keyboardID.map(KeyboardID.init) + diagnosticEvents.append("App started") + Task { await start() } + } + + var localPeer: PeerMac { + PeerMac(id: localID, name: localMacName, availability: .current, isTrusted: true) + } + + private var localID: UUID { identity.id } + + var allMacs: [PeerMac] { [localPeer] + peers } + var onboardingReady: Bool { + configuredKeyboardID != nil && keyboard.isPaired && peers.contains(where: { $0.isTrusted }) && guidedRoundTripConfirmed + } + var currentOwner: PeerMac? { allMacs.first(where: { $0.id == ownerID }) } + + var menuBarTitle: String { transferStage.isBusy ? transferStage.title : "Keybridge" } + var menuBarSymbol: String { + if transferStage.isBusy { return "arrow.triangle.2.circlepath" } + return keyboard.isConnected ? "keyboard.fill" : "keyboard" + } + var accessibilityStatus: String { + if let owner = currentOwner { return "Keybridge, keyboard connected to \(owner.name)" } + return "Keybridge, keyboard not connected" + } + + func beginPairing() { + setupPairingCode = "" + Task { + do { + let candidates = try await runtime.refreshEnrollmentCandidates() + enrollmentOffers = Dictionary(uniqueKeysWithValues: candidates.map { ($0.offer.peer.id, $0.offer) }) + enrollmentCodes = Dictionary(uniqueKeysWithValues: candidates.map { ($0.offer.peer.id, $0.verificationCode) }) + for candidate in candidates where !peers.contains(where: { $0.id == candidate.offer.peer.id }) { + peers.append(.init(id: candidate.offer.peer.id, name: candidate.offer.peer.name, availability: .online, isTrusted: false)) + } + setupPairingCode = candidates.first?.verificationCode ?? "" + log(candidates.isEmpty ? "No untrusted Macs found" : "Enrollment offer received; compare the code on both Macs") + } catch { transferStage = .failed(error.localizedDescription); log("Peer enrollment failed: \(error.localizedDescription)") } + } + } + + func addPreviewPeer() { + log("Searching for nearby Macs"); beginPairing() + } + + func verificationCode(for peer: PeerMac) -> String? { enrollmentCodes[peer.id] } + + func trust(_ peer: PeerMac) { + guard let offer = enrollmentOffers[peer.id], let code = enrollmentCodes[peer.id] else { return } + Task { + do { + try await runtime.confirmEnrollment(remote: offer, displayedCode: code) + if let index = peers.firstIndex(where: { $0.id == peer.id }) { peers[index].isTrusted = true } + enrollmentOffers[peer.id] = nil; enrollmentCodes[peer.id] = nil; persist(); log("Trusted peer \(peer.name)") + } catch { transferStage = .failed(error.localizedDescription); log("Peer verification failed: \(error.localizedDescription)") } + } + } + + func cancelTransfer() { transferTask?.cancel(); transferTask = nil; transferStage = .idle; log("Transfer cancelled") } + + func forget(_ peer: PeerMac) { + Task { + do { + try await runtime.forgetPeer(id: peer.id) + peers.removeAll { $0.id == peer.id } + var configuration = configurationStore.load(); configuration.trustedPeerIDs.remove(peer.id); configurationStore.save(configuration) + log("Forgot peer \(peer.name)") + } catch { transferStage = .failed(error.localizedDescription); log("Could not forget peer: \(error.localizedDescription)") } + } + } + + func refreshKeyboards() async { + do { + let found = try await runtime.keyboards() + availableKeyboards = found.map { KeyboardDevice(id: stableUUID($0.id.rawValue), name: $0.name, detail: $0.id.rawValue, isPaired: $0.isPaired, isConnected: $0.isConnected) } + if let configuredKeyboardID, let found = found.first(where: { $0.id == configuredKeyboardID }) { apply(found) } + log("Found \(found.count) paired keyboard(s)") + } catch { transferStage = .failed(error.localizedDescription); log("Bluetooth inventory failed: \(error.localizedDescription)") } + } + + func selectKeyboard(_ device: KeyboardDevice) { + configuredKeyboardID = KeyboardID(device.detail) + keyboard = device + if device.isConnected { ownerID = localID } + persist(); log("Selected paired keyboard \(device.name)") + } + + func moveKeyboard(to peer: PeerMac) { + guard peer.availability != .offline else { transferStage = .failed("The target Mac is offline."); return } + guard peer.isTrusted else { transferStage = .failed("Verify the target Mac first."); return } + guard !transferStage.isBusy else { return } + guard let configuredKeyboardID else { transferStage = .failed(AppRuntimeError.keyboardNotSelected.localizedDescription); return } + guard let ownerID else { transferStage = .failed("The current keyboard owner is unknown."); return } + lastTransferTarget = peer + transferTask?.cancel() + transferTask = Task { + do { + try await runtime.transfer(keyboardID: configuredKeyboardID, source: PeerID(ownerID), target: PeerID(peer.id)) { [weak self] progress in + Task { @MainActor in self?.consume(progress, targetName: peer.name) } + } + self.ownerID = peer.id + self.keyboard.isConnected = peer.id == self.localID + if peer.id == self.guidedTransferTargetID { self.guidedTargetTransferCompleted = true } + if peer.id == self.localID && self.guidedTargetTransferCompleted { self.guidedReturnTransferCompleted = true } + self.transferStage = .complete(peer.name); self.log(self.transferStage.title); self.transferTask = nil + } catch { + if error is CancellationError { self.transferStage = .idle; self.log("Transfer cancelled") } + else { self.transferStage = .failed(error.localizedDescription); self.log("Transfer failed: \(error.localizedDescription)") } + self.transferTask = nil + } + } + } + + func reconnectLocally() { moveKeyboard(to: localPeer) } + func beginGuidedTransfer(to peer: PeerMac) { + guidedTransferTargetID = peer.id + guidedTargetTransferCompleted = false + guidedReturnTransferCompleted = false + moveKeyboard(to: peer) + } + func beginGuidedReturn() { + guard guidedTargetTransferCompleted else { return } + moveKeyboard(to: localPeer) + } + func retry() { + guard let lastTransferTarget else { transferStage = .idle; return } + transferTask = nil + transferStage = .idle + moveKeyboard(to: lastTransferTarget) + } + func confirmGuidedRoundTrip() { + guard guidedTargetTransferCompleted, guidedReturnTransferCompleted else { return } + guidedRoundTripConfirmed = true + log("User confirmed typed HID input on target and return path") + } + + func finishOnboarding() { + guard onboardingReady else { + transferStage = .failed("Select a paired keyboard, trust another Mac, and confirm the guided round trip first.") + return + } + hasCompletedOnboarding = true; log("Completed onboarding") + } + + func resetSetup() { + let peersToForget = peers + Task { + for peer in peersToForget { try? await runtime.forgetPeer(id: peer.id) } + peers.removeAll() + configuredKeyboardID = nil + keyboard = .init(id: UUID(), name: "Magic Keyboard", detail: "No keyboard selected", isPaired: false, isConnected: false) + ownerID = nil + guidedRoundTripConfirmed = false + hasCompletedOnboarding = false + configurationStore.save(.init(localMacName: localMacName, keyboardID: nil, + trustedPeerIDs: [], hasCompletedOnboarding: false, + guidedRoundTripConfirmed: false)) + log("Reset local setup and peer trust; Bluetooth pairing was preserved") + } + } + + func setLaunchAtLogin(_ enabled: Bool) { + do { try LaunchAtLoginController.setEnabled(enabled); launchAtLogin = enabled } + catch { launchAtLogin = LaunchAtLoginController.isEnabled; transferStage = .failed("Launch at login: \(error.localizedDescription)") } + } + + func diagnosticsText() -> String { + (["Keybridge diagnostics", "Mac: \(localMacName)", "Keyboard: \(keyboard.name)", "State: \(transferStage.title)", "Peers: \(peers.count)", ""] + diagnosticEvents).joined(separator: "\n") + } + + func copyDiagnostics() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(diagnosticsText(), forType: .string) + } + + func exportDiagnostics() { + let panel = NSSavePanel() + panel.nameFieldStringValue = "keybridge-diagnostics.txt" + guard panel.runModal() == .OK, let url = panel.url else { return } + try? diagnosticsText().write(to: url, atomically: true, encoding: .utf8) + } + + private func log(_ event: String) { + diagnosticEvents.append("\(Date().formatted(date: .omitted, time: .standard)): \(event)") + if diagnosticEvents.count > 200 { diagnosticEvents.removeFirst() } + } + + private func start() async { + await refreshKeyboards() + let updates = await runtime.peerUpdates() + let ownerUpdates = await runtime.ownerUpdates() + await runtime.start() + peerTask = Task { [weak self] in + for await discovered in updates { + guard let self else { return } + let trusted = self.configurationStore.load().trustedPeerIDs + let enrolled = discovered.prefix(2).map { PeerMac(id: $0.id, name: $0.name, availability: $0.isOnline ? .online : .offline, isTrusted: trusted.contains($0.id)) } + let enrolledIDs = Set(enrolled.map(\.id)) + let candidates = self.peers.filter { self.enrollmentOffers[$0.id] != nil && !enrolledIDs.contains($0.id) } + self.peers = enrolled + candidates + } + } + ownerTask = Task { [weak self] in + for await owner in ownerUpdates { + guard let self else { return } + self.ownerID = owner + self.keyboard.isConnected = owner == self.localID + let ownerName = owner.flatMap { id in self.allMacs.first(where: { $0.id == id })?.name } + self.log(ownerName.map { "Keyboard owner is \($0)" } ?? "Keyboard owner unknown") + } + } + } + + private func consume(_ progress: SwitchProgress, targetName: String) { + transferStage = switch progress.stage { + case .idle: .idle + case .validating, .acquiringLease: .contacting(targetName) + case .releasing: .releasing + case .connecting: .connecting(targetName) + case .verifying: .verifying + case .recovering: .recovering + case .completed: .complete(targetName) + case .failed: .failed(progress.detail) + } + log(progress.detail) + } + + private func apply(_ device: BluetoothKeyboard) { + keyboard = KeyboardDevice(id: stableUUID(device.id.rawValue), name: device.name, detail: device.id.rawValue, isPaired: device.isPaired, isConnected: device.isConnected) + if device.isConnected { ownerID = localID } + } + + private func stableUUID(_ value: String) -> UUID { + var bytes = Array(value.utf8.prefix(16)); bytes += repeatElement(0, count: max(0, 16 - bytes.count)) + return UUID(uuid: (bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15])) + } + + private func persist() { + var trusted = configurationStore.load().trustedPeerIDs + for peer in peers where peer.isTrusted { trusted.insert(peer.id) } + configurationStore.save(.init(localMacName: localMacName, keyboardID: configuredKeyboardID?.rawValue, + trustedPeerIDs: trusted, hasCompletedOnboarding: hasCompletedOnboarding, + guidedRoundTripConfirmed: guidedRoundTripConfirmed)) + } + + private func scheduleDisplayNameUpdate() { + nameUpdateTask?.cancel() + let name = localMacName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return } + nameUpdateTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(400)) + guard !Task.isCancelled, let self else { return } + do { try await self.runtime.updateDisplayName(name); self.log("Updated advertised Mac name") } + catch { self.transferStage = .failed("Mac name update failed: \(error.localizedDescription)") } + } + } +} diff --git a/Sources/KeyboardSwitchApp/AppRuntime.swift b/Sources/KeyboardSwitchApp/AppRuntime.swift new file mode 100644 index 0000000..8ff623f --- /dev/null +++ b/Sources/KeyboardSwitchApp/AppRuntime.swift @@ -0,0 +1,434 @@ +import Foundation +import KeyboardSwitchBluetooth +import KeyboardSwitchCore +import MagicSwitchNetworking + +struct RuntimePeer: Sendable, Equatable { + let id: UUID + let name: String + let isOnline: Bool +} + +struct RuntimeEnrollmentCandidate: Sendable, Equatable { + let offer: EnrollmentOffer + let verificationCode: String +} + +enum AppRuntimeError: LocalizedError, Equatable { + case serviceUnavailable(String) + case keyboardNotSelected + case peerNotTrusted + case requestTimedOut + case commandRejected(String) + + var errorDescription: String? { + switch self { + case .serviceUnavailable(let detail): "Secure peer service is unavailable: \(detail)" + case .keyboardNotSelected: "Select a paired Magic Keyboard first." + case .peerNotTrusted: "The Mac is not trusted or is offline." + case .requestTimedOut: "The other Mac did not respond in time." + case .commandRejected(let detail): detail + } + } +} + +protocol AppRuntime: Sendable { + func keyboards() async throws -> [BluetoothKeyboard] + func peerUpdates() async -> AsyncStream<[RuntimePeer]> + func ownerUpdates() async -> AsyncStream + func start() async + func stop() async + func transfer(keyboardID: KeyboardID, source: PeerID, target: PeerID, + progress: @escaping @Sendable (SwitchProgress) -> Void) async throws + func localEnrollmentOffer() async throws -> EnrollmentOffer + func enrollmentCode(remote: EnrollmentOffer) async throws -> String + func confirmEnrollment(remote: EnrollmentOffer, displayedCode: String) async throws + func forgetPeer(id: UUID) async throws + func refreshEnrollmentCandidates() async throws -> [RuntimeEnrollmentCandidate] + func updateDisplayName(_ name: String) async throws +} + +extension AppRuntime { + func ownerUpdates() async -> AsyncStream { AsyncStream { $0.finish() } } + func localEnrollmentOffer() async throws -> EnrollmentOffer { throw AppRuntimeError.serviceUnavailable("Enrollment is unsupported") } + func enrollmentCode(remote: EnrollmentOffer) async throws -> String { throw AppRuntimeError.serviceUnavailable("Enrollment is unsupported") } + func confirmEnrollment(remote: EnrollmentOffer, displayedCode: String) async throws { throw AppRuntimeError.serviceUnavailable("Enrollment is unsupported") } + func forgetPeer(id: UUID) async throws { throw AppRuntimeError.serviceUnavailable("Enrollment is unsupported") } + func refreshEnrollmentCandidates() async throws -> [RuntimeEnrollmentCandidate] { throw AppRuntimeError.serviceUnavailable("Enrollment is unsupported") } + func updateDisplayName(_ name: String) async throws {} +} + +/// Production composition root. All remote packets are authenticated by `PeerService`; Bluetooth +/// commands additionally require the sender to hold the current fenced transaction lease. +actor ProductionAppRuntime: AppRuntime { + private enum Reply { case command(Bool, Bool?, String?), lease(Bool, UInt64) } + + private let bluetooth: any BluetoothAdapter + private let identity: PeerIdentity + private let service: PeerService + private var continuation: AsyncStream<[RuntimePeer]>.Continuation? + private var updates: AsyncStream<[RuntimePeer]>? + private var ownerContinuation: AsyncStream.Continuation? + private var ownerStream: AsyncStream? + private var knownOwner: UUID? + private var ownership: OwnershipState + private var knownKeyboardID: KeyboardID? + private var messageTask: Task? + private var discoveryTask: Task? + private var replies: [UUID: Reply] = [:] + private var replyCorrelator = ReplyCorrelator() + private var leaseAuthority = LeaseAuthority() + private var localOffer: EnrollmentOffer? + private var started = false + private var startupFailure: String? + private let identityLoadFailure: String? + + init(bluetooth: any BluetoothAdapter = MacOSBluetoothAdapter(), + identityStore: any IdentityPersisting = KeychainIdentityStore(service: "app.keybridge.identity"), + displayName: String = Host.current().localizedName ?? "This Mac", + trustStore: KeychainTrustStore = .init(service: "app.keybridge.trusted-peers"), + ownershipStore: any OwnershipEpochPersisting = UserDefaultsOwnershipEpochStore()) { + self.bluetooth = bluetooth + self.ownership = OwnershipState(store: ownershipStore) + self.knownOwner = self.ownership.ownerID + do { + self.identity = try identityStore.loadOrCreateIdentity() + self.identityLoadFailure = nil + } catch { + // Keep initialization non-throwing for SwiftUI composition, but never start networking + // with an identity that was not durably loaded from the configured secure store. + self.identity = PeerIdentity() + self.identityLoadFailure = error.localizedDescription + } + self.service = PeerService(identity: self.identity, displayName: displayName, trustStore: trustStore) + } + + func keyboards() async throws -> [BluetoothKeyboard] { + let devices = try await bluetooth.keyboards() + if devices.count == 1 { knownKeyboardID = devices[0].id } + if devices.contains(where: \.isConnected) { + knownOwner = identity.id; ownership.restorePersistedOwner(identity.id); ownerContinuation?.yield(identity.id) + } + return devices + } + + func peerUpdates() -> AsyncStream<[RuntimePeer]> { + if let updates { return updates } + let stream = AsyncStream<[RuntimePeer]> { continuation = $0 } + updates = stream + return stream + } + + func ownerUpdates() -> AsyncStream { + if let ownerStream { return ownerStream } + let stream = AsyncStream { ownerContinuation = $0 } + ownerStream = stream + if let knownOwner { ownerContinuation?.yield(knownOwner) } + return stream + } + + func start() async { + guard !started else { return } + guard identityLoadFailure == nil else { continuation?.yield([]); return } + startupFailure = nil + do { try await service.start(); started = true } + catch { startupFailure = error.localizedDescription; continuation?.yield([]); return } + messageTask = Task { [weak self, service] in + for await received in service.messages { await self?.handle(received) } + } + discoveryTask = Task { [weak self] in + while !Task.isCancelled { + await self?.publishPeers() + await self?.broadcastOwnerStatus(requireLocalOwnership: true) + try? await Task.sleep(for: .seconds(1)) + } + } + } + + func stop() async { + messageTask?.cancel(); discoveryTask?.cancel() + messageTask = nil; discoveryTask = nil; started = false; startupFailure = nil + await service.stop() + } + + func localEnrollmentOffer() async throws -> EnrollmentOffer { + if let identityLoadFailure { throw AppRuntimeError.serviceUnavailable("secure identity unavailable: \(identityLoadFailure)") } + if let localOffer { return localOffer } + let offer = await service.localEnrollmentOffer(); localOffer = offer; return offer + } + + func enrollmentCode(remote: EnrollmentOffer) async throws -> String { + Enrollment.verificationCode(local: try await localEnrollmentOffer(), remote: remote) + } + + func confirmEnrollment(remote: EnrollmentOffer, displayedCode: String) async throws { + let local = try await localEnrollmentOffer() + try await service.confirmEnrollment(local: local, remote: remote, displayedCode: displayedCode) + localOffer = nil + await publishPeers() + } + + func forgetPeer(id: UUID) async throws { try await service.forgetPeer(id: id); await publishPeers() } + + func updateDisplayName(_ name: String) async throws { + try await service.updateDisplayName(name) + localOffer = nil + } + + func refreshEnrollmentCandidates() async throws -> [RuntimeEnrollmentCandidate] { + if let startupFailure { throw AppRuntimeError.serviceUnavailable("startup failed: \(startupFailure)") } + guard started else { throw AppRuntimeError.serviceUnavailable("not started") } + await service.refreshEnrollmentOffers() + let local = try await localEnrollmentOffer() + return await service.discoveredEnrollmentOffers().map { + RuntimeEnrollmentCandidate(offer: $0, verificationCode: Enrollment.verificationCode(local: local, remote: $0)) + } + } + + func transfer(keyboardID: KeyboardID, source: PeerID, target: PeerID, + progress: @escaping @Sendable (SwitchProgress) -> Void) async throws { + guard source != target else { throw KeyboardSwitchCore.SwitchFailure.samePeer } + if let identityLoadFailure { throw AppRuntimeError.serviceUnavailable("secure identity unavailable: \(identityLoadFailure)") } + if let startupFailure { throw AppRuntimeError.serviceUnavailable("startup failed: \(startupFailure)") } + guard started else { throw AppRuntimeError.serviceUnavailable("not started") } + let transactionID = UUID() + knownKeyboardID = keyboardID + let wallClock = UInt64(Date().timeIntervalSince1970 * 1_000_000) + let fence = ownership.nextEpoch(wallClock: wallClock) + let expiry = Date().addingTimeInterval(30) + let trustedPeerIDs = try await service.trustedPeers().map(\.id) + let onlinePeerIDs = try await service.trustedOnlinePeers().map(\.id) + guard onlinePeerIDs.contains(source.rawValue) || source.rawValue == identity.id else { throw KeyboardSwitchCore.SwitchFailure.sourceUnreachable } + guard onlinePeerIDs.contains(target.rawValue) || target.rawValue == identity.id else { throw KeyboardSwitchCore.SwitchFailure.targetUnreachable } + + progress(.init(transactionID: transactionID, stage: .acquiringLease, detail: "Reserving keyboard transfer")) + // Every online member votes, so a third Mac fences concurrent A→B and C→A claims. + // Membership is the fixed configured trust set, not the currently visible subset. A + // partitioned member must make the claim fail rather than permit split-brain ownership. + let voters = Self.requiredVoters(trustedPeerIDs: trustedPeerIDs, localID: identity.id) + var acquired: [UUID] = [] + var sourceReleased = false + do { + for peer in voters.sorted(by: { $0.uuidString < $1.uuidString }) { + let accepted = try await claimLease(on: peer, transactionID: transactionID, expiry: expiry, fence: fence) + guard accepted else { throw KeyboardSwitchCore.SwitchFailure.leaseUnavailable } + acquired.append(peer) + } + try await renew(acquired, transactionID: transactionID, fence: fence) + progress(.init(transactionID: transactionID, stage: .releasing, detail: "Releasing keyboard from source")) + try await retryCommand(.disconnect, keyboardID, on: source.rawValue, transactionID: transactionID, fence: fence) + guard try await status(keyboardID, on: source.rawValue, transactionID: transactionID, fence: fence) == false else { + throw KeyboardSwitchCore.SwitchFailure.sourceReleaseFailed + } + sourceReleased = true + try await renew(acquired, transactionID: transactionID, fence: fence) + progress(.init(transactionID: transactionID, stage: .connecting, detail: "Connecting keyboard to target")) + do { + try await retryCommand(.connect, keyboardID, on: target.rawValue, transactionID: transactionID, fence: fence) + progress(.init(transactionID: transactionID, stage: .verifying, detail: "Verifying keyboard connection")) + guard try await status(keyboardID, on: target.rawValue, transactionID: transactionID, fence: fence) else { + throw KeyboardSwitchCore.SwitchFailure.targetConnectionFailed(recoveredSource: false) + } + } catch { + progress(.init(transactionID: transactionID, stage: .recovering, detail: "Restoring keyboard to source")) + let recovered: Bool + do { + try await renew(acquired, transactionID: transactionID, fence: fence) + try await retryCommand(.connect, keyboardID, on: source.rawValue, transactionID: transactionID, fence: fence) + recovered = try await status(keyboardID, on: source.rawValue, transactionID: transactionID, fence: fence) + } catch { recovered = false } + knownOwner = recovered ? source.rawValue : nil + ownership.commitLocal(ownerID: knownOwner, epoch: fence) + ownerContinuation?.yield(knownOwner) + await broadcastOwnerStatus() + if recovered { sourceReleased = false } + if error is CancellationError { throw CancellationError() } + throw KeyboardSwitchCore.SwitchFailure.targetConnectionFailed(recoveredSource: recovered) + } + progress(.init(transactionID: transactionID, stage: .completed, detail: "Keyboard moved")) + knownOwner = target.rawValue; ownership.commitLocal(ownerID: target.rawValue, epoch: fence); ownerContinuation?.yield(target.rawValue) + for peer in trustedPeerIDs where peer != identity.id { + try? await service.send(.switchCompleted(transactionID: transactionID, ownerID: target.rawValue, epoch: fence), to: peer) + } + } catch { + // Once the source has released the physical device, no later failure (including + // cancellation or a failed renewal) may simply abandon it. Attempt restoration while + // still fenced; if the lease cannot be renewed, report unknown ownership rather than + // issuing an unfenced Bluetooth command. + if sourceReleased { + progress(.init(transactionID: transactionID, stage: .recovering, detail: "Restoring keyboard to source")) + let recovered: Bool + do { + try await renew(acquired, transactionID: transactionID, fence: fence) + try await retryCommand(.connect, keyboardID, on: source.rawValue, transactionID: transactionID, fence: fence) + recovered = try await status(keyboardID, on: source.rawValue, transactionID: transactionID, fence: fence) + } catch { recovered = false } + knownOwner = recovered ? source.rawValue : nil + ownership.commitLocal(ownerID: knownOwner, epoch: fence) + ownerContinuation?.yield(knownOwner) + await broadcastOwnerStatus() + } + for peer in trustedPeerIDs where peer != identity.id { + try? await service.send(.switchCancelled(transactionID: transactionID), to: peer, timeout: .seconds(2)) + } + progress(.init(transactionID: transactionID, stage: .failed, detail: error.localizedDescription)) + await release(acquired, transactionID: transactionID, fence: fence) + throw error + } + await release(acquired, transactionID: transactionID, fence: fence) + } + + /// Renews the same fenced transaction on every voter. A partial renewal is treated as lease + /// loss; callers must not issue another Bluetooth operation under a subset lease. + private func renew(_ peers: [UUID], transactionID: UUID, fence: UInt64) async throws { + let expiry = Date().addingTimeInterval(30) + for peer in peers { + guard try await claimLease(on: peer, transactionID: transactionID, expiry: expiry, fence: fence) else { + throw KeyboardSwitchCore.SwitchFailure.leaseUnavailable + } + } + } + + private func claimLease(on peer: UUID, transactionID: UUID, expiry: Date, fence: UInt64) async throws -> Bool { + if peer == identity.id { return acceptLease(claimantID: identity.id, transactionID: transactionID, expiry: expiry, fence: fence) } + let requestID = UUID() + replyCorrelator.expect(requestID: requestID, peerID: peer, transactionID: transactionID, fence: fence) + defer { replyCorrelator.remove(requestID: requestID); replies[requestID] = nil } + try await service.send(.leaseClaim(requestID: requestID, transactionID: transactionID, expiresAt: expiry, fence: fence), to: peer) + guard case .lease(let accepted, let returnedFence) = try await waitForReply(requestID), returnedFence == fence else { return false } + return accepted + } + + private func release(_ peers: [UUID], transactionID: UUID, fence: UInt64) async { + for peer in peers { + if peer == identity.id { _ = leaseAuthority.release(claimantID: identity.id, transactionID: transactionID, fence: fence) } + else { try? await service.send(.leaseRelease(transactionID: transactionID, fence: fence), to: peer) } + } + } + + private func command(_ command: KeyboardCommand, _ keyboard: KeyboardID, on peer: UUID, transactionID: UUID, fence: UInt64) async throws { + if peer == identity.id { + guard ownsValidLocalLease(transactionID: transactionID, fence: fence) else { throw KeyboardSwitchCore.SwitchFailure.leaseUnavailable } + try await execute(command, keyboard: keyboard); return + } + let requestID = UUID() + replyCorrelator.expect(requestID: requestID, peerID: peer, transactionID: transactionID, fence: fence) + defer { replyCorrelator.remove(requestID: requestID); replies[requestID] = nil } + try await service.send(.keyboardCommand(requestID: requestID, transactionID: transactionID, fence: fence, command: command, deviceID: keyboard.rawValue), to: peer) + guard case .command(let succeeded, _, let detail) = try await waitForReply(requestID), succeeded else { + throw AppRuntimeError.commandRejected("Remote Bluetooth command failed") + } + _ = detail + } + + private func retryCommand(_ command: KeyboardCommand, _ keyboard: KeyboardID, on peer: UUID, transactionID: UUID, fence: UInt64) async throws { + var delay = Duration.milliseconds(250) + var lastError: (any Error)? + for attempt in 1...3 { + do { try await self.command(command, keyboard, on: peer, transactionID: transactionID, fence: fence); return } + catch { lastError = error } + if attempt < 3 { try await Task.sleep(for: delay); delay *= 2 } + } + throw lastError ?? AppRuntimeError.commandRejected("Bluetooth command failed") + } + + private func status(_ keyboard: KeyboardID, on peer: UUID, transactionID: UUID, fence: UInt64) async throws -> Bool { + if peer == identity.id { + guard ownsValidLocalLease(transactionID: transactionID, fence: fence) else { throw KeyboardSwitchCore.SwitchFailure.leaseUnavailable } + return try await bluetooth.keyboard(id: keyboard).isConnected + } + let requestID = UUID() + replyCorrelator.expect(requestID: requestID, peerID: peer, transactionID: transactionID, fence: fence) + defer { replyCorrelator.remove(requestID: requestID); replies[requestID] = nil } + try await service.send(.keyboardCommand(requestID: requestID, transactionID: transactionID, fence: fence, command: .status, deviceID: keyboard.rawValue), to: peer) + let reply = try await waitForReply(requestID) + guard case .command(let succeeded, let connected, let detail) = reply else { throw AppRuntimeError.commandRejected("Invalid remote status response") } + guard succeeded, let connected else { throw AppRuntimeError.commandRejected(detail ?? "Remote status failed") } + return connected + } + + private func waitForReply(_ id: UUID) async throws -> Reply { + // Bound each request well below the lease period so retries and recovery retain time to + // renew rather than predictably crossing a 30-second lease. + for _ in 0..<30 { + if let reply = replies.removeValue(forKey: id) { return reply } + try await Task.sleep(for: .milliseconds(100)) + } + throw AppRuntimeError.requestTimedOut + } + + private func handle(_ received: ReceivedPeerMessage) async { + switch received.message { + case .leaseClaim(let requestID, let transactionID, let expiresAt, let fence): + let accepted = acceptLease(claimantID: received.peer.id, transactionID: transactionID, expiry: expiresAt, fence: fence) + try? await service.send(.leaseDecision(requestID: requestID, transactionID: transactionID, accepted: accepted, fence: fence), to: received.peer.id) + case .leaseDecision(let requestID, let transactionID, let accepted, let fence): + if replyCorrelator.accepts(requestID: requestID, peerID: received.peer.id, transactionID: transactionID, fence: fence) { replies[requestID] = .lease(accepted, fence) } + case .leaseRelease(let transactionID, let fence): + _ = leaseAuthority.release(claimantID: received.peer.id, transactionID: transactionID, fence: fence) + case .switchCancelled(let transactionID): + _ = leaseAuthority.cancel(claimantID: received.peer.id, transactionID: transactionID) + case .keyboardCommand(let requestID, let transactionID, let fence, let command, let deviceID): + guard leaseAuthority.authorizes(claimantID: received.peer.id, transactionID: transactionID, fence: fence) else { + try? await service.send(.keyboardCommandResult(requestID: requestID, transactionID: transactionID, fence: fence, succeeded: false, connected: nil, detail: "No valid transfer lease"), to: received.peer.id); return + } + do { + let keyboard = KeyboardID(deviceID); try await execute(command, keyboard: keyboard) + let connected = try await bluetooth.keyboard(id: keyboard).isConnected + try await service.send(.keyboardCommandResult(requestID: requestID, transactionID: transactionID, fence: fence, succeeded: true, connected: connected, detail: nil), to: received.peer.id) + } catch { + try? await service.send(.keyboardCommandResult(requestID: requestID, transactionID: transactionID, fence: fence, succeeded: false, connected: nil, detail: error.localizedDescription), to: received.peer.id) + } + case .keyboardCommandResult(let requestID, let transactionID, let fence, let succeeded, let connected, let detail): + if replyCorrelator.accepts(requestID: requestID, peerID: received.peer.id, transactionID: transactionID, fence: fence) { replies[requestID] = .command(succeeded, connected, detail) } + case .switchCompleted(_, let ownerID, let epoch): applyRemoteOwner(ownerID, epoch: epoch) + case .keyboardStatus(_, _, let ownerID, let epoch, _): applyRemoteOwner(ownerID, epoch: epoch) + default: break + } + } + + private func execute(_ command: KeyboardCommand, keyboard: KeyboardID) async throws { + switch command { case .connect: try await bluetooth.connect(id: keyboard); case .disconnect: try await bluetooth.disconnect(id: keyboard); case .status: _ = try await bluetooth.keyboard(id: keyboard) } + } + + private func acceptLease(claimantID: UUID, transactionID: UUID, expiry: Date, fence: UInt64) -> Bool { + leaseAuthority.accept(claimantID: claimantID, transactionID: transactionID, expiry: expiry, fence: fence) + } + + private func ownsValidLocalLease(transactionID: UUID, fence: UInt64, now: Date = .now) -> Bool { + leaseAuthority.authorizes(claimantID: identity.id, transactionID: transactionID, fence: fence, now: now) + } + + static func isAcceptableLeaseExpiry(_ expiry: Date, now: Date = .now) -> Bool { + var authority = LeaseAuthority() + return authority.accept(claimantID: UUID(), transactionID: UUID(), expiry: expiry, fence: 0, now: now) + } + + static func requiredVoters(trustedPeerIDs: [UUID], localID: UUID) -> Set { + Set(trustedPeerIDs).union([localID]) + } + + private func publishPeers() async { + let trusted = (try? await service.trustedPeers()) ?? [] + let online = Set(((try? await service.trustedOnlinePeers()) ?? []).map(\.id)) + continuation?.yield(trusted.map { RuntimePeer(id: $0.id, name: $0.name, isOnline: online.contains($0.id)) }) + } + + private func applyRemoteOwner(_ ownerID: UUID?, epoch: UInt64) { + guard ownership.apply(ownerID: ownerID, epoch: epoch) else { return } + knownOwner = ownerID; ownerContinuation?.yield(ownerID) + } + + private func broadcastOwnerStatus(requireLocalOwnership: Bool = false) async { + guard let knownKeyboardID else { return } + // Periodic reports are authoritative only from the Mac physically believed to own the + // keyboard. This prevents a rejoining non-owner from replaying stale ownership forever. + if requireLocalOwnership, knownOwner != identity.id { return } + let peers = (try? await service.trustedOnlinePeers()) ?? [] + for peer in peers { + try? await service.send(.keyboardStatus(deviceID: knownKeyboardID.rawValue, connected: knownOwner != nil, + ownerID: knownOwner, epoch: ownership.epoch, + transactionID: nil), to: peer.id, timeout: .seconds(2)) + } + } +} diff --git a/Sources/KeyboardSwitchApp/KeyboardSwitchApp.swift b/Sources/KeyboardSwitchApp/KeyboardSwitchApp.swift new file mode 100644 index 0000000..1d443b8 --- /dev/null +++ b/Sources/KeyboardSwitchApp/KeyboardSwitchApp.swift @@ -0,0 +1,31 @@ +import SwiftUI + +@main +struct KeyboardSwitchApp: App { + @State private var model = AppModel() + + var body: some Scene { + MenuBarExtra { + MenuPopoverView() + .environment(model) + } label: { + Label(model.menuBarTitle, systemImage: model.menuBarSymbol) + .accessibilityLabel(model.accessibilityStatus) + } + .menuBarExtraStyle(.window) + + Settings { + SettingsView() + .environment(model) + .frame(minWidth: 620, minHeight: 520) + } + + Window("Welcome to Keybridge", id: "onboarding") { + OnboardingView() + .environment(model) + .frame(minWidth: 680, minHeight: 570) + } + .windowResizability(.contentSize) + .defaultLaunchBehavior(model.hasCompletedOnboarding ? .suppressed : .presented) + } +} diff --git a/Sources/KeyboardSwitchApp/MenuPopoverView.swift b/Sources/KeyboardSwitchApp/MenuPopoverView.swift new file mode 100644 index 0000000..2a9680b --- /dev/null +++ b/Sources/KeyboardSwitchApp/MenuPopoverView.swift @@ -0,0 +1,128 @@ +import AppKit +import SwiftUI + +struct MenuPopoverView: View { + @Environment(AppModel.self) private var model + @Environment(\.openSettings) private var openSettings + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + VStack(spacing: 0) { + header + Divider() + keyboardCard + Divider().padding(.horizontal, 16) + macList + Divider() + footer + } + .frame(width: 360) + .background(Color(nsColor: .windowBackgroundColor)) + .animation(reduceMotion ? nil : .snappy, value: model.transferStage) + } + + private var header: some View { + HStack(spacing: 10) { + ZStack { + RoundedRectangle(cornerRadius: 10).fill(Color.accentColor.gradient) + Image(systemName: "keyboard.badge.ellipsis").foregroundStyle(.white).font(.title3) + }.frame(width: 38, height: 38) + VStack(alignment: .leading, spacing: 2) { + Text("Keybridge").font(.headline) + Text(model.transferStage.title).font(.caption).foregroundStyle(.secondary) + .accessibilityLabel("Transfer status: \(model.transferStage.title)") + } + Spacer() + if model.transferStage.isBusy { ProgressView().controlSize(.small) } + }.padding(16) + } + + private var keyboardCard: some View { + HStack(spacing: 12) { + Image(systemName: model.keyboard.isConnected ? "keyboard.fill" : "keyboard") + .font(.title2).foregroundStyle(model.keyboard.isPaired ? Color.accentColor : .secondary) + .frame(width: 34) + VStack(alignment: .leading, spacing: 2) { + Text(model.keyboard.name).fontWeight(.medium) + Text(ownerText).font(.caption).foregroundStyle(.secondary) + } + Spacer() + Circle().fill(model.currentOwner == nil ? .gray : .green).frame(width: 8, height: 8) + .accessibilityLabel(model.currentOwner == nil ? "Disconnected" : "Connected") + }.padding(16) + } + + private var ownerText: String { + if let owner = model.currentOwner { return "Connected to \(owner.name)" } + return model.keyboard.isPaired ? "Not connected" : "Setup required" + } + + private var macList: some View { + VStack(alignment: .leading, spacing: 8) { + Text("MOVE KEYBOARD TO").font(.caption2.weight(.semibold)).foregroundStyle(.secondary) + ForEach(model.allMacs) { peer in + MacDestinationRow(peer: peer) + } + if model.peers.isEmpty { + Text("Add another Mac in Settings to start switching.") + .font(.caption).foregroundStyle(.secondary).padding(.vertical, 6) + } + if case .failed(let reason) = model.transferStage { + Label(reason, systemImage: "exclamationmark.triangle.fill") + .font(.caption).foregroundStyle(.orange) + HStack { Button("Retry") { model.retry() }; Button("Reconnect here") { model.reconnectLocally() } } + } + if model.transferStage.isBusy { + Button("Cancel Transfer", role: .cancel) { model.cancelTransfer() } + .accessibilityHint("Stops the transfer and may attempt to restore the previous Mac") + } + }.padding(16) + } + + private var footer: some View { + HStack { + Button { openSettings() } label: { Label("Settings", systemImage: "gearshape") } + .buttonStyle(.plain) + Spacer() + Menu { + Button("Copy Diagnostics") { model.copyDiagnostics() } + Button("Export Diagnostics…") { model.exportDiagnostics() } + Divider() + Button("Quit Keybridge") { NSApplication.shared.terminate(nil) } + } label: { Image(systemName: "ellipsis.circle") } + .menuStyle(.borderlessButton).fixedSize() + .accessibilityLabel("More actions") + }.padding(12) + } +} + +private struct MacDestinationRow: View { + @Environment(AppModel.self) private var model + let peer: PeerMac + + var body: some View { + Button { model.moveKeyboard(to: peer) } label: { + HStack(spacing: 10) { + Image(systemName: peer.availability == .offline ? "desktopcomputer.trianglebadge.exclamationmark" : "desktopcomputer") + .foregroundStyle(peer.availability == .offline ? .secondary : Color.accentColor) + VStack(alignment: .leading, spacing: 1) { + Text(peer.name) + Text(status).font(.caption).foregroundStyle(.secondary) + } + Spacer() + if model.ownerID == peer.id { Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) } + else { Image(systemName: "arrow.right.circle").foregroundStyle(.tertiary) } + }.contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(peer.availability == .offline || !peer.isTrusted || model.ownerID == peer.id || model.transferStage.isBusy) + .accessibilityLabel("Move keyboard to \(peer.name), \(status)") + .padding(.vertical, 5) + } + + private var status: String { + if model.ownerID == peer.id { return "Current owner" } + if !peer.isTrusted { return "Verification required" } + return peer.availability == .offline ? "Offline" : "Available" + } +} diff --git a/Sources/KeyboardSwitchApp/OnboardingView.swift b/Sources/KeyboardSwitchApp/OnboardingView.swift new file mode 100644 index 0000000..d29763a --- /dev/null +++ b/Sources/KeyboardSwitchApp/OnboardingView.swift @@ -0,0 +1,124 @@ +import SwiftUI + +struct OnboardingView: View { + @Environment(AppModel.self) private var model + @Environment(\.dismissWindow) private var dismissWindow + @State private var step = 0 + @State private var targetPhraseConfirmed = false + @State private var returnPhraseConfirmed = false + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 6) { + ForEach(0..<5) { index in Capsule().fill(index <= step ? Color.accentColor : Color.secondary.opacity(0.2)).frame(height: 4) } + }.padding([.top, .horizontal], 24) + TabView(selection: $step) { + welcome.tag(0); nameMac.tag(1); pairKeyboard.tag(2); addPeers.tag(3); roundTrip.tag(4) + }.tabViewStyle(.automatic) + Divider() + HStack { + if step > 0 { Button("Back") { step -= 1 } } + Spacer() + Button(step == 4 ? "Finish" : "Continue") { + if step == 4 { + model.confirmGuidedRoundTrip(); model.finishOnboarding() + if model.hasCompletedOnboarding { dismissWindow(id: "onboarding") } + } else { step += 1 } + } + .disabled(step == 2 && !model.keyboard.isPaired || step == 3 && !model.peers.contains(where: { $0.isTrusted }) || step == 4 && !(targetPhraseConfirmed && returnPhraseConfirmed)) + .keyboardShortcut(.defaultAction) + }.padding(20) + } + .accessibilityElement(children: .contain) + } + + private var welcome: some View { + SetupPage(symbol: "keyboard.badge.ellipsis", title: "Your keyboard, wherever you work", detail: "Keybridge helps your paired Magic Keyboard move between trusted Macs on your local network.") { + VStack(alignment: .leading, spacing: 12) { + SetupFeature(symbol: "lock.shield", title: "Private by design", detail: "No account, cloud, analytics, or keystroke capture.") + SetupFeature(symbol: "cable.connector", title: "Pair once by cable", detail: "Connect the keyboard to every Mac with USB-C during setup.") + SetupFeature(symbol: "wifi", title: "Switch locally", detail: "Macs coordinate over your local network.") + } + } + } + + private var nameMac: some View { + SetupPage(symbol: "desktopcomputer", title: "Name this Mac", detail: "Choose a name that will be easy to recognize from your other Macs.") { + @Bindable var model = model + TextField("Mac name", text: $model.localMacName).textFieldStyle(.roundedBorder).frame(maxWidth: 340) + } + } + + private var pairKeyboard: some View { + SetupPage(symbol: "cable.connector", title: "Connect your Magic Keyboard", detail: "Plug the keyboard into this Mac with USB-C, switch it on, then verify it appears as paired.") { + VStack(spacing: 12) { + Label(model.keyboard.isPaired ? "Magic Keyboard is ready" : "Waiting for a paired keyboard", systemImage: model.keyboard.isPaired ? "checkmark.circle.fill" : "circle.dotted") + .foregroundStyle(model.keyboard.isPaired ? .green : .secondary) + Button("Check for Keyboard") { Task { await model.refreshKeyboards() } } + ForEach(model.availableKeyboards) { device in + Button("Use \(device.name)") { model.selectKeyboard(device) } + } + } + } + } + + private var addPeers: some View { + SetupPage(symbol: "macbook.and.iphone", title: "Add your other Macs", detail: "Open Keybridge on another Mac connected to the same network, then verify the code shown on both screens.") { + VStack(spacing: 12) { + Button("Find Nearby Macs") { model.beginPairing() } + ForEach(model.peers.filter { !$0.isTrusted }) { peer in + if let code = model.verificationCode(for: peer) { + HStack { + VStack(alignment: .leading) { + Text(peer.name).fontWeight(.semibold) + Text(code).font(.system(size: 24, weight: .semibold, design: .monospaced)).textSelection(.enabled) + } + Spacer() + Button("Codes Match") { model.trust(peer) } + } + } + } + } + } + } + + private var roundTrip: some View { + SetupPage(symbol: "arrow.triangle.2.circlepath", title: "Prove the full round trip", detail: "A Bluetooth Connected status is not proof that typing works. Move the keyboard to a trusted Mac and back, then confirm real input on each target.") { + VStack(alignment: .leading, spacing: 14) { + if let peer = model.peers.first(where: { $0.isTrusted }) { + Button("Move keyboard to \(peer.name)") { model.beginGuidedTransfer(to: peer) } + Toggle("I typed “keybridge-target” on \(peer.name), and this Mac received no input", isOn: $targetPhraseConfirmed) + .disabled(!model.guidedTargetTransferCompleted) + Button("Move keyboard back to \(model.localMacName)") { model.beginGuidedReturn() } + .disabled(!targetPhraseConfirmed || !model.guidedTargetTransferCompleted) + Toggle("I typed “keybridge-return” here after the return transfer", isOn: $returnPhraseConfirmed) + .disabled(!targetPhraseConfirmed || !model.guidedReturnTransferCompleted) + } + Text("Keep a fallback input device connected. If either phrase fails, do not finish setup.") + .font(.caption).foregroundStyle(.secondary) + } + } + } +} + +private struct SetupPage: View { + let symbol: String; let title: String; let detail: String; @ViewBuilder let content: Content + var body: some View { + VStack(spacing: 22) { + Spacer(); Image(systemName: symbol).font(.system(size: 48)).foregroundStyle(Color.accentColor) + Text(title).font(.largeTitle.bold()).multilineTextAlignment(.center) + Text(detail).font(.title3).foregroundStyle(.secondary).multilineTextAlignment(.center).frame(maxWidth: 500) + content.frame(maxWidth: 480); Spacer() + }.padding(36) + } +} + +private struct SetupFeature: View { + let symbol: String; let title: String; let detail: String + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: symbol).foregroundStyle(Color.accentColor).frame(width: 28) + VStack(alignment: .leading) { Text(title).fontWeight(.semibold); Text(detail).foregroundStyle(.secondary) } + } + } +} diff --git a/Sources/KeyboardSwitchApp/RuntimeAuthorization.swift b/Sources/KeyboardSwitchApp/RuntimeAuthorization.swift new file mode 100644 index 0000000..b08a8d4 --- /dev/null +++ b/Sources/KeyboardSwitchApp/RuntimeAuthorization.swift @@ -0,0 +1,121 @@ +import Foundation + +struct PersistedOwnership: Codable, Equatable, Sendable { + var ownerID: UUID? + var epoch: UInt64 +} + +protocol OwnershipEpochPersisting: Sendable { + func load() -> PersistedOwnership + func save(_ value: PersistedOwnership) +} + +final class UserDefaultsOwnershipEpochStore: OwnershipEpochPersisting, @unchecked Sendable { + private let defaults: UserDefaults + private let key: String + + init(defaults: UserDefaults = .standard, key: String = "Keybridge.OwnershipState.v2") { + self.defaults = defaults; self.key = key + } + + func load() -> PersistedOwnership { + guard let data = defaults.data(forKey: key), + let value = try? JSONDecoder().decode(PersistedOwnership.self, from: data) else { + return .init(ownerID: nil, epoch: 0) + } + return value + } + func save(_ value: PersistedOwnership) { + guard let data = try? JSONEncoder().encode(value) else { return } + defaults.set(data, forKey: key) + } +} + +struct OwnershipState { + private(set) var ownerID: UUID? + private(set) var epoch: UInt64 + private let store: any OwnershipEpochPersisting + + init(store: any OwnershipEpochPersisting) { + self.store = store + let persisted = store.load() + self.ownerID = persisted.ownerID + self.epoch = persisted.epoch + } + + mutating func nextEpoch(wallClock: UInt64) -> UInt64 { + let next = max(epoch &+ 1, wallClock) + epoch = next; persist() + return next + } + + @discardableResult + mutating func apply(ownerID: UUID?, epoch proposedEpoch: UInt64) -> Bool { + guard proposedEpoch > epoch else { return false } + self.ownerID = ownerID; epoch = proposedEpoch; persist() + return true + } + + mutating func commitLocal(ownerID: UUID?, epoch proposedEpoch: UInt64) { + guard proposedEpoch >= epoch else { return } + self.ownerID = ownerID; epoch = proposedEpoch; persist() + } + + mutating func restorePersistedOwner(_ ownerID: UUID?) { self.ownerID = ownerID; persist() } + + private func persist() { store.save(.init(ownerID: ownerID, epoch: epoch)) } +} + +struct RuntimeLease: Equatable { + let claimantID: UUID + let transactionID: UUID + let expiresAt: Date + let fence: UInt64 +} + +struct LeaseAuthority { + private(set) var held: RuntimeLease? + + mutating func accept(claimantID: UUID, transactionID: UUID, expiry: Date, fence: UInt64, now: Date = .now) -> Bool { + guard expiry > now, expiry <= now.addingTimeInterval(35) else { return false } + if let held, held.expiresAt > now, + (held.claimantID != claimantID || held.transactionID != transactionID) { return false } + if let held, held.claimantID == claimantID, held.transactionID == transactionID, fence < held.fence { return false } + held = RuntimeLease(claimantID: claimantID, transactionID: transactionID, expiresAt: expiry, fence: fence) + return true + } + + func authorizes(claimantID: UUID, transactionID: UUID, fence: UInt64, now: Date = .now) -> Bool { + guard let held else { return false } + return held.claimantID == claimantID && held.transactionID == transactionID && held.fence == fence && held.expiresAt > now + } + + mutating func release(claimantID: UUID, transactionID: UUID, fence: UInt64) -> Bool { + guard let held, held.claimantID == claimantID, held.transactionID == transactionID, held.fence == fence else { return false } + self.held = nil + return true + } + + mutating func cancel(claimantID: UUID, transactionID: UUID) -> Bool { + guard let held, held.claimantID == claimantID, held.transactionID == transactionID else { return false } + self.held = nil + return true + } +} + +struct ExpectedRuntimeReply: Equatable { + let peerID: UUID + let transactionID: UUID + let fence: UInt64 +} + +struct ReplyCorrelator { + private var expected: [UUID: ExpectedRuntimeReply] = [:] + mutating func expect(requestID: UUID, peerID: UUID, transactionID: UUID, fence: UInt64) { + expected[requestID] = .init(peerID: peerID, transactionID: transactionID, fence: fence) + } + mutating func remove(requestID: UUID) { expected[requestID] = nil } + func accepts(requestID: UUID, peerID: UUID, transactionID: UUID, fence: UInt64) -> Bool { + expected[requestID] == .init(peerID: peerID, transactionID: transactionID, fence: fence) + } +} diff --git a/Sources/KeyboardSwitchApp/SettingsView.swift b/Sources/KeyboardSwitchApp/SettingsView.swift new file mode 100644 index 0000000..03a5300 --- /dev/null +++ b/Sources/KeyboardSwitchApp/SettingsView.swift @@ -0,0 +1,67 @@ +import SwiftUI + +struct SettingsView: View { + @Environment(AppModel.self) private var model + @State private var confirmReset = false + + var body: some View { + @Bindable var model = model + TabView { + Form { + TextField("This Mac", text: $model.localMacName) + LabeledContent("Keyboard") { + HStack { + Text(model.keyboard.detail) + Menu("Select…") { + ForEach(model.availableKeyboards) { device in Button(device.name) { model.selectKeyboard(device) } } + Divider(); Button("Refresh") { Task { await model.refreshKeyboards() } } + } + } + } + Toggle("Launch Keybridge at login", isOn: Binding(get: { model.launchAtLogin }, set: { model.setLaunchAtLogin($0) })) + }.formStyle(.grouped).tabItem { Label("General", systemImage: "gear") } + + VStack(alignment: .leading, spacing: 12) { + HStack { Text("Trusted Macs").font(.title2.bold()); Spacer(); Button("Find Macs") { model.beginPairing() } } + List(model.peers) { peer in + HStack { + Image(systemName: "desktopcomputer") + VStack(alignment: .leading) { + Text(peer.name) + Text(peer.isTrusted ? peer.availability.rawValue.capitalized : (model.verificationCode(for: peer).map { "Code \($0)" } ?? "Awaiting verification")) + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + if !peer.isTrusted, model.verificationCode(for: peer) != nil { Button("Codes Match") { model.trust(peer) } } + Button("Forget", role: .destructive) { model.forget(peer) } + } + } + Text("Up to three Macs can participate, including this Mac.").font(.caption).foregroundStyle(.secondary) + }.padding().tabItem { Label("Macs", systemImage: "macbook.and.iphone") } + + Form { + LabeledContent("Updates") { Text("Manual") } + Text("Open-source builds do not contact an update server. Download, build, and verify a newer tagged release manually.") + .font(.caption).foregroundStyle(.secondary) + Button("Reset Setup and Peer Trust…", role: .destructive) { confirmReset = true } + Text("Resetting preserves the keyboard’s macOS Bluetooth pairing and never removes bond information.") + .font(.caption).foregroundStyle(.secondary) + }.formStyle(.grouped).tabItem { Label("Maintenance", systemImage: "wrench.and.screwdriver") } + + VStack(alignment: .leading, spacing: 12) { + Text("Diagnostics").font(.title2.bold()) + Text("Diagnostics contain connection state changes and redacted identifiers. They never contain keystrokes.").foregroundStyle(.secondary) + ScrollView { Text(model.diagnosticsText()).font(.system(.caption, design: .monospaced)).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading).padding(10) } + .background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) + HStack { Button("Copy") { model.copyDiagnostics() }; Button("Export…") { model.exportDiagnostics() }; Spacer() } + }.padding().tabItem { Label("Diagnostics", systemImage: "stethoscope") } + } + .padding(8) + .confirmationDialog("Reset Keybridge setup?", isPresented: $confirmReset, titleVisibility: .visible) { + Button("Reset Setup and Peer Trust", role: .destructive) { model.resetSetup() } + Button("Cancel", role: .cancel) {} + } message: { + Text("Trusted Macs and the selected keyboard will be cleared. macOS Bluetooth pairing will be preserved.") + } + } +} diff --git a/Sources/KeyboardSwitchApp/SystemIntegrations.swift b/Sources/KeyboardSwitchApp/SystemIntegrations.swift new file mode 100644 index 0000000..e1c054b --- /dev/null +++ b/Sources/KeyboardSwitchApp/SystemIntegrations.swift @@ -0,0 +1,9 @@ +import ServiceManagement + +enum LaunchAtLoginController { + static var isEnabled: Bool { SMAppService.mainApp.status == .enabled } + static func setEnabled(_ enabled: Bool) throws { + if enabled { try SMAppService.mainApp.register() } + else { try SMAppService.mainApp.unregister() } + } +} diff --git a/Sources/KeyboardSwitchBluetooth/MacOSBluetoothAdapter.swift b/Sources/KeyboardSwitchBluetooth/MacOSBluetoothAdapter.swift new file mode 100644 index 0000000..dd3a988 --- /dev/null +++ b/Sources/KeyboardSwitchBluetooth/MacOSBluetoothAdapter.swift @@ -0,0 +1,78 @@ +@preconcurrency import IOBluetooth +import Foundation +import KeyboardSwitchCore + +/// Feasibility adapter backed by the public but legacy IOBluetooth framework. +/// It never removes pairing/bond information. +public actor MacOSBluetoothAdapter: BluetoothAdapter { + public init() {} + + public func keyboards() throws -> [BluetoothKeyboard] { + pairedDevices().compactMap { device in + let name = device.name ?? "Unknown Bluetooth device" + guard Self.looksLikeKeyboard(name: name, classOfDevice: device.classOfDevice) else { return nil } + return Self.snapshot(device) + }.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + public func keyboard(id: KeyboardID) throws -> BluetoothKeyboard { Self.snapshot(try device(id)) } + + public func connect(id: KeyboardID) throws { + let device = try device(id) + guard device.isPaired() else { throw BluetoothAdapterError.deviceNotPaired(id) } + let result = device.openConnection() + guard result == kIOReturnSuccess else { + throw BluetoothAdapterError.operationFailed(operation: "connect", code: result) + } + } + + public func disconnect(id: KeyboardID) throws { + let result = try device(id).closeConnection() + guard result == kIOReturnSuccess else { + throw BluetoothAdapterError.operationFailed(operation: "disconnect", code: result) + } + } + + public func events() -> AsyncStream<[BluetoothKeyboard]> { + // IOBluetooth's Obj-C notifications do not safely bridge into a Sendable + // callback. Polling is bounded and stops immediately with the consumer. + AsyncStream { continuation in + let task = Task { + var previous: [BluetoothKeyboard] = [] + while !Task.isCancelled { + let current = (try? self.keyboards()) ?? [] + if current != previous { continuation.yield(current); previous = current } + try? await Task.sleep(for: .seconds(1)) + } + continuation.finish() + } + continuation.onTermination = { @Sendable _ in task.cancel() } + } + } + + private func pairedDevices() -> [IOBluetoothDevice] { + (IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice]) ?? [] + } + + private func device(_ id: KeyboardID) throws -> IOBluetoothDevice { + guard let found = pairedDevices().first(where: { Self.identifier($0) == id }) else { + throw BluetoothAdapterError.deviceNotFound(id) + } + return found + } + + private static func snapshot(_ device: IOBluetoothDevice) -> BluetoothKeyboard { + .init(id: identifier(device), name: device.name ?? "Unknown Bluetooth device", isPaired: device.isPaired(), isConnected: device.isConnected()) + } + + private static func identifier(_ device: IOBluetoothDevice) -> KeyboardID { + KeyboardID((device.addressString ?? "unknown").uppercased()) + } + + private static func looksLikeKeyboard(name: String, classOfDevice: BluetoothClassOfDevice) -> Bool { + let normalized = name.lowercased() + let isPeripheral = (classOfDevice & 0x1F00) == 0x0500 + let hasKeyboardCapability = (classOfDevice & 0x0040) != 0 + return (isPeripheral && hasKeyboardCapability) || normalized.contains("keyboard") + } +} diff --git a/Sources/KeyboardSwitchBluetooth/SimulatedBluetoothAdapter.swift b/Sources/KeyboardSwitchBluetooth/SimulatedBluetoothAdapter.swift new file mode 100644 index 0000000..c56c30d --- /dev/null +++ b/Sources/KeyboardSwitchBluetooth/SimulatedBluetoothAdapter.swift @@ -0,0 +1,60 @@ +import Foundation +import KeyboardSwitchCore + +public actor SimulatedBluetoothAdapter: BluetoothAdapter { + private var devices: [KeyboardID: BluetoothKeyboard] + public var connectFailuresRemaining = 0 + public var disconnectFailuresRemaining = 0 + private var observers: [UUID: AsyncStream<[BluetoothKeyboard]>.Continuation] = [:] + + public init(keyboards: [BluetoothKeyboard]) { + devices = Dictionary(uniqueKeysWithValues: keyboards.map { ($0.id, $0) }) + } + + public func keyboards() -> [BluetoothKeyboard] { devices.values.sorted { $0.name < $1.name } } + + public func keyboard(id: KeyboardID) throws -> BluetoothKeyboard { + guard let device = devices[id] else { throw BluetoothAdapterError.deviceNotFound(id) } + return device + } + + public func connect(id: KeyboardID) throws { + var device = try keyboard(id: id) + guard device.isPaired else { throw BluetoothAdapterError.deviceNotPaired(id) } + if connectFailuresRemaining > 0 { + connectFailuresRemaining -= 1 + throw BluetoothAdapterError.operationFailed(operation: "connect", code: -1) + } + device = .init(id: id, name: device.name, isPaired: true, isConnected: true) + devices[id] = device + publish() + } + + public func disconnect(id: KeyboardID) throws { + var device = try keyboard(id: id) + if disconnectFailuresRemaining > 0 { + disconnectFailuresRemaining -= 1 + throw BluetoothAdapterError.operationFailed(operation: "disconnect", code: -1) + } + device = .init(id: id, name: device.name, isPaired: device.isPaired, isConnected: false) + devices[id] = device + publish() + } + + public func events() -> AsyncStream<[BluetoothKeyboard]> { + let observerID = UUID() + return AsyncStream { continuation in + observers[observerID] = continuation + continuation.yield(keyboards()) + continuation.onTermination = { @Sendable _ in + Task { await self.removeObserver(observerID) } + } + } + } + + private func removeObserver(_ id: UUID) { observers[id] = nil } + private func publish() { + let snapshot = keyboards() + observers.values.forEach { $0.yield(snapshot) } + } +} diff --git a/Sources/KeyboardSwitchCore/Bluetooth.swift b/Sources/KeyboardSwitchCore/Bluetooth.swift new file mode 100644 index 0000000..b6da721 --- /dev/null +++ b/Sources/KeyboardSwitchCore/Bluetooth.swift @@ -0,0 +1,49 @@ +import Foundation + +public struct KeyboardID: Hashable, Codable, Sendable, CustomStringConvertible { + public let rawValue: String + + public init(_ rawValue: String) { self.rawValue = rawValue } + public var description: String { rawValue } +} + +public struct BluetoothKeyboard: Equatable, Codable, Sendable, Identifiable { + public let id: KeyboardID + public let name: String + public let isPaired: Bool + public let isConnected: Bool + + public init(id: KeyboardID, name: String, isPaired: Bool, isConnected: Bool) { + self.id = id + self.name = name + self.isPaired = isPaired + self.isConnected = isConnected + } +} + +public enum BluetoothAdapterError: Error, Equatable, Sendable, LocalizedError { + case deviceNotFound(KeyboardID) + case deviceNotPaired(KeyboardID) + case operationFailed(operation: String, code: Int32) + case verificationTimedOut(expectedConnected: Bool) + case unsupported(String) + + public var errorDescription: String? { + switch self { + case .deviceNotFound(let id): "Bluetooth device \(id) was not found." + case .deviceNotPaired(let id): "Bluetooth device \(id) is not paired. Cable-pair it first." + case .operationFailed(let operation, let code): "Bluetooth \(operation) failed with code \(code)." + case .verificationTimedOut(let expected): "Timed out waiting for the device to become \(expected ? "connected" : "disconnected")." + case .unsupported(let reason): reason + } + } +} + +public protocol BluetoothAdapter: Sendable { + func keyboards() async throws -> [BluetoothKeyboard] + func keyboard(id: KeyboardID) async throws -> BluetoothKeyboard + func connect(id: KeyboardID) async throws + func disconnect(id: KeyboardID) async throws + /// Emits inventory snapshots when the adapter observes a connection change. + func events() async -> AsyncStream<[BluetoothKeyboard]> +} diff --git a/Sources/KeyboardSwitchCore/Switching.swift b/Sources/KeyboardSwitchCore/Switching.swift new file mode 100644 index 0000000..fd290b5 --- /dev/null +++ b/Sources/KeyboardSwitchCore/Switching.swift @@ -0,0 +1,228 @@ +import Foundation + +public struct PeerID: Hashable, Codable, Sendable { + public let rawValue: UUID + public init(_ rawValue: UUID = UUID()) { self.rawValue = rawValue } +} + +public struct SwitchRequest: Equatable, Sendable { + public let transactionID: UUID + public let keyboardID: KeyboardID + public let source: PeerID + public let target: PeerID + + public init(transactionID: UUID = UUID(), keyboardID: KeyboardID, source: PeerID, target: PeerID) { + self.transactionID = transactionID + self.keyboardID = keyboardID + self.source = source + self.target = target + } +} + +public enum SwitchStage: String, Equatable, Sendable { + case idle, validating, acquiringLease, releasing, connecting, verifying, recovering, completed, failed +} + +public struct SwitchProgress: Equatable, Sendable { + public let transactionID: UUID? + public let stage: SwitchStage + public let detail: String + + public init(transactionID: UUID?, stage: SwitchStage, detail: String) { + self.transactionID = transactionID + self.stage = stage + self.detail = detail + } +} + +public enum SwitchFailure: Error, Equatable, Sendable, LocalizedError { + case alreadySwitching + case samePeer + case leaseUnavailable + case sourceUnreachable + case targetUnreachable + case operationTimedOut(String) + case sourceReleaseFailed + case targetConnectionFailed(recoveredSource: Bool) + + public var errorDescription: String? { + switch self { + case .alreadySwitching: "Another keyboard transfer is already running." + case .samePeer: "Source and target must be different Macs." + case .leaseUnavailable: "Another Mac owns the transfer lease." + case .sourceUnreachable: "The source Mac is offline." + case .targetUnreachable: "The target Mac is offline." + case .operationTimedOut(let operation): "Timed out while \(operation)." + case .sourceReleaseFailed: "The source Mac could not release the keyboard." + case .targetConnectionFailed(let recovered): recovered + ? "The target could not connect; the keyboard was restored to the source." + : "The target could not connect and automatic source recovery also failed." + } + } +} + +public protocol SwitchLease: Sendable { + func acquire(transactionID: UUID, expiresAt: Date) async -> Bool + func release(transactionID: UUID) async + /// Extends the lease only if the caller still owns the same fenced transaction. + func renew(transactionID: UUID, expiresAt: Date) async -> Bool +} + +public protocol RemoteKeyboardControl: Sendable { + /// Implementations must respond to cooperative task cancellation so the + /// coordinator's operation deadlines remain bounded. + func isReachable(peer: PeerID) async -> Bool + func disconnect(keyboard: KeyboardID, on peer: PeerID) async throws + func connect(keyboard: KeyboardID, on peer: PeerID) async throws + func isConnected(keyboard: KeyboardID, on peer: PeerID) async throws -> Bool +} + +public struct SwitchPolicy: Sendable { + public var maximumAttempts: Int + public var initialRetryDelay: Duration + public var verificationAttempts: Int + public var leaseDuration: TimeInterval + public var operationTimeout: Duration + + public init(maximumAttempts: Int = 3, initialRetryDelay: Duration = .milliseconds(250), verificationAttempts: Int = 10, leaseDuration: TimeInterval = 30, operationTimeout: Duration = .seconds(8)) { + self.maximumAttempts = max(1, maximumAttempts) + self.initialRetryDelay = initialRetryDelay + self.verificationAttempts = max(1, verificationAttempts) + self.leaseDuration = leaseDuration + self.operationTimeout = operationTimeout + } +} + +public actor KeyboardSwitchCoordinator { + public typealias ProgressHandler = @Sendable (SwitchProgress) -> Void + private let remote: any RemoteKeyboardControl + private let lease: any SwitchLease + private let policy: SwitchPolicy + private var activeTransaction: UUID? + + public init(remote: any RemoteKeyboardControl, lease: any SwitchLease, policy: SwitchPolicy = .init()) { + self.remote = remote + self.lease = lease + self.policy = policy + } + + public func perform(_ request: SwitchRequest, progress: ProgressHandler = { _ in }) async throws { + guard activeTransaction == nil else { throw SwitchFailure.alreadySwitching } + guard request.source != request.target else { throw SwitchFailure.samePeer } + activeTransaction = request.transactionID + defer { activeTransaction = nil } + + progress(.init(transactionID: request.transactionID, stage: .validating, detail: "Checking both Macs")) + guard await remote.isReachable(peer: request.source) else { throw SwitchFailure.sourceUnreachable } + guard await remote.isReachable(peer: request.target) else { throw SwitchFailure.targetUnreachable } + + progress(.init(transactionID: request.transactionID, stage: .acquiringLease, detail: "Reserving keyboard transfer")) + guard await lease.acquire(transactionID: request.transactionID, expiresAt: Date().addingTimeInterval(policy.leaseDuration)) else { + throw SwitchFailure.leaseUnavailable + } + + do { + try await performWithLease(request, progress: progress) + await lease.release(transactionID: request.transactionID) + } catch { + await lease.release(transactionID: request.transactionID) + throw error + } + } + + private func performWithLease(_ request: SwitchRequest, progress: ProgressHandler) async throws { + progress(.init(transactionID: request.transactionID, stage: .releasing, detail: "Releasing keyboard from source")) + do { + try await retry { try await self.timed("releasing the keyboard") { try await self.remote.disconnect(keyboard: request.keyboardID, on: request.source) } } + try await verify(expected: false, keyboard: request.keyboardID, peer: request.source) + } catch { + if error is CancellationError { throw error } + progress(.init(transactionID: request.transactionID, stage: .failed, detail: "Source did not release keyboard")) + if case SwitchFailure.operationTimedOut = error { throw error } + throw SwitchFailure.sourceReleaseFailed + } + + guard await lease.renew(transactionID: request.transactionID, expiresAt: Date().addingTimeInterval(policy.leaseDuration)) else { + throw SwitchFailure.leaseUnavailable + } + + progress(.init(transactionID: request.transactionID, stage: .connecting, detail: "Connecting keyboard to target")) + do { + try await retry { try await self.timed("connecting the keyboard") { try await self.remote.connect(keyboard: request.keyboardID, on: request.target) } } + progress(.init(transactionID: request.transactionID, stage: .verifying, detail: "Verifying keyboard connection")) + try await verify(expected: true, keyboard: request.keyboardID, peer: request.target) + } catch { + if error is CancellationError { throw error } + progress(.init(transactionID: request.transactionID, stage: .recovering, detail: "Restoring keyboard to source")) + let recovered = await recover(request) + progress(.init(transactionID: request.transactionID, stage: .failed, detail: recovered ? "Restored to source" : "Manual recovery required")) + throw SwitchFailure.targetConnectionFailed(recoveredSource: recovered) + } + progress(.init(transactionID: request.transactionID, stage: .completed, detail: "Keyboard moved")) + } + + private func timed(_ name: String, operation: @escaping @Sendable () async throws -> Void) async throws { + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { try await operation() } + group.addTask { + try await Task.sleep(for: self.policy.operationTimeout) + throw SwitchFailure.operationTimedOut(name) + } + defer { group.cancelAll() } + try await group.next()! + } + } + + private func retry(_ operation: @Sendable () async throws -> Void) async throws { + var delay = policy.initialRetryDelay + var lastError: (any Error)? + for attempt in 1...policy.maximumAttempts { + do { try await operation(); return } catch { lastError = error } + if attempt < policy.maximumAttempts { try await Task.sleep(for: delay); delay *= 2 } + } + throw lastError ?? CancellationError() + } + + private func verify(expected: Bool, keyboard: KeyboardID, peer: PeerID) async throws { + for attempt in 1...policy.verificationAttempts { + if try await remote.isConnected(keyboard: keyboard, on: peer) == expected { return } + if attempt < policy.verificationAttempts { try await Task.sleep(for: policy.initialRetryDelay) } + } + throw BluetoothAdapterError.verificationTimedOut(expectedConnected: expected) + } + + private func recover(_ request: SwitchRequest) async -> Bool { + do { + try await retry { try await self.remote.connect(keyboard: request.keyboardID, on: request.source) } + try await verify(expected: true, keyboard: request.keyboardID, peer: request.source) + return true + } catch { return false } + } +} + +public actor InMemorySwitchLease: SwitchLease { + private var holder: UUID? + private var expiry: Date? + + public init() {} + + public func acquire(transactionID: UUID, expiresAt: Date) -> Bool { + if let expiry, expiry <= Date() { holder = nil; self.expiry = nil } + guard holder == nil || holder == transactionID else { return false } + holder = transactionID + expiry = expiresAt + return true + } + + public func release(transactionID: UUID) { + guard holder == transactionID else { return } + holder = nil + expiry = nil + } + + public func renew(transactionID: UUID, expiresAt: Date) -> Bool { + guard holder == transactionID, let expiry, expiry > Date() else { return false } + self.expiry = expiresAt + return true + } +} diff --git a/Sources/KeyboardSwitchDiagnostics/main.swift b/Sources/KeyboardSwitchDiagnostics/main.swift new file mode 100644 index 0000000..a9698be --- /dev/null +++ b/Sources/KeyboardSwitchDiagnostics/main.swift @@ -0,0 +1,32 @@ +import Foundation +import KeyboardSwitchBluetooth + +@main +struct Diagnostics { + static func main() async { + let adapter = MacOSBluetoothAdapter() + do { + let keyboards = try await adapter.keyboards() + if keyboards.isEmpty { + print("No paired Bluetooth keyboards found. Cable-pair the Magic Keyboard, unplug it, then retry.") + } + for keyboard in keyboards { + let redacted = String(keyboard.id.rawValue.suffix(5)) + print("\(keyboard.name) id=…\(redacted) paired=\(keyboard.isPaired) connected=\(keyboard.isConnected)") + } + print("Inventory only. Use --connect or --disconnect with a full identifier to run an explicit feasibility operation.") + + let args = CommandLine.arguments + if args.count == 3, args[1] == "--connect" { + try await adapter.connect(id: .init(args[2])) + print("Connect request returned successfully; verify actual keyboard input.") + } else if args.count == 3, args[1] == "--disconnect" { + try await adapter.disconnect(id: .init(args[2])) + print("Disconnect request returned successfully; verify connection state on both Macs.") + } + } catch { + fputs("Diagnostic failed: \(error.localizedDescription)\n", stderr) + Foundation.exit(EXIT_FAILURE) + } + } +} diff --git a/Sources/MagicSwitchNetworking/Identity.swift b/Sources/MagicSwitchNetworking/Identity.swift new file mode 100644 index 0000000..2d1ca90 --- /dev/null +++ b/Sources/MagicSwitchNetworking/Identity.swift @@ -0,0 +1,86 @@ +import CryptoKit +import Foundation +#if canImport(Security) +import Security +#endif + +public struct PeerIdentity: Sendable { + public let id: UUID + public let signingKey: Curve25519.Signing.PrivateKey + public let agreementKey: Curve25519.KeyAgreement.PrivateKey + + public init(id: UUID = UUID(), signingKey: Curve25519.Signing.PrivateKey = .init(), agreementKey: Curve25519.KeyAgreement.PrivateKey = .init()) { + self.id = id + self.signingKey = signingKey + self.agreementKey = agreementKey + } + + public func record(name: String) -> PeerRecord { + PeerRecord(id: id, name: name, signingPublicKey: signingKey.publicKey.rawRepresentation, + agreementPublicKey: agreementKey.publicKey.rawRepresentation) + } +} + +public protocol IdentityPersisting: Sendable { + func loadOrCreateIdentity() throws -> PeerIdentity +} + +public final class MemoryIdentityStore: IdentityPersisting, @unchecked Sendable { + private let lock = NSLock() + private var identity: PeerIdentity? + public init() {} + public func loadOrCreateIdentity() throws -> PeerIdentity { + lock.lock(); defer { lock.unlock() } + if let identity { return identity } + let value = PeerIdentity(); identity = value; return value + } +} + +#if canImport(Security) +public final class KeychainIdentityStore: IdentityPersisting, @unchecked Sendable { + private let service: String + public init(service: String = "com.magicswitch.identity") { self.service = service } + + public func loadOrCreateIdentity() throws -> PeerIdentity { + if let data = try read() { + do { + let stored = try JSONDecoder().decode(StoredIdentity.self, from: data) + return try PeerIdentity(id: stored.id, signingKey: .init(rawRepresentation: stored.signingPrivateKey), + agreementKey: .init(rawRepresentation: stored.agreementPrivateKey)) + } catch { throw IdentityError.corruptIdentity } + } + let identity = PeerIdentity() + let stored = StoredIdentity(id: identity.id, signingPrivateKey: identity.signingKey.rawRepresentation, + agreementPrivateKey: identity.agreementKey.rawRepresentation) + try write(JSONEncoder().encode(stored)) + return identity + } + + private struct StoredIdentity: Codable { let id: UUID; let signingPrivateKey: Data; let agreementPrivateKey: Data } + private func read() throws -> Data? { + let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, kSecAttrAccount as String: "installation", + kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { throw IdentityError.keychain(status) } + return data + } + private func write(_ data: Data) throws { + let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, kSecAttrAccount as String: "installation", + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + kSecValueData as String: data] + var status = SecItemAdd(query as CFDictionary, nil) + if status == errSecDuplicateItem { + let match: [String: Any] = [kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, kSecAttrAccount as String: "installation"] + status = SecItemUpdate(match as CFDictionary, [kSecValueData as String: data] as CFDictionary) + } + guard status == errSecSuccess else { throw IdentityError.keychain(status) } + } +} + +public enum IdentityError: Error { case keychain(OSStatus), corruptIdentity } +#endif diff --git a/Sources/MagicSwitchNetworking/KeychainTrustStore.swift b/Sources/MagicSwitchNetworking/KeychainTrustStore.swift new file mode 100644 index 0000000..2c380a3 --- /dev/null +++ b/Sources/MagicSwitchNetworking/KeychainTrustStore.swift @@ -0,0 +1,45 @@ +import Foundation +#if canImport(Security) +import Security + +/// Production trust storage. Records are protected by the login Keychain and are never recovered from malformed data. +public actor KeychainTrustStore { + private let service: String + private var loaded: [UUID: PeerRecord]? + public init(service: String = "com.magicswitch.trusted-peers") { self.service = service } + + public func peer(id: UUID) throws -> PeerRecord? { try load()[id] } + public func allPeers() throws -> [PeerRecord] { try load().values.sorted { $0.name < $1.name } } + public func trust(_ peer: PeerRecord) throws { + var peers = try load() + guard peers.count < 2 || peers[peer.id] != nil else { throw TrustError.peerLimitReached } + if let old = peers[peer.id], old.signingPublicKey != peer.signingPublicKey || old.agreementPublicKey != peer.agreementPublicKey { + throw TrustError.identityChanged + } + peers[peer.id] = peer; try save(peers); loaded = peers + } + public func remove(id: UUID) throws { var peers = try load(); peers[id] = nil; try save(peers); loaded = peers } + + private func load() throws -> [UUID: PeerRecord] { + if let loaded { return loaded } + let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, + kSecAttrAccount as String: "v1", kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne] + var result: CFTypeRef?; let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { loaded = [:]; return [:] } + guard status == errSecSuccess, let data = result as? Data else { throw TrustPersistenceError.keychain(status) } + do { + let records = try JSONDecoder().decode([PeerRecord].self, from: data) + let peers = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) }); loaded = peers; return peers + } catch { throw TrustPersistenceError.corruptData } + } + private func save(_ peers: [UUID: PeerRecord]) throws { + let data = try JSONEncoder().encode(Array(peers.values)) + let match: [String: Any] = [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: "v1"] + let attrs: [String: Any] = [kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly] + var status = SecItemUpdate(match as CFDictionary, attrs as CFDictionary) + if status == errSecItemNotFound { var add = match; attrs.forEach { add[$0.key] = $0.value }; status = SecItemAdd(add as CFDictionary, nil) } + guard status == errSecSuccess else { throw TrustPersistenceError.keychain(status) } + } +} +public enum TrustPersistenceError: Error { case keychain(OSStatus), corruptData } +#endif diff --git a/Sources/MagicSwitchNetworking/PeerService.swift b/Sources/MagicSwitchNetworking/PeerService.swift new file mode 100644 index 0000000..423cad1 --- /dev/null +++ b/Sources/MagicSwitchNetworking/PeerService.swift @@ -0,0 +1,346 @@ +import Foundation +import Network + +public struct ReceivedPeerMessage: Sendable { public let peer: PeerRecord; public let message: PeerMessage } +public enum PeerServiceError: Error { case notStarted, untrustedPeer, connectionLimit, deadlineExceeded, enrollmentCodeMismatch, enrollmentCandidateUnavailable, enrollmentPossessionFailed, listenerReplaced } + +/// Actor-isolated runtime for discovery, authenticated dispatch, and explicit user-confirmed enrollment. +/// v1 uses long-lived Curve25519 ECDH identities and therefore does not provide forward secrecy. +public actor PeerService { + private let identity: PeerIdentity + private var displayName: String + private let trust: KeychainTrustStore + private let codec = SecureCodec() + private let replay = ReplayGuard() + private var browser: BonjourPeerBrowser? + private var listener: PeerListener? + private var startingListener: PeerListener? + private let makeListener: @Sendable (String) throws -> PeerListener + private let awaitListenerReady: @Sendable (PeerListener) async throws -> Void + private let listenerReadinessTimeout: Duration + private var enrollmentRefreshTask: Task? + private var discoveryGeneration: UInt64 = 0 + private var enrollmentRefreshLaunches = 0 + private var discovered: [String: DiscoveredPeer] = [:] + private var discoveredPeerIDs: [String: UUID] = [:] + private struct EnrollmentCandidate { let offer: EnrollmentOffer; let receivedAt: Date } + private var enrollmentCandidates: [UUID: EnrollmentCandidate] = [:] + private var enrollmentEndpoints: [UUID: String] = [:] + private var activeEnrollmentOffer: EnrollmentOffer? + private var activeConnections = 0 + private let maximumConnections = 4 + private let maximumEnrollmentCandidates = 8 + private let maximumDiscoveredPeers = 16 + private let maximumConcurrentEnrollmentProbes = 4 + private let enrollmentLifetime: TimeInterval = 300 + private let continuation: AsyncStream.Continuation + public nonisolated let messages: AsyncStream + + public init(identity: PeerIdentity, displayName: String, trustStore: KeychainTrustStore = .init(), + listenerFactory: @escaping @Sendable (String) throws -> PeerListener = { try PeerListener(advertisedName: $0) }, + listenerReadiness: @escaping @Sendable (PeerListener) async throws -> Void = { try await $0.waitUntilReady() }, + listenerReadinessTimeout: Duration = .seconds(5)) { + self.identity = identity; self.displayName = displayName; self.trust = trustStore; self.makeListener = listenerFactory + self.awaitListenerReady = listenerReadiness + self.listenerReadinessTimeout = listenerReadinessTimeout + var c: AsyncStream.Continuation! + messages = AsyncStream { c = $0 }; continuation = c + } + + public func localEnrollmentOffer() -> EnrollmentOffer { + if let activeEnrollmentOffer { return activeEnrollmentOffer } + let offer = Enrollment.makeOffer(identity: identity, name: displayName) + activeEnrollmentOffer = offer + return offer + } + public func confirmEnrollment(local: EnrollmentOffer, remote: EnrollmentOffer, displayedCode: String) async throws { + guard local.peer.id == identity.id, Enrollment.verificationCode(local: local, remote: remote) == displayedCode else { + throw PeerServiceError.enrollmentCodeMismatch + } + let cutoff = Date().addingTimeInterval(-enrollmentLifetime) + guard let candidate = enrollmentCandidates[remote.peer.id], candidate.receivedAt >= cutoff, + candidate.offer == remote, + let key = enrollmentEndpoints[remote.peer.id], let endpoint = discovered[key]?.endpoint else { + throw PeerServiceError.enrollmentCandidateUnavailable + } + // A matching SAS authenticates the displayed keys to the user; this challenge additionally + // proves that the selected network endpoint owns those keys before anything is persisted. + guard await proveEnrollmentPossession(endpoint: endpoint, local: local, remote: remote, endpointKey: key) else { + throw PeerServiceError.enrollmentPossessionFailed + } + try await trust.trust(remote.peer) + discoveredPeerIDs[key] = remote.peer.id + enrollmentCandidates[remote.peer.id] = nil + enrollmentEndpoints[remote.peer.id] = nil + } + + public func updateDisplayName(_ name: String) async throws { + guard name != displayName else { return } + // Initial listener startup is actor-reentrant while readiness is awaited. Do not let a + // concurrent rename mutate the enrollment identity while that old-name listener is still + // provisional; the caller can retry once startup has committed or failed. + guard startingListener == nil else { throw PeerServiceError.listenerReplaced } + guard let oldListener = listener else { + displayName = name; activeEnrollmentOffer = nil + return + } + // Construct and configure first. If construction fails the old listener, advertised name, + // and enrollment offer all remain valid, so callers can retry without restarting the app. + let replacement = try makeListener(name) + replacement.onConnection = { [weak self] connection in Task { await self?.accept(connection) } } + replacement.start() + do { + try await withThrowingTaskGroup(of: Void.self) { group in + defer { group.cancelAll() } + group.addTask { try await self.awaitListenerReady(replacement) } + group.addTask { try await Task.sleep(for: self.listenerReadinessTimeout); throw PeerServiceError.deadlineExceeded } + _ = try await group.next() + } + } + catch { replacement.cancel(); throw error } + guard listener === oldListener else { replacement.cancel(); throw PeerServiceError.listenerReplaced } + listener = replacement + displayName = name + activeEnrollmentOffer = nil + oldListener.cancel() + } + + public func start() async throws { + guard listener == nil else { return } + guard startingListener == nil else { throw PeerServiceError.listenerReplaced } + let candidate = try makeListener(displayName) + startingListener = candidate + candidate.onConnection = { [weak self] connection in Task { await self?.accept(connection) } } + candidate.start() + do { + try await withThrowingTaskGroup(of: Void.self) { group in + defer { group.cancelAll() } + group.addTask { try await self.awaitListenerReady(candidate) } + group.addTask { try await Task.sleep(for: self.listenerReadinessTimeout); throw PeerServiceError.deadlineExceeded } + _ = try await group.next() + } + } catch { + candidate.cancel() + if startingListener === candidate { startingListener = nil } + throw error + } + guard startingListener === candidate, listener == nil else { + candidate.cancel() + if startingListener === candidate { startingListener = nil } + throw PeerServiceError.listenerReplaced + } + startingListener = nil + listener = candidate + let browser = BonjourPeerBrowser(); browser.start(); self.browser = browser + Task { [weak self] in for await peers in browser.updates { await self?.updateDiscovery(peers) } } + } + public func stop() { + discoveryGeneration &+= 1 + enrollmentRefreshTask?.cancel(); enrollmentRefreshTask = nil + browser?.cancel(); listener?.cancel(); startingListener?.cancel() + browser = nil; listener = nil; startingListener = nil + discovered = [:]; discoveredPeerIDs = [:]; enrollmentCandidates = [:]; enrollmentEndpoints = [:] + } + public func availablePeers() -> [DiscoveredPeer] { Array(discovered.values).sorted { $0.name < $1.name } } + public func trustedOnlinePeers() async throws -> [PeerRecord] { + let onlineIDs = Set(discoveredPeerIDs.values) + return try await trust.allPeers().filter { onlineIDs.contains($0.id) } + } + public func trustedPeers() async throws -> [PeerRecord] { try await trust.allPeers() } + public func forgetPeer(id: UUID) async throws { try await trust.remove(id: id) } + public func discoveredEnrollmentOffers() -> [EnrollmentOffer] { + let cutoff = Date().addingTimeInterval(-enrollmentLifetime) + enrollmentCandidates = enrollmentCandidates.filter { $0.value.receivedAt >= cutoff } + return enrollmentCandidates.values.map(\.offer).sorted { $0.peer.name < $1.peer.name } + } + + /// Exchanges complete public enrollment offers over the local connection. The offers are not + /// secret; authenticity is established only when the user compares the derived six-digit code. + public func refreshEnrollmentOffers(timeout: Duration = .seconds(3)) async { + await refreshEnrollmentOffers(timeout: timeout, expectedGeneration: discoveryGeneration) + } + + private func refreshEnrollmentOffers(timeout: Duration, expectedGeneration: UInt64?) async { + let local = localEnrollmentOffer() + let peers = Array(discovered.prefix(maximumDiscoveredPeers)) + for batchStart in stride(from: 0, to: peers.count, by: maximumConcurrentEnrollmentProbes) { + let batch = peers[batchStart.. (generation: UInt64, launches: Int, scheduled: Bool) { + (discoveryGeneration, enrollmentRefreshLaunches, enrollmentRefreshTask != nil) + } + private func accept(_ connection: PeerConnection) async { + guard activeConnections < maximumConnections else { connection.cancel(); return } + activeConnections += 1; defer { activeConnections -= 1; connection.cancel() } + connection.start(queue: .init(label: "MagicSwitch.Incoming")) + do { + let wire = try await withThrowingTaskGroup(of: PeerWireMessage.self) { group in + group.addTask { try await connection.receiveWire() } + group.addTask { try await Task.sleep(for: .seconds(10)); connection.cancel(); throw PeerServiceError.deadlineExceeded } + let first = try await group.next()!; group.cancelAll(); return first + } + if case .enrollmentRequest(let remoteOffer) = wire { + guard remoteOffer.peer.id != identity.id else { return } + rememberEnrollment(remoteOffer, endpointKey: nil) + try await connection.sendWire(.enrollmentResponse(localEnrollmentOffer())) + return + } + if case .enrollmentProofRequest(let request) = wire { + guard request.responder.peer.id == identity.id, + request.responder == localEnrollmentOffer(), + request.requester.peer.id != identity.id, + request.endpointBinding.utf8.count <= 1_024 else { throw TransportError.invalidFrame } + try await connection.sendWire(.enrollmentProofResponse(try Enrollment.signProof(request, identity: identity))) + return + } + guard case .secure(let packet) = wire else { throw TransportError.invalidFrame } + guard let peer = try await trust.peer(id: packet.senderID) else { throw PeerServiceError.untrustedPeer } + let message = try await codec.open(packet, as: identity, trustedPeer: peer, replayGuard: replay) + if case .heartbeat(let challenge?) = message { + try await connection.send(try codec.seal(.heartbeat(transactionID: challenge), from: identity, to: peer)) + } + continuation.yield(.init(peer: peer, message: message)) + } catch { /* Fail closed; callers observe only authenticated messages. */ } + } + + private func rememberEnrollment(_ offer: EnrollmentOffer, endpointKey: String?) { + let cutoff = Date().addingTimeInterval(-enrollmentLifetime) + enrollmentCandidates = enrollmentCandidates.filter { $0.value.receivedAt >= cutoff } + if enrollmentCandidates[offer.peer.id] == nil, enrollmentCandidates.count >= maximumEnrollmentCandidates, + let oldest = enrollmentCandidates.min(by: { $0.value.receivedAt < $1.value.receivedAt })?.key { + enrollmentCandidates[oldest] = nil; enrollmentEndpoints[oldest] = nil + } + enrollmentCandidates[offer.peer.id] = .init(offer: offer, receivedAt: .now) + if let endpointKey { enrollmentEndpoints[offer.peer.id] = endpointKey } + } + + /// Proves that a discovered endpoint controls the private keys pinned for the trusted peer. + private func authenticate(endpoint: NWEndpoint, peer: PeerRecord) async -> Bool { + let challenge = UUID() + let connection = PeerConnection(endpoint: endpoint) + connection.start(queue: .init(label: "MagicSwitch.RouteProbe")); defer { connection.cancel() } + do { + try await connection.send(try codec.seal(.heartbeat(transactionID: challenge), from: identity, to: peer)) + let packet = try await withThrowingTaskGroup(of: SecurePacket.self) { group in + group.addTask { try await connection.receive() } + group.addTask { + try await Task.sleep(for: .seconds(3)) + connection.cancel() + throw PeerServiceError.deadlineExceeded + } + let packet = try await group.next()! + group.cancelAll() + return packet + } + let response = try await codec.open(packet, as: identity, trustedPeer: peer, replayGuard: replay) + guard case .heartbeat(let returned) = response else { return false } + return returned == challenge + } catch { return false } + } + + + private func proveEnrollmentPossession(endpoint: NWEndpoint, local: EnrollmentOffer, remote: EnrollmentOffer, + endpointKey: String) async -> Bool { + let request = EnrollmentProofRequest(requester: local, responder: remote, nonce: UUID(), endpointBinding: endpointKey) + let connection = PeerConnection(endpoint: endpoint) + connection.start(queue: .init(label: "MagicSwitch.EnrollmentProof")); defer { connection.cancel() } + do { + try await connection.sendWire(.enrollmentProofRequest(request)) + let response = try await withThrowingTaskGroup(of: EnrollmentProofResponse.self) { group in + group.addTask { + guard case .enrollmentProofResponse(let response) = try await connection.receiveWire() else { throw TransportError.invalidFrame } + return response + } + group.addTask { try await Task.sleep(for: .seconds(3)); connection.cancel(); throw PeerServiceError.deadlineExceeded } + let response = try await group.next()!; group.cancelAll(); return response + } + return Enrollment.verifyProof(response, request: request) + } catch { return false } + } +} diff --git a/Sources/MagicSwitchNetworking/PeerTransport.swift b/Sources/MagicSwitchNetworking/PeerTransport.swift new file mode 100644 index 0000000..bf20c1c --- /dev/null +++ b/Sources/MagicSwitchNetworking/PeerTransport.swift @@ -0,0 +1,148 @@ +import Foundation +import Network + +public struct DiscoveredPeer: Sendable, Hashable { + public let name: String + public let endpoint: NWEndpoint + public init(name: String, endpoint: NWEndpoint) { self.name = name; self.endpoint = endpoint } + public static func == (lhs: Self, rhs: Self) -> Bool { lhs.name == rhs.name && String(describing: lhs.endpoint) == String(describing: rhs.endpoint) } + public func hash(into hasher: inout Hasher) { hasher.combine(name); hasher.combine(String(describing: endpoint)) } +} + +/// Bonjour discovery restricted to the local domain. Call `start()` once and consume `updates`. +public final class BonjourPeerBrowser: @unchecked Sendable { + private let browser: NWBrowser + private let continuation: AsyncStream<[DiscoveredPeer]>.Continuation + public let updates: AsyncStream<[DiscoveredPeer]> + + public init() { + var c: AsyncStream<[DiscoveredPeer]>.Continuation! + updates = AsyncStream { c = $0 }; continuation = c + let parameters = NWParameters.tcp + parameters.includePeerToPeer = true + browser = NWBrowser(for: .bonjour(type: PeerProtocol.serviceType, domain: "local."), using: parameters) + browser.browseResultsChangedHandler = { [continuation] results, _ in + let peers = results.compactMap { result -> DiscoveredPeer? in + guard case let .service(name, _, _, _) = result.endpoint else { return nil } + return DiscoveredPeer(name: name, endpoint: result.endpoint) + } + continuation.yield(peers) + } + browser.stateUpdateHandler = { [continuation] state in + if case .failed(let error) = state { continuation.finish(); NSLog("Bonjour browser failed: %@", String(describing: error)) } + } + } + public func start(queue: DispatchQueue = .init(label: "MagicSwitch.BonjourBrowser")) { browser.start(queue: queue) } + public func cancel() { browser.cancel(); continuation.finish() } +} + +public enum TransportError: Error { case invalidFrame, oversizedFrame, closed } + +public enum PeerWireMessage: Codable, Sendable { + case secure(SecurePacket) + case enrollmentRequest(EnrollmentOffer) + case enrollmentResponse(EnrollmentOffer) + case enrollmentProofRequest(EnrollmentProofRequest) + case enrollmentProofResponse(EnrollmentProofResponse) +} + +public enum PeerFraming { + public static func frame(_ packet: SecurePacket) throws -> Data { + try frameWire(.secure(packet)) + } + public static func frameWire(_ packet: PeerWireMessage) throws -> Data { + let payload = try JSONEncoder().encode(packet) + guard payload.count <= PeerConnection.maximumPacketSize else { throw TransportError.oversizedFrame } + var length = UInt32(payload.count).bigEndian + var frame = withUnsafeBytes(of: &length) { Data($0) }; frame.append(payload); return frame + } + public static func decode(_ frame: Data) throws -> SecurePacket { + guard case .secure(let packet) = try decodeWire(frame) else { throw TransportError.invalidFrame } + return packet + } + public static func decodeWire(_ frame: Data) throws -> PeerWireMessage { + guard frame.count >= 4 else { throw TransportError.invalidFrame } + let count = frame.prefix(4).withUnsafeBytes { $0.loadUnaligned(as: UInt32.self).bigEndian } + guard count > 0, count <= PeerConnection.maximumPacketSize, frame.count == Int(count) + 4 else { throw TransportError.invalidFrame } + return try JSONDecoder().decode(PeerWireMessage.self, from: frame.dropFirst(4)) + } +} + +/// Length-prefixed packet framing. Authentication and encryption are performed by `SecureCodec`. +public final class PeerConnection: @unchecked Sendable { + public static let maximumPacketSize = 65_536 + private let connection: NWConnection + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + public init(endpoint: NWEndpoint) { connection = NWConnection(to: endpoint, using: .tcp) } + public init(connection: NWConnection) { self.connection = connection } + public func start(queue: DispatchQueue) { connection.start(queue: queue) } + public func cancel() { connection.cancel() } + + public func send(_ packet: SecurePacket) async throws { + try await sendWire(.secure(packet)) + } + + public func sendWire(_ packet: PeerWireMessage) async throws { + let frame = try PeerFraming.frameWire(packet) + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + connection.send(content: frame, completion: .contentProcessed { error in + if let error { continuation.resume(throwing: error) } else { continuation.resume() } + }) + } + } + + public func receive() async throws -> SecurePacket { + guard case .secure(let packet) = try await receiveWire() else { throw TransportError.invalidFrame } + return packet + } + + public func receiveWire() async throws -> PeerWireMessage { + let header = try await receiveExactly(4) + let count = header.withUnsafeBytes { $0.loadUnaligned(as: UInt32.self).bigEndian } + guard count > 0, count <= Self.maximumPacketSize else { throw TransportError.invalidFrame } + return try decoder.decode(PeerWireMessage.self, from: await receiveExactly(Int(count))) + } + + private func receiveExactly(_ count: Int) async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + connection.receive(minimumIncompleteLength: count, maximumLength: count) { data, _, complete, error in + if let error { continuation.resume(throwing: error) } + else if let data, data.count == count { continuation.resume(returning: data) } + else if complete { continuation.resume(throwing: TransportError.closed) } + else { continuation.resume(throwing: TransportError.invalidFrame) } + } + } + } +} + +public final class PeerListener: @unchecked Sendable { + private let listener: NWListener + private let stateStream: AsyncStream + private let stateContinuation: AsyncStream.Continuation + public var onConnection: (@Sendable (PeerConnection) -> Void)? + public init(advertisedName: String) throws { + listener = try NWListener(using: .tcp) + var continuation: AsyncStream.Continuation! + stateStream = AsyncStream { continuation = $0 } + stateContinuation = continuation + let stateContinuation = continuation! + listener.service = .init(name: advertisedName, type: PeerProtocol.serviceType) + listener.newConnectionHandler = { [weak self] connection in self?.onConnection?(PeerConnection(connection: connection)) } + listener.stateUpdateHandler = { state in stateContinuation.yield(state) } + } + public func start(queue: DispatchQueue = .init(label: "MagicSwitch.PeerListener")) { listener.start(queue: queue) } + public func waitUntilReady() async throws { + for await state in stateStream { + switch state { + case .ready: return + case .failed(let error): throw error + case .cancelled: throw TransportError.closed + default: continue + } + } + throw TransportError.closed + } + public func cancel() { listener.cancel(); stateContinuation.finish() } +} diff --git a/Sources/MagicSwitchNetworking/Protocol.swift b/Sources/MagicSwitchNetworking/Protocol.swift new file mode 100644 index 0000000..c7ef7f8 --- /dev/null +++ b/Sources/MagicSwitchNetworking/Protocol.swift @@ -0,0 +1,99 @@ +import Foundation + +public enum PeerProtocol { + public static let version = 2 + public static let serviceType = "_keyboardswitch._tcp" +} + +public struct PeerCapabilities: Codable, Hashable, Sendable { + public var canControlBluetooth: Bool + public var maxProtocolVersion: Int + + public init(canControlBluetooth: Bool = true, maxProtocolVersion: Int = PeerProtocol.version) { + self.canControlBluetooth = canControlBluetooth + self.maxProtocolVersion = maxProtocolVersion + } +} + +public enum SwitchFailure: String, Codable, Sendable { + case busy, keyboardUnavailable, peerOffline, releaseFailed, connectionFailed + case verificationFailed, incompatibleProtocol, unauthorized, timedOut +} + +public enum KeyboardCommand: String, Codable, Sendable { case disconnect, connect, status } + +public enum PeerMessage: Codable, Equatable, Sendable { + case presence(name: String, capabilities: PeerCapabilities) + case keyboardStatus(deviceID: String, connected: Bool, ownerID: UUID?, epoch: UInt64, transactionID: UUID?) + case switchRequest(transactionID: UUID, deviceID: String, sourceID: UUID, targetID: UUID) + case switchAccepted(transactionID: UUID, leaseExpiresAt: Date) + case switchRejected(transactionID: UUID, reason: SwitchFailure) + case switchCancelled(transactionID: UUID) + case releaseConfirmed(transactionID: UUID) + case connectionResult(transactionID: UUID, connected: Bool, failure: SwitchFailure?) + case switchCompleted(transactionID: UUID, ownerID: UUID, epoch: UInt64) + case heartbeat(transactionID: UUID?) + case recovery(transactionID: UUID, detail: String) + case keyboardCommand(requestID: UUID, transactionID: UUID, fence: UInt64, command: KeyboardCommand, deviceID: String) + case keyboardCommandResult(requestID: UUID, transactionID: UUID, fence: UInt64, succeeded: Bool, connected: Bool?, detail: String?) + case leaseClaim(requestID: UUID, transactionID: UUID, expiresAt: Date, fence: UInt64) + case leaseDecision(requestID: UUID, transactionID: UUID, accepted: Bool, fence: UInt64) + case leaseRelease(transactionID: UUID, fence: UInt64) +} + +public struct PeerRecord: Codable, Hashable, Identifiable, Sendable { + public let id: UUID + public var name: String + public let signingPublicKey: Data + public let agreementPublicKey: Data + public var trustedAt: Date + + public init(id: UUID, name: String, signingPublicKey: Data, agreementPublicKey: Data, trustedAt: Date = .now) { + self.id = id + self.name = name + self.signingPublicKey = signingPublicKey + self.agreementPublicKey = agreementPublicKey + self.trustedAt = trustedAt + } +} + +public struct EnrollmentOffer: Codable, Equatable, Sendable { + public let peer: PeerRecord + public let challenge: Data + + public init(peer: PeerRecord, challenge: Data) { + self.peer = peer + self.challenge = challenge + } +} + +public struct EnrollmentProofRequest: Codable, Equatable, Sendable { + public let requester: EnrollmentOffer + public let responder: EnrollmentOffer + public let nonce: UUID + public let endpointBinding: String + + public init(requester: EnrollmentOffer, responder: EnrollmentOffer, nonce: UUID, endpointBinding: String) { + self.requester = requester; self.responder = responder; self.nonce = nonce; self.endpointBinding = endpointBinding + } +} + +public struct EnrollmentProofResponse: Codable, Equatable, Sendable { + public let responderID: UUID + public let nonce: UUID + public let signature: Data + + public init(responderID: UUID, nonce: UUID, signature: Data) { + self.responderID = responderID; self.nonce = nonce; self.signature = signature + } +} + +public struct SecurePacket: Codable, Equatable, Sendable { + public let version: Int + public let senderID: UUID + public let recipientID: UUID + public let sentAt: Date + public let nonce: UUID + public let ciphertext: Data + public let signature: Data +} diff --git a/Sources/MagicSwitchNetworking/SecureCodec.swift b/Sources/MagicSwitchNetworking/SecureCodec.swift new file mode 100644 index 0000000..853a9dd --- /dev/null +++ b/Sources/MagicSwitchNetworking/SecureCodec.swift @@ -0,0 +1,82 @@ +import CryptoKit +import Foundation + +public actor ReplayGuard { + private var seen: [UUID: Date] = [:] + private let maximumAge: TimeInterval + public init(maximumAge: TimeInterval = 30) { self.maximumAge = maximumAge } + public func validate(nonce: UUID, sentAt: Date, now: Date = .now) throws { + guard abs(now.timeIntervalSince(sentAt)) <= maximumAge else { throw SecureCodecError.stale } + seen = seen.filter { now.timeIntervalSince($0.value) <= maximumAge } + guard seen[nonce] == nil else { throw SecureCodecError.replay } + seen[nonce] = now + } +} + +public enum SecureCodecError: Error, Equatable { + case incompatibleVersion, wrongRecipient, untrustedPeer, invalidSignature, replay, stale, malformed +} + +public struct SecureCodec: Sendable { + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + public init() {} + + public func seal(_ message: PeerMessage, from identity: PeerIdentity, to peer: PeerRecord, now: Date = .now, nonce: UUID = UUID()) throws -> SecurePacket { + let publicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: peer.agreementPublicKey) + let key = try symmetricKey(identity: identity, peerPublicKey: publicKey) + let sealed = try ChaChaPoly.seal(encoder.encode(message), using: key) + let unsigned = UnsignedPacket(version: PeerProtocol.version, senderID: identity.id, recipientID: peer.id, + sentAt: now, nonce: nonce, ciphertext: sealed.combined) + let signature = try identity.signingKey.signature(for: signatureMaterial(unsigned)) + return SecurePacket(version: unsigned.version, senderID: unsigned.senderID, recipientID: unsigned.recipientID, + sentAt: unsigned.sentAt, nonce: unsigned.nonce, ciphertext: unsigned.ciphertext, signature: signature) + } + + public func open(_ packet: SecurePacket, as identity: PeerIdentity, trustedPeer: PeerRecord, replayGuard: ReplayGuard) async throws -> PeerMessage { + guard packet.version == PeerProtocol.version else { throw SecureCodecError.incompatibleVersion } + guard packet.recipientID == identity.id else { throw SecureCodecError.wrongRecipient } + guard packet.senderID == trustedPeer.id else { throw SecureCodecError.untrustedPeer } + let unsigned = UnsignedPacket(version: packet.version, senderID: packet.senderID, recipientID: packet.recipientID, + sentAt: packet.sentAt, nonce: packet.nonce, ciphertext: packet.ciphertext) + guard let signingKey = try? Curve25519.Signing.PublicKey(rawRepresentation: trustedPeer.signingPublicKey), + signingKey.isValidSignature(packet.signature, for: signatureMaterial(unsigned)) else { throw SecureCodecError.invalidSignature } + try await replayGuard.validate(nonce: packet.nonce, sentAt: packet.sentAt) + do { + let agreementKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: trustedPeer.agreementPublicKey) + let key = try symmetricKey(identity: identity, peerPublicKey: agreementKey) + let box = try ChaChaPoly.SealedBox(combined: packet.ciphertext) + return try decoder.decode(PeerMessage.self, from: ChaChaPoly.open(box, using: key)) + } catch let error as SecureCodecError { throw error } + catch { throw SecureCodecError.malformed } + } + + private func symmetricKey(identity: PeerIdentity, peerPublicKey: Curve25519.KeyAgreement.PublicKey) throws -> SymmetricKey { + let secret = try identity.agreementKey.sharedSecretFromKeyAgreement(with: peerPublicKey) + return secret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: Data("MagicSwitch-v1".utf8), + sharedInfo: Data(), outputByteCount: 32) + } + + /// Canonical binary representation avoids JSON key-order and date-rounding differences. + private func signatureMaterial(_ packet: UnsignedPacket) -> Data { + var data = Data() + func appendInteger(_ value: T) { + var value = value.bigEndian + withUnsafeBytes(of: &value) { data.append(contentsOf: $0) } + } + func appendUUID(_ uuid: UUID) { + var value = uuid.uuid + withUnsafeBytes(of: &value) { data.append(contentsOf: $0) } + } + appendInteger(UInt32(packet.version)) + appendUUID(packet.senderID); appendUUID(packet.recipientID) + appendInteger(packet.sentAt.timeIntervalSince1970.bitPattern) + appendUUID(packet.nonce) + appendInteger(UInt32(packet.ciphertext.count)); data.append(packet.ciphertext) + return data + } +} + +private struct UnsignedPacket: Codable { + let version: Int; let senderID: UUID; let recipientID: UUID; let sentAt: Date; let nonce: UUID; let ciphertext: Data +} diff --git a/Sources/MagicSwitchNetworking/Trust.swift b/Sources/MagicSwitchNetworking/Trust.swift new file mode 100644 index 0000000..a5a6df2 --- /dev/null +++ b/Sources/MagicSwitchNetworking/Trust.swift @@ -0,0 +1,73 @@ +import CryptoKit +import Foundation + +public actor TrustStore { + private var peers: [UUID: PeerRecord] + + /// In-memory store for tests and previews. Production code uses `KeychainTrustStore`. + public init(peers: [PeerRecord] = []) { + self.peers = Dictionary(uniqueKeysWithValues: peers.map { ($0.id, $0) }) + } + + public func peer(id: UUID) -> PeerRecord? { peers[id] } + public func allPeers() -> [PeerRecord] { peers.values.sorted { $0.name < $1.name } } + public func trust(_ peer: PeerRecord) throws { + guard peers.count < 2 || peers[peer.id] != nil else { throw TrustError.peerLimitReached } + if let existing = peers[peer.id], (existing.signingPublicKey != peer.signingPublicKey || existing.agreementPublicKey != peer.agreementPublicKey) { + throw TrustError.identityChanged + } + peers[peer.id] = peer + // Intentionally not persisted outside the Keychain. + } + public func remove(id: UUID) throws { peers[id] = nil } +} + +public enum TrustError: Error, Equatable { case peerLimitReached, identityChanged, invalidCode } + +public enum Enrollment { + public static func makeOffer(identity: PeerIdentity, name: String) -> EnrollmentOffer { + EnrollmentOffer(peer: identity.record(name: name), challenge: Data((0..<16).map { _ in UInt8.random(in: .min ... .max) })) + } + + /// A user-verifiable six digit code. Both peers compute this after exchanging offers. + public static func verificationCode(local: EnrollmentOffer, remote: EnrollmentOffer) -> String { + let ordered = [local, remote].sorted { $0.peer.id.uuidString < $1.peer.id.uuidString } + var material = Data("MagicSwitch/enrollment/v1\0".utf8) + func append(_ data: Data) { var length = UInt32(data.count).bigEndian; withUnsafeBytes(of: &length) { material.append(contentsOf: $0) }; material.append(data) } + for offer in ordered { + append(Data(offer.peer.id.uuidString.utf8)); append(Data(offer.peer.name.utf8)) + append(offer.peer.signingPublicKey); append(offer.peer.agreementPublicKey); append(offer.challenge) + } + let digest = SHA256.hash(data: material) + let value = digest.prefix(4).reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } % 1_000_000 + return String(format: "%06u", value) + } + + /// Canonical transcript for the endpoint-selected, pre-trust possession proof. Both complete + /// offers and the fresh request nonce are signed; endpointBinding is the exact endpoint chosen + /// by the requester and echoed by the responder over that same bounded TCP connection. + public static func proofTranscript(_ request: EnrollmentProofRequest) throws -> Data { + var material = Data("Keybridge/enrollment-possession/v1\0".utf8) + func append(_ data: Data) { var length = UInt32(data.count).bigEndian; withUnsafeBytes(of: &length) { material.append(contentsOf: $0) }; material.append(data) } + append(try canonicalOffer(request.requester)); append(try canonicalOffer(request.responder)) + append(Data(request.nonce.uuidString.utf8)); append(Data(request.endpointBinding.utf8)) + return material + } + + public static func signProof(_ request: EnrollmentProofRequest, identity: PeerIdentity) throws -> EnrollmentProofResponse { + .init(responderID: identity.id, nonce: request.nonce, + signature: try identity.signingKey.signature(for: proofTranscript(request))) + } + + public static func verifyProof(_ response: EnrollmentProofResponse, request: EnrollmentProofRequest) -> Bool { + guard response.responderID == request.responder.peer.id, response.nonce == request.nonce, + let key = try? Curve25519.Signing.PublicKey(rawRepresentation: request.responder.peer.signingPublicKey), + let transcript = try? proofTranscript(request) else { return false } + return key.isValidSignature(response.signature, for: transcript) + } + + private static func canonicalOffer(_ offer: EnrollmentOffer) throws -> Data { + let encoder = JSONEncoder(); encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return try encoder.encode(offer) + } +} diff --git a/Tests/ACCEPTANCE.md b/Tests/ACCEPTANCE.md new file mode 100644 index 0000000..9eff679 --- /dev/null +++ b/Tests/ACCEPTANCE.md @@ -0,0 +1,14 @@ +# Hardware Acceptance Record + +Physical compatibility is not inferred from unit tests or a successful Bluetooth API return. Run `./Scripts/hardware-acceptance.sh` with a fallback input device available. The harness writes JSON Lines evidence under `dist/` and stops on the first failed observation. + +Required release evidence: + +- Set `MAC_A_MODEL`, `MAC_B_MODEL`, `MAC_C_MODEL`, `MAC_A_OS`, `MAC_B_OS`, `MAC_C_OS`, `KEYBOARD_MODEL`, and `KEYBOARD_FIRMWARE`; the harness refuses unknown metadata. +- Complete three consecutive A→B→C→A cycles with exact typed phrases received only by each target. +- Confirm pairing remains on both Macs after every handoff. +- Complete a handoff after app restart, sleep/wake, Wi-Fi interruption, Bluetooth restart, and keyboard power-cycle. +- Confirm failure recovery either restores the source or accurately requests manual recovery. +- Never accept a Bluetooth “Connected” status as typed-HID proof. + +Never commit hardware logs containing machine names or complete Bluetooth identifiers. Attach redacted logs to the release record. diff --git a/Tests/KeyboardSwitchAppTests/AppRuntimeTests.swift b/Tests/KeyboardSwitchAppTests/AppRuntimeTests.swift new file mode 100644 index 0000000..928f897 --- /dev/null +++ b/Tests/KeyboardSwitchAppTests/AppRuntimeTests.swift @@ -0,0 +1,262 @@ +import Foundation +import KeyboardSwitchCore +import MagicSwitchNetworking +import Testing +@testable import KeyboardSwitchApp + +@Suite("App runtime safety and configuration") +struct AppRuntimeTests { + @Test("configuration round trips") + func configurationRoundTrip() { + let suite = "KeybridgeTests.\(UUID())" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let store = UserDefaultsConfigurationStore(defaults: defaults) + let expected = AppConfiguration(localMacName: "Studio", keyboardID: "AA-BB", trustedPeerIDs: [UUID()], hasCompletedOnboarding: true) + store.save(expected) + #expect(store.load() == expected) + } + + @Test("legacy configuration defaults round-trip evidence to false") + func legacyConfigurationMigration() throws { + let peerID = UUID() + let json = """ + {"localMacName":"Studio","keyboardID":"keyboard","trustedPeerIDs":["\(peerID.uuidString)"],"hasCompletedOnboarding":false,"directShortcutEnabled":false} + """ + let decoded = try JSONDecoder().decode(AppConfiguration.self, from: Data(json.utf8)) + #expect(!decoded.guidedRoundTripConfirmed) + #expect(decoded.trustedPeerIDs == [peerID]) + } + + @Test("onboarding requires keyboard trust and typed round-trip confirmation") @MainActor + func onboardingReadinessGate() { + var configuration = AppConfiguration.initial + configuration.guidedRoundTripConfirmed = true + let runtime = ModelRuntime(), store = MemoryConfigurationStore(configuration) + let model = AppModel(runtime: runtime, configurationStore: store) + model.finishOnboarding() + #expect(!model.hasCompletedOnboarding) + + model.selectKeyboard(.init(id: UUID(), name: "Magic Keyboard", detail: "keyboard", isPaired: true, isConnected: true)) + model.peers = [.init(id: UUID(), name: "Laptop", availability: .online, isTrusted: true)] + model.finishOnboarding() + #expect(model.hasCompletedOnboarding) + #expect(model.onboardingReady) + } + + @Test("production runtime never succeeds before secure service startup") + func stoppedRuntimeDoesNotFakeSuccess() async { + let runtime = ProductionAppRuntime(bluetooth: EmptyBluetoothAdapter(), identityStore: MemoryIdentityStore()) + await #expect(throws: AppRuntimeError.self) { + try await runtime.transfer(keyboardID: KeyboardID("keyboard"), source: PeerID(), target: PeerID()) { _ in } + } + } + + @Test("production runtime fails closed when secure identity cannot load") + func identityFailureFailsClosed() async { + let runtime = ProductionAppRuntime(bluetooth: EmptyBluetoothAdapter(), identityStore: FailingIdentityStore()) + await runtime.start() + await #expect(throws: AppRuntimeError.self) { + _ = try await runtime.localEnrollmentOffer() + } + await #expect(throws: AppRuntimeError.self) { + try await runtime.transfer(keyboardID: KeyboardID("keyboard"), source: PeerID(), target: PeerID()) { _ in } + } + } + + @Test("remote lease expiry is narrowly bounded") + func leaseExpiryIsBounded() { + let now = Date() + #expect(ProductionAppRuntime.isAcceptableLeaseExpiry(now.addingTimeInterval(30), now: now)) + #expect(!ProductionAppRuntime.isAcceptableLeaseExpiry(now.addingTimeInterval(300), now: now)) + #expect(!ProductionAppRuntime.isAcceptableLeaseExpiry(now.addingTimeInterval(-1), now: now)) + } + + @Test("lease authority fences local operations, renews, and cancels exactly") + func leaseAuthorityFencing() { + let claimant = UUID(), transaction = UUID(), now = Date(), fence: UInt64 = 42 + var authority = LeaseAuthority() + let initiallyAccepted = authority.accept(claimantID: claimant, transactionID: transaction, + expiry: now.addingTimeInterval(10), fence: fence, now: now) + #expect(initiallyAccepted) + #expect(authority.authorizes(claimantID: claimant, transactionID: transaction, + fence: fence, now: now)) + #expect(!authority.authorizes(claimantID: claimant, transactionID: transaction, + fence: fence - 1, now: now)) + #expect(!authority.authorizes(claimantID: claimant, transactionID: transaction, + fence: fence, now: now.addingTimeInterval(11))) + + let renewed = authority.accept(claimantID: claimant, transactionID: transaction, + expiry: now.addingTimeInterval(30), fence: fence, now: now) + #expect(renewed) + #expect(authority.authorizes(claimantID: claimant, transactionID: transaction, + fence: fence, now: now.addingTimeInterval(20))) + let wrongCancellation = authority.cancel(claimantID: claimant, transactionID: UUID()) + let cancellation = authority.cancel(claimantID: claimant, transactionID: transaction) + #expect(!wrongCancellation) + #expect(cancellation) + #expect(!authority.authorizes(claimantID: claimant, transactionID: transaction, + fence: fence, now: now)) + } + + @Test("lease commands and releases are bound to claimant transaction and fence") + func leaseAuthorizationIsFullyBound() { + let now = Date(), owner = UUID(), attacker = UUID(), transaction = UUID() + var authority = LeaseAuthority() + let accepted = authority.accept(claimantID: owner, transactionID: transaction, + expiry: now.addingTimeInterval(30), fence: 42, now: now) + #expect(accepted) + #expect(authority.authorizes(claimantID: owner, transactionID: transaction, fence: 42, now: now)) + #expect(!authority.authorizes(claimantID: attacker, transactionID: transaction, fence: 42, now: now)) + #expect(!authority.authorizes(claimantID: owner, transactionID: UUID(), fence: 42, now: now)) + #expect(!authority.authorizes(claimantID: owner, transactionID: transaction, fence: 41, now: now)) + let attackerRelease = authority.release(claimantID: attacker, transactionID: transaction, fence: 42) + let wrongTransactionRelease = authority.release(claimantID: owner, transactionID: UUID(), fence: 42) + let wrongFenceRelease = authority.release(claimantID: owner, transactionID: transaction, fence: 41) + #expect(!attackerRelease); #expect(!wrongTransactionRelease); #expect(!wrongFenceRelease) + #expect(authority.authorizes(claimantID: owner, transactionID: transaction, fence: 42, now: now)) + let ownerRelease = authority.release(claimantID: owner, transactionID: transaction, fence: 42) + #expect(ownerRelease) + #expect(!authority.authorizes(claimantID: owner, transactionID: transaction, fence: 42, now: now)) + } + + @Test("lease bounds reject expired overlong and conflicting claims") + func leaseClaimsAreBounded() { + let now = Date(), owner = UUID(), transaction = UUID() + var authority = LeaseAuthority() + let expired = authority.accept(claimantID: owner, transactionID: transaction, + expiry: now.addingTimeInterval(-1), fence: 1, now: now) + let overlong = authority.accept(claimantID: owner, transactionID: transaction, + expiry: now.addingTimeInterval(36), fence: 1, now: now) + let valid = authority.accept(claimantID: owner, transactionID: transaction, + expiry: now.addingTimeInterval(30), fence: 2, now: now) + let conflicting = authority.accept(claimantID: UUID(), transactionID: UUID(), + expiry: now.addingTimeInterval(30), fence: 3, now: now) + let staleFence = authority.accept(claimantID: owner, transactionID: transaction, + expiry: now.addingTimeInterval(30), fence: 1, now: now) + #expect(!expired); #expect(!overlong); #expect(valid); #expect(!conflicting); #expect(!staleFence) + } + + @Test("reply correlation rejects wrong peer transaction fence and request") + func repliesAreFullyCorrelated() { + let request = UUID(), peer = UUID(), transaction = UUID() + var correlator = ReplyCorrelator() + correlator.expect(requestID: request, peerID: peer, transactionID: transaction, fence: 99) + #expect(correlator.accepts(requestID: request, peerID: peer, transactionID: transaction, fence: 99)) + #expect(!correlator.accepts(requestID: UUID(), peerID: peer, transactionID: transaction, fence: 99)) + #expect(!correlator.accepts(requestID: request, peerID: UUID(), transactionID: transaction, fence: 99)) + #expect(!correlator.accepts(requestID: request, peerID: peer, transactionID: UUID(), fence: 99)) + #expect(!correlator.accepts(requestID: request, peerID: peer, transactionID: transaction, fence: 98)) + correlator.remove(requestID: request) + #expect(!correlator.accepts(requestID: request, peerID: peer, transactionID: transaction, fence: 99)) + } + + @Test("partitioned trusted members remain required lease voters") + func fixedLeaseMembership() { + let local = UUID(), online = UUID(), partitioned = UUID() + let voters = ProductionAppRuntime.requiredVoters(trustedPeerIDs: [online, partitioned], localID: local) + #expect(voters == [local, online, partitioned]) + // Discovery/online state is intentionally absent from this calculation: the caller must + // contact every configured voter and fail closed if the partitioned member cannot vote. + } + + @Test("ownership epochs reject reordered updates and survive restart") + func ownershipEpochOrdering() { + let store = MemoryOwnershipEpochStore(), oldOwner = UUID(), newOwner = UUID() + var state = OwnershipState(store: store) + let acceptedNew = state.apply(ownerID: newOwner, epoch: 200) + let acceptedOld = state.apply(ownerID: oldOwner, epoch: 100) + #expect(acceptedNew) + #expect(!acceptedOld) + #expect(state.ownerID == newOwner) + + var restarted = OwnershipState(store: store) + #expect(restarted.epoch == 200) + #expect(restarted.ownerID == newOwner) + let acceptedEqual = restarted.apply(ownerID: oldOwner, epoch: 200) + let acceptedStale = restarted.apply(ownerID: oldOwner, epoch: 199) + #expect(!acceptedEqual) + #expect(!acceptedStale) + let next = restarted.nextEpoch(wallClock: 150) + restarted.commitLocal(ownerID: newOwner, epoch: next) + #expect(restarted.epoch == 201) + #expect(restarted.ownerID == newOwner) + } + + @Test("model consumes remote owner events and retry replays the failed transfer") @MainActor + func modelOwnerAndRetry() async throws { + let peerID = UUID(), runtime = ModelRuntime() + let store = MemoryConfigurationStore(.init(localMacName: "Configured Studio", keyboardID: "keyboard", + trustedPeerIDs: [peerID], hasCompletedOnboarding: true)) + let model = AppModel(runtime: runtime, configurationStore: store) + let peer = PeerMac(id: peerID, name: "Laptop", availability: .online, isTrusted: true) + model.peers = [peer] + model.selectKeyboard(.init(id: UUID(), name: "Magic Keyboard", detail: "keyboard", isPaired: true, isConnected: true)) + model.ownerID = model.localPeer.id + + model.moveKeyboard(to: peer) + try await waitUntil { if case .failed = model.transferStage { true } else { false } } + #expect(await runtime.transferCount() == 1) + model.retry() + try await waitUntil { model.ownerID == peerID && model.transferStage == .complete("Laptop") } + #expect(await runtime.transferCount() == 2) + + await runtime.emitOwner(peerID) + try await waitUntil { model.ownerID == peerID } + #expect(!model.keyboard.isConnected) + } + + @MainActor private func waitUntil(_ condition: () -> Bool) async throws { + for _ in 0..<100 { + if condition() { return } + try await Task.sleep(for: .milliseconds(10)) + } + Issue.record("Timed out waiting for model state") + } +} + +private final class MemoryConfigurationStore: AppConfigurationPersisting, @unchecked Sendable { + private var value: AppConfiguration + init(_ value: AppConfiguration) { self.value = value } + func load() -> AppConfiguration { value } + func save(_ value: AppConfiguration) { self.value = value } +} + +private final class MemoryOwnershipEpochStore: OwnershipEpochPersisting, @unchecked Sendable { + private let lock = NSLock() + private var value = PersistedOwnership(ownerID: nil, epoch: 0) + func load() -> PersistedOwnership { lock.withLock { value } } + func save(_ value: PersistedOwnership) { lock.withLock { self.value = value } } +} + +private actor ModelRuntime: AppRuntime { + private let peerPair = AsyncStream<[RuntimePeer]>.makeStream() + private let ownerPair = AsyncStream.makeStream() + private var transfers = 0 + + func keyboards() -> [BluetoothKeyboard] { [.init(id: .init("keyboard"), name: "Magic Keyboard", isPaired: true, isConnected: false)] } + func peerUpdates() -> AsyncStream<[RuntimePeer]> { peerPair.stream } + func ownerUpdates() -> AsyncStream { ownerPair.stream } + func start() {} + func stop() {} + func emitOwner(_ id: UUID?) { ownerPair.continuation.yield(id) } + func transferCount() -> Int { transfers } + func transfer(keyboardID: KeyboardID, source: PeerID, target: PeerID, progress: @escaping @Sendable (SwitchProgress) -> Void) async throws { + transfers += 1 + if transfers == 1 { throw BluetoothAdapterError.unsupported("simulated failure") } + progress(.init(transactionID: UUID(), stage: .completed, detail: "Keyboard moved")) + } +} + +private struct FailingIdentityStore: IdentityPersisting { + struct Failure: Error {} + func loadOrCreateIdentity() throws -> PeerIdentity { throw Failure() } +} + +private actor EmptyBluetoothAdapter: BluetoothAdapter { + func keyboards() -> [BluetoothKeyboard] { [] } + func keyboard(id: KeyboardID) throws -> BluetoothKeyboard { throw BluetoothAdapterError.deviceNotFound(id) } + func connect(id: KeyboardID) throws {} + func disconnect(id: KeyboardID) throws {} + func events() -> AsyncStream<[BluetoothKeyboard]> { AsyncStream { $0.finish() } } +} diff --git a/Tests/KeyboardSwitchAppTests/MultiPeerIntegrationTests.swift b/Tests/KeyboardSwitchAppTests/MultiPeerIntegrationTests.swift new file mode 100644 index 0000000..32500cf --- /dev/null +++ b/Tests/KeyboardSwitchAppTests/MultiPeerIntegrationTests.swift @@ -0,0 +1,50 @@ +import Foundation +import KeyboardSwitchCore +import Testing + +@Suite("Simulated three-Mac switching") +struct MultiPeerIntegrationTests { + @Test("keyboard performs A to B to C to A round trip") + func roundTrip() async throws { + let a = PeerID(), b = PeerID(), c = PeerID() + let keyboard = KeyboardID("integration-keyboard") + let fleet = SimulatedFleet(peers: [a, b, c], owner: a) + let coordinator = KeyboardSwitchCoordinator(remote: fleet, lease: InMemorySwitchLease(), policy: .init(initialRetryDelay: .milliseconds(1), verificationAttempts: 2)) + + for (source, target) in [(a, b), (b, c), (c, a)] { + try await coordinator.perform(.init(keyboardID: keyboard, source: source, target: target)) + #expect(await fleet.currentOwner() == target) + } + } + + @Test("failed target connection restores source") + func recovery() async { + let a = PeerID(), b = PeerID(), keyboard = KeyboardID("integration-keyboard") + let fleet = SimulatedFleet(peers: [a, b], owner: a, failingConnectPeer: b) + let coordinator = KeyboardSwitchCoordinator(remote: fleet, lease: InMemorySwitchLease(), policy: .init(maximumAttempts: 1, initialRetryDelay: .milliseconds(1), verificationAttempts: 1)) + await #expect(throws: SwitchFailure.targetConnectionFailed(recoveredSource: true)) { + try await coordinator.perform(.init(keyboardID: keyboard, source: a, target: b)) + } + #expect(await fleet.currentOwner() == a) + } +} + +private actor SimulatedFleet: RemoteKeyboardControl { + private let peers: Set + private var owner: PeerID? + private let failingConnectPeer: PeerID? + + init(peers: Set, owner: PeerID?, failingConnectPeer: PeerID? = nil) { + self.peers = peers; self.owner = owner; self.failingConnectPeer = failingConnectPeer + } + + func currentOwner() -> PeerID? { owner } + func isReachable(peer: PeerID) -> Bool { peers.contains(peer) } + func disconnect(keyboard: KeyboardID, on peer: PeerID) throws { if owner == peer { owner = nil } } + func connect(keyboard: KeyboardID, on peer: PeerID) throws { + if peer == failingConnectPeer { throw BluetoothAdapterError.operationFailed(operation: "connect", code: -1) } + guard peers.contains(peer) else { throw SwitchFailure.targetUnreachable } + owner = peer + } + func isConnected(keyboard: KeyboardID, on peer: PeerID) -> Bool { owner == peer } +} diff --git a/Tests/KeyboardSwitchCoreTests/BluetoothSimulatorTests.swift b/Tests/KeyboardSwitchCoreTests/BluetoothSimulatorTests.swift new file mode 100644 index 0000000..2c5c0fa --- /dev/null +++ b/Tests/KeyboardSwitchCoreTests/BluetoothSimulatorTests.swift @@ -0,0 +1,40 @@ +import Testing +import KeyboardSwitchBluetooth +import KeyboardSwitchCore + +@Suite("Bluetooth simulator") +struct BluetoothSimulatorTests { + @Test("connect and disconnect preserve pairing") + func connectionLifecycle() async throws { + let id = KeyboardID("test") + let adapter = SimulatedBluetoothAdapter(keyboards: [.init(id: id, name: "Magic Keyboard", isPaired: true, isConnected: false)]) + try await adapter.connect(id: id) + #expect(try await adapter.keyboard(id: id).isConnected) + try await adapter.disconnect(id: id) + let keyboard = try await adapter.keyboard(id: id) + #expect(!keyboard.isConnected) + #expect(keyboard.isPaired) + } + + @Test("unpaired keyboard cannot connect") + func unpairedFails() async { + let id = KeyboardID("test") + let adapter = SimulatedBluetoothAdapter(keyboards: [.init(id: id, name: "Magic Keyboard", isPaired: false, isConnected: false)]) + await #expect(throws: BluetoothAdapterError.deviceNotPaired(id)) { + try await adapter.connect(id: id) + } + } + + @Test("event stream observes state changes") + func eventStream() async throws { + let id = KeyboardID("test") + let adapter = SimulatedBluetoothAdapter(keyboards: [.init(id: id, name: "Magic Keyboard", isPaired: true, isConnected: false)]) + let stream = await adapter.events() + let observation = Task { () -> Bool in + for await snapshot in stream where snapshot.first?.isConnected == true { return true } + return false + } + try await adapter.connect(id: id) + #expect(await observation.value) + } +} diff --git a/Tests/KeyboardSwitchCoreTests/SwitchingTests.swift b/Tests/KeyboardSwitchCoreTests/SwitchingTests.swift new file mode 100644 index 0000000..b0624a7 --- /dev/null +++ b/Tests/KeyboardSwitchCoreTests/SwitchingTests.swift @@ -0,0 +1,139 @@ +import Foundation +import Testing +@testable import KeyboardSwitchCore + +@Suite("Keyboard switch transaction") +struct SwitchingTests { + @Test("successful transfer releases source and connects target") + func successfulTransfer() async throws { + let source = PeerID(), target = PeerID(), keyboard = KeyboardID("keyboard") + let remote = FakeRemote(connected: [source: true, target: false]) + let coordinator = KeyboardSwitchCoordinator(remote: remote, lease: InMemorySwitchLease(), policy: fastPolicy) + try await coordinator.perform(.init(keyboardID: keyboard, source: source, target: target)) + #expect(await remote.isConnected(keyboard: keyboard, on: source) == false) + #expect(await remote.isConnected(keyboard: keyboard, on: target) == true) + } + + @Test("successful transfer releases its lease synchronously") + func releasesLease() async throws { + let source = PeerID(), target = PeerID(), lease = InMemorySwitchLease() + let coordinator = KeyboardSwitchCoordinator(remote: FakeRemote(connected: [source: true]), lease: lease, policy: fastPolicy) + try await coordinator.perform(.init(keyboardID: .init("keyboard"), source: source, target: target)) + #expect(await lease.acquire(transactionID: UUID(), expiresAt: .distantFuture)) + } + + @Test("target failure restores source") + func recovery() async throws { + let source = PeerID(), target = PeerID(), keyboard = KeyboardID("keyboard") + let remote = FakeRemote(connected: [source: true, target: false], failingConnectPeers: [target]) + let coordinator = KeyboardSwitchCoordinator(remote: remote, lease: InMemorySwitchLease(), policy: fastPolicy) + do { + try await coordinator.perform(.init(keyboardID: keyboard, source: source, target: target)) + Issue.record("Expected target connection failure") + } catch let failure as SwitchFailure { + #expect(failure == .targetConnectionFailed(recoveredSource: true)) + } + #expect(await remote.isConnected(keyboard: keyboard, on: source) == true) + } + + @Test("lease contention rejects transfer") + func leaseContention() async throws { + let lease = InMemorySwitchLease() + #expect(await lease.acquire(transactionID: UUID(), expiresAt: .distantFuture)) + let coordinator = KeyboardSwitchCoordinator(remote: FakeRemote(), lease: lease, policy: fastPolicy) + do { + try await coordinator.perform(.init(keyboardID: .init("keyboard"), source: PeerID(), target: PeerID())) + Issue.record("Expected lease failure") + } catch let failure as SwitchFailure { + #expect(failure == .leaseUnavailable) + } + } + + @Test("same source and target is rejected") + func samePeer() async { + let peer = PeerID() + let coordinator = KeyboardSwitchCoordinator(remote: FakeRemote(), lease: InMemorySwitchLease(), policy: fastPolicy) + await #expect(throws: SwitchFailure.samePeer) { + try await coordinator.perform(.init(keyboardID: .init("keyboard"), source: peer, target: peer)) + } + } + + @Test("offline peers have accurate failures") + func reachabilityFailures() async { + let source = PeerID(), target = PeerID(), keyboard = KeyboardID("keyboard") + let sourceOffline = KeyboardSwitchCoordinator(remote: FakeRemote(reachable: [target]), lease: InMemorySwitchLease(), policy: fastPolicy) + await #expect(throws: SwitchFailure.sourceUnreachable) { + try await sourceOffline.perform(.init(keyboardID: keyboard, source: source, target: target)) + } + let targetOffline = KeyboardSwitchCoordinator(remote: FakeRemote(reachable: [source]), lease: InMemorySwitchLease(), policy: fastPolicy) + await #expect(throws: SwitchFailure.targetUnreachable) { + try await targetOffline.perform(.init(keyboardID: keyboard, source: source, target: target)) + } + } + + @Test("failed target and recovery reports manual recovery") + func failedRecovery() async { + let source = PeerID(), target = PeerID(), keyboard = KeyboardID("keyboard") + let remote = FakeRemote(connected: [source: true], failingConnectPeers: [source, target]) + let coordinator = KeyboardSwitchCoordinator(remote: remote, lease: InMemorySwitchLease(), policy: fastPolicy) + await #expect(throws: SwitchFailure.targetConnectionFailed(recoveredSource: false)) { + try await coordinator.perform(.init(keyboardID: keyboard, source: source, target: target)) + } + } + + @Test("cancellation releases lease") + func cancellationReleasesLease() async throws { + let source = PeerID(), target = PeerID(), lease = InMemorySwitchLease() + let coordinator = KeyboardSwitchCoordinator(remote: SlowRemote(), lease: lease, policy: .init(operationTimeout: .seconds(5))) + let task = Task { try await coordinator.perform(.init(keyboardID: .init("keyboard"), source: source, target: target)) } + try await Task.sleep(for: .milliseconds(20)) + task.cancel() + await #expect(throws: CancellationError.self) { try await task.value } + #expect(await lease.acquire(transactionID: UUID(), expiresAt: .distantFuture)) + } + + @Test("hung operation is bounded") + func operationTimeout() async { + let coordinator = KeyboardSwitchCoordinator( + remote: SlowRemote(), lease: InMemorySwitchLease(), + policy: .init(maximumAttempts: 1, initialRetryDelay: .milliseconds(1), verificationAttempts: 1, leaseDuration: 2, operationTimeout: .milliseconds(10)) + ) + await #expect(throws: SwitchFailure.operationTimedOut("releasing the keyboard")) { + try await coordinator.perform(.init(keyboardID: .init("keyboard"), source: PeerID(), target: PeerID())) + } + } + + private var fastPolicy: SwitchPolicy { + .init(maximumAttempts: 2, initialRetryDelay: .milliseconds(1), verificationAttempts: 2, leaseDuration: 2) + } +} + +private actor FakeRemote: RemoteKeyboardControl { + private var connected: [PeerID: Bool] + private let reachable: Set? + private let failingConnectPeers: Set + + init(connected: [PeerID: Bool] = [:], reachable: Set? = nil, failingConnectPeers: Set = []) { + self.connected = connected + self.reachable = reachable + self.failingConnectPeers = failingConnectPeers + } + + func isReachable(peer: PeerID) -> Bool { reachable?.contains(peer) ?? true } + + func disconnect(keyboard: KeyboardID, on peer: PeerID) { connected[peer] = false } + + func connect(keyboard: KeyboardID, on peer: PeerID) throws { + if failingConnectPeers.contains(peer) { throw BluetoothAdapterError.operationFailed(operation: "connect", code: -1) } + connected[peer] = true + } + + func isConnected(keyboard: KeyboardID, on peer: PeerID) -> Bool { connected[peer] ?? false } +} + +private actor SlowRemote: RemoteKeyboardControl { + func isReachable(peer: PeerID) -> Bool { true } + func disconnect(keyboard: KeyboardID, on peer: PeerID) async throws { try await Task.sleep(for: .seconds(30)) } + func connect(keyboard: KeyboardID, on peer: PeerID) async throws { try await Task.sleep(for: .seconds(30)) } + func isConnected(keyboard: KeyboardID, on peer: PeerID) -> Bool { false } +} diff --git a/Tests/MagicSwitchNetworkingTests/SecurityTests.swift b/Tests/MagicSwitchNetworkingTests/SecurityTests.swift new file mode 100644 index 0000000..24d1c03 --- /dev/null +++ b/Tests/MagicSwitchNetworkingTests/SecurityTests.swift @@ -0,0 +1,355 @@ +import CryptoKit +import Foundation +import Testing +@testable import MagicSwitchNetworking + +@Suite struct SecurityTests { + @Test func encryptedRoundTrip() async throws { + let alice = PeerIdentity(), bob = PeerIdentity() + let message = PeerMessage.switchRequest(transactionID: UUID(), deviceID: "keyboard", sourceID: alice.id, targetID: bob.id) + let packet = try SecureCodec().seal(message, from: alice, to: bob.record(name: "Bob")) + #expect(packet.ciphertext != (try JSONEncoder().encode(message))) + let opened = try await SecureCodec().open(packet, as: bob, trustedPeer: alice.record(name: "Alice"), replayGuard: ReplayGuard()) + #expect(opened == message) + } + + @Test func rejectsReplay() async throws { + let alice = PeerIdentity(), bob = PeerIdentity(), guarder = ReplayGuard() + let packet = try SecureCodec().seal(.heartbeat(transactionID: nil), from: alice, to: bob.record(name: "Bob")) + _ = try await SecureCodec().open(packet, as: bob, trustedPeer: alice.record(name: "Alice"), replayGuard: guarder) + await #expect(throws: SecureCodecError.replay) { + try await SecureCodec().open(packet, as: bob, trustedPeer: alice.record(name: "Alice"), replayGuard: guarder) + } + } + + @Test func rejectsStaleAndWrongRecipient() async throws { + let alice = PeerIdentity(), bob = PeerIdentity(), carol = PeerIdentity() + let stale = try SecureCodec().seal(.heartbeat(transactionID: nil), from: alice, to: bob.record(name: "Bob"), now: .now.addingTimeInterval(-60)) + await #expect(throws: SecureCodecError.stale) { + try await SecureCodec().open(stale, as: bob, trustedPeer: alice.record(name: "Alice"), replayGuard: ReplayGuard(maximumAge: 5)) + } + let packet = try SecureCodec().seal(.heartbeat(transactionID: nil), from: alice, to: bob.record(name: "Bob")) + await #expect(throws: SecureCodecError.wrongRecipient) { + try await SecureCodec().open(packet, as: carol, trustedPeer: alice.record(name: "Alice"), replayGuard: ReplayGuard()) + } + } + + @Test func rejectsTampering() async throws { + let alice = PeerIdentity(), bob = PeerIdentity() + let packet = try SecureCodec().seal(.heartbeat(transactionID: nil), from: alice, to: bob.record(name: "Bob")) + let tampered = SecurePacket(version: packet.version, senderID: packet.senderID, recipientID: packet.recipientID, + sentAt: packet.sentAt, nonce: packet.nonce, ciphertext: packet.ciphertext + Data([0]), signature: packet.signature) + await #expect(throws: SecureCodecError.invalidSignature) { + try await SecureCodec().open(tampered, as: bob, trustedPeer: alice.record(name: "Alice"), replayGuard: ReplayGuard()) + } + } + + @Test func enrollmentCodeMatchesOnBothPeers() { + let a = Enrollment.makeOffer(identity: PeerIdentity(), name: "A") + let b = Enrollment.makeOffer(identity: PeerIdentity(), name: "B") + #expect(Enrollment.verificationCode(local: a, remote: b) == Enrollment.verificationCode(local: b, remote: a)) + #expect(Enrollment.verificationCode(local: a, remote: b).count == 6) + let renamed = EnrollmentOffer(peer: PeerRecord(id: b.peer.id, name: "Other", signingPublicKey: b.peer.signingPublicKey, + agreementPublicKey: b.peer.agreementPublicKey), challenge: b.challenge) + #expect(Enrollment.verificationCode(local: a, remote: b) != Enrollment.verificationCode(local: a, remote: renamed)) + let changedAgreement = EnrollmentOffer(peer: PeerRecord(id: b.peer.id, name: b.peer.name, signingPublicKey: b.peer.signingPublicKey, + agreementPublicKey: PeerIdentity().agreementKey.publicKey.rawRepresentation), challenge: b.challenge) + #expect(Enrollment.verificationCode(local: a, remote: b) != Enrollment.verificationCode(local: a, remote: changedAgreement)) + let changedChallenge = EnrollmentOffer(peer: b.peer, challenge: b.challenge + Data([0])) + #expect(Enrollment.verificationCode(local: a, remote: b) != Enrollment.verificationCode(local: a, remote: changedChallenge)) + } + + @Test func enrollmentPossessionProofIsTranscriptBound() throws { + let requester = PeerIdentity(), responder = PeerIdentity() + let request = EnrollmentProofRequest( + requester: Enrollment.makeOffer(identity: requester, name: "Requester"), + responder: Enrollment.makeOffer(identity: responder, name: "Responder"), + nonce: UUID(), endpointBinding: "127.0.0.1:4242") + let proof = try Enrollment.signProof(request, identity: responder) + #expect(Enrollment.verifyProof(proof, request: request)) + let redirected = EnrollmentProofRequest(requester: request.requester, responder: request.responder, + nonce: request.nonce, endpointBinding: "127.0.0.1:4343") + #expect(!Enrollment.verifyProof(proof, request: redirected)) + let replayed = EnrollmentProofRequest(requester: request.requester, responder: request.responder, + nonce: UUID(), endpointBinding: request.endpointBinding) + #expect(!Enrollment.verifyProof(proof, request: replayed)) + } + + @Test func trustStoreEnforcesPeerLimitAndIdentityPinning() async throws { + let store = TrustStore() + let one = PeerIdentity(), two = PeerIdentity(), three = PeerIdentity() + try await store.trust(one.record(name: "One")); try await store.trust(two.record(name: "Two")) + await #expect(throws: TrustError.peerLimitReached) { try await store.trust(three.record(name: "Three")) } + let impostor = PeerRecord(id: one.id, name: "One", signingPublicKey: three.signingKey.publicKey.rawRepresentation, + agreementPublicKey: three.agreementKey.publicKey.rawRepresentation) + await #expect(throws: TrustError.identityChanged) { try await store.trust(impostor) } + } + + @Test func framingRoundTripAndMalformedLength() throws { + let a = PeerIdentity(), b = PeerIdentity() + let packet = try SecureCodec().seal(.heartbeat(transactionID: nil), from: a, to: b.record(name: "B")) + let frame = try PeerFraming.frame(packet) + #expect(try PeerFraming.decode(frame) == packet) + #expect(throws: TransportError.self) { try PeerFraming.decode(Data([0, 0, 0, 20, 1])) } + } + + @Test func enrollmentOfferWireRoundTrip() throws { + let offer = Enrollment.makeOffer(identity: PeerIdentity(), name: "Nearby Mac") + let frame = try PeerFraming.frameWire(.enrollmentRequest(offer)) + guard case .enrollmentRequest(let decoded) = try PeerFraming.decodeWire(frame) else { + Issue.record("Expected enrollment request"); return + } + #expect(decoded == offer) + #expect(throws: TransportError.invalidFrame) { try PeerFraming.decode(frame) } + } + + @Test func rejectsOversizedPreTrustFrame() { + let huge = EnrollmentOffer(peer: PeerIdentity().record(name: String(repeating: "x", count: PeerConnection.maximumPacketSize)), challenge: Data()) + #expect(throws: TransportError.oversizedFrame) { try PeerFraming.frameWire(.enrollmentRequest(huge)) } + } + + @Test func peerServiceCanRestartWithoutClosingMessageStream() async throws { + let service = PeerService(identity: PeerIdentity(), displayName: "Restart Test \(UUID())", + trustStore: KeychainTrustStore(service: "KeybridgeTests.\(UUID())")) + try await service.start() + await service.stop() + try await service.start() + await service.stop() + } + + @Test func initialListenerFailureCanRetryCleanly() async throws { + enum Expected: Error { case readinessFailed } + final class ReadinessState: @unchecked Sendable { + let lock = NSLock(); var calls = 0 + func awaitReady(_ listener: PeerListener) async throws { + let call = lock.withLock { calls += 1; return calls } + if call == 1 { throw Expected.readinessFailed } + } + } + let readiness = ReadinessState() + let service = PeerService(identity: PeerIdentity(), displayName: "Initial failure \(UUID())", + trustStore: KeychainTrustStore(service: "KeybridgeTests.\(UUID())"), + listenerReadiness: readiness.awaitReady) + await #expect(throws: Expected.self) { try await service.start() } + try await service.start() + await service.stop() + } + + @Test func initialListenerTimeoutCanRetryCleanly() async throws { + final class ReadinessState: @unchecked Sendable { + let lock = NSLock(); var calls = 0 + func awaitReady(_ listener: PeerListener) async throws { + let call = lock.withLock { calls += 1; return calls } + if call == 1 { try await Task.sleep(for: .seconds(60)) } + } + } + let readiness = ReadinessState() + let service = PeerService(identity: PeerIdentity(), displayName: "Initial timeout \(UUID())", + trustStore: KeychainTrustStore(service: "KeybridgeTests.\(UUID())"), + listenerReadiness: readiness.awaitReady, + listenerReadinessTimeout: .milliseconds(25)) + await #expect(throws: PeerServiceError.self) { try await service.start() } + try await service.start() + await service.stop() + } + + @Test func renameDuringProvisionalStartupFailsWithoutMutatingEnrollmentName() async throws { + actor ReadinessGate { + private var entered = false + private var released = false + private var continuation: CheckedContinuation? + + func awaitReady(_ listener: PeerListener) async throws { + entered = true + if released { return } + await withCheckedContinuation { continuation = $0 } + } + + func hasEntered() -> Bool { entered } + func release() { + released = true + continuation?.resume() + continuation = nil + } + } + let originalName = "Provisional-\(UUID())" + let gate = ReadinessGate() + let service = PeerService(identity: PeerIdentity(), displayName: originalName, + trustStore: KeychainTrustStore(service: "KeybridgeTests.\(UUID())"), + listenerReadiness: gate.awaitReady) + let startup = Task { try await service.start() } + while !(await gate.hasEntered()) { try await Task.sleep(for: .milliseconds(1)) } + + await #expect(throws: PeerServiceError.self) { try await service.updateDisplayName("Must not commit") } + #expect((await service.localEnrollmentOffer()).peer.name == originalName) + + await gate.release() + try await startup.value + #expect((await service.localEnrollmentOffer()).peer.name == originalName) + await service.stop() + } + + @Test func displayNameReplacementFailureLeavesExistingServiceRestartable() async throws { + enum Expected: Error { case replacementFailed } + final class FactoryState: @unchecked Sendable { + let lock = NSLock(); var calls = 0 + func make(_ name: String) throws -> PeerListener { + lock.lock(); defer { lock.unlock() } + calls += 1 + if calls == 2 { throw Expected.replacementFailed } + return try PeerListener(advertisedName: name) + } + } + let factory = FactoryState() + let service = PeerService(identity: PeerIdentity(), displayName: "Original-\(UUID())", + trustStore: KeychainTrustStore(service: "KeybridgeTests.\(UUID())"), + listenerFactory: factory.make) + try await service.start() + let originalOffer = await service.localEnrollmentOffer() + await #expect(throws: Expected.self) { try await service.updateDisplayName("Rejected name") } + #expect(await service.localEnrollmentOffer() == originalOffer) + // A failed rename must not poison the started-state guard or require process restart. + await service.stop() + try await service.start() + await service.stop() + } + + @Test func asynchronousRenameFailurePreservesOldListenerAndOffer() async throws { + enum Expected: Error { case readinessFailed } + final class ReadinessState: @unchecked Sendable { + let lock = NSLock(); var calls = 0 + func awaitReady(_ listener: PeerListener) async throws { + let call = lock.withLock { calls += 1; return calls } + if call == 2 { throw Expected.readinessFailed } + } + } + let readiness = ReadinessState() + let service = PeerService(identity: PeerIdentity(), displayName: "Original-\(UUID())", + trustStore: KeychainTrustStore(service: "KeybridgeTests.\(UUID())"), + listenerReadiness: readiness.awaitReady) + try await service.start() + let originalOffer = await service.localEnrollmentOffer() + await #expect(throws: Expected.self) { try await service.updateDisplayName("Rejected name") } + #expect(await service.localEnrollmentOffer() == originalOffer) + // start() remains a harmless no-op because the original listener is still installed. + try await service.start() + await service.stop() + } + + @Test func renameReadinessTimeoutPreservesOldListenerAndRemainsRestartable() async throws { + final class ReadinessState: @unchecked Sendable { + let lock = NSLock(); var calls = 0 + func awaitReady(_ listener: PeerListener) async throws { + let call = lock.withLock { calls += 1; return calls } + if call == 2 { try await Task.sleep(for: .seconds(60)) } + } + } + let readiness = ReadinessState() + let service = PeerService(identity: PeerIdentity(), displayName: "Original-\(UUID())", + trustStore: KeychainTrustStore(service: "KeybridgeTests.\(UUID())"), + listenerReadiness: readiness.awaitReady, + listenerReadinessTimeout: .milliseconds(25)) + try await service.start() + let originalOffer = await service.localEnrollmentOffer() + await #expect(throws: PeerServiceError.self) { try await service.updateDisplayName("Timed out") } + #expect(await service.localEnrollmentOffer() == originalOffer) + await service.stop() + try await service.start() + await service.stop() + } + + @Test func explicitRefreshCannotPublishAcrossDiscoveryGenerationOrStop() async throws { + let service = PeerService(identity: PeerIdentity(), displayName: "Race-\(UUID())", + trustStore: KeychainTrustStore(service: "KeybridgeTests.\(UUID())")) + let unreachable = DiscoveredPeer(name: "Delayed", endpoint: .hostPort(host: "192.0.2.1", port: 9)) + await service.updateDiscovery([unreachable]) + let refresh = Task { await service.refreshEnrollmentOffers(timeout: .milliseconds(200)) } + try await Task.sleep(for: .milliseconds(20)) + await service.updateDiscovery([]) + await service.stop() + await refresh.value + #expect(await service.discoveredEnrollmentOffers().isEmpty) + #expect((await service.enrollmentRefreshDiagnostics()).generation == 3) + } + + @Test func discoveryChurnCoalescesEnrollmentRefreshAndStopCancelsIt() async throws { + let service = PeerService(identity: PeerIdentity(), displayName: "Churn-\(UUID())", + trustStore: KeychainTrustStore(service: "KeybridgeTests.\(UUID())")) + for _ in 0..<100 { await service.updateDiscovery([]) } + try await Task.sleep(for: .milliseconds(250)) + let afterChurn = await service.enrollmentRefreshDiagnostics() + #expect(afterChurn.generation == 100) + #expect(afterChurn.launches == 1) + #expect(!afterChurn.scheduled) + await service.updateDiscovery([]) + await service.stop() + try await Task.sleep(for: .milliseconds(150)) + let afterStop = await service.enrollmentRefreshDiagnostics() + #expect(afterStop.generation == 102) + #expect(afterStop.launches == 1) + #expect(!afterStop.scheduled) + } + + @Test func enrollmentCannotTrustAnOfferThatWasNotCachedFromAnEndpoint() async throws { + let localIdentity = PeerIdentity() + let service = PeerService(identity: localIdentity, displayName: "Local", + trustStore: KeychainTrustStore(service: "KeybridgeTests.\(UUID())")) + let local = await service.localEnrollmentOffer() + let remote = Enrollment.makeOffer(identity: PeerIdentity(), name: "Unseen") + let code = Enrollment.verificationCode(local: local, remote: remote) + await #expect(throws: PeerServiceError.self) { + try await service.confirmEnrollment(local: local, remote: remote, displayedCode: code) + } + #expect(try await service.trustedPeers().isEmpty) + } + + + @Test(.timeLimit(.minutes(1))) func twoFreshPeerServicesEnrollAndExchangeAuthenticatedMessage() async throws { + let suffix = UUID().uuidString + let aliceID = PeerIdentity(), bobID = PeerIdentity() + let alice = PeerService(identity: aliceID, displayName: "Alice-\(suffix)", + trustStore: KeychainTrustStore(service: "KeybridgeTests.Alice.\(suffix)")) + let bob = PeerService(identity: bobID, displayName: "Bob-\(suffix)", + trustStore: KeychainTrustStore(service: "KeybridgeTests.Bob.\(suffix)")) + try await alice.start(); try await bob.start() + defer { Task { await alice.stop(); await bob.stop() } } + + func candidate(_ service: PeerService, id: UUID) async throws -> EnrollmentOffer { + for _ in 0..<40 { + await service.refreshEnrollmentOffers(timeout: .seconds(1)) + if let offer = await service.discoveredEnrollmentOffers().first(where: { $0.peer.id == id }) { return offer } + try await Task.sleep(for: .milliseconds(250)) + } + throw PeerServiceError.enrollmentCandidateUnavailable + } + + let bobOffer = try await candidate(alice, id: bobID.id) + let aliceOffer = try await candidate(bob, id: aliceID.id) + let aliceLocal = await alice.localEnrollmentOffer() + let bobLocal = await bob.localEnrollmentOffer() + try await alice.confirmEnrollment(local: aliceLocal, remote: bobOffer, + displayedCode: Enrollment.verificationCode(local: aliceLocal, remote: bobOffer)) + try await bob.confirmEnrollment(local: bobLocal, remote: aliceOffer, + displayedCode: Enrollment.verificationCode(local: bobLocal, remote: aliceOffer)) + #expect(try await alice.trustedPeers().map(\.id) == [bobID.id]) + #expect(try await bob.trustedPeers().map(\.id) == [aliceID.id]) + + // Refresh authenticates the newly pinned routes, then a normal secure packet must traverse + // the same real listener/browser/connection stack and be emitted only after verification. + await alice.refreshEnrollmentOffers(timeout: .seconds(1)) + await bob.refreshEnrollmentOffers(timeout: .seconds(1)) + let transactionID = UUID() + try await alice.send(.heartbeat(transactionID: transactionID), to: bobID.id, timeout: .seconds(3)) + let received = try await withThrowingTaskGroup(of: ReceivedPeerMessage.self) { group in + group.addTask { + for await message in bob.messages where message.peer.id == aliceID.id { + if message.message == .heartbeat(transactionID: transactionID) { return message } + } + throw PeerServiceError.deadlineExceeded + } + group.addTask { try await Task.sleep(for: .seconds(3)); throw PeerServiceError.deadlineExceeded } + let value = try await group.next()!; group.cancelAll(); return value + } + #expect(received.message == .heartbeat(transactionID: transactionID)) + } +}