diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a32228f..94671b8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,10 +23,6 @@ permissions: jobs: release: - # NOTE: the MLX/Gemma 4 dependency (gemma-4-swift-mlx → mlx-swift-lm) only compiles on a recent - # toolchain: Xcode 26.3 (Swift 6.3.x) FAILS to type-check it (LoRA/TurboQuant), while Xcode 26.5 - # (Swift 6.3.2) builds it cleanly. macos-15 tops out at Xcode 26.3, so the release runs on macos-26 - # (which ships 26.5) and the step below selects 26.5 explicitly. runs-on: macos-26 env: PRODUCT: ThreeFingerSwitcher @@ -36,11 +32,8 @@ jobs: - name: Select Xcode and gate on toolchain (fail fast) run: | set -euo pipefail - # Prefer the verified-working Xcode 26.5 (Swift 6.3.2 — the toolchain that compiles the - # MLX/Gemma deps; 26.3 does not). Fall back to the newest installed if that exact version is - # ever removed from the image; the Swift gate below still guards against a too-old toolchain. - XC="/Applications/Xcode_26.5.app" - [ -d "$XC" ] || XC="$(ls -d /Applications/Xcode*.app | sort -V | tail -1)" + # Use the newest installed Xcode; the Swift gate below guards against a too-old toolchain. + XC="$(ls -d /Applications/Xcode*.app | sort -V | tail -1)" echo "Using Xcode at: $XC" sudo xcode-select -s "$XC/Contents/Developer" swift --version diff --git a/CLAUDE.md b/CLAUDE.md index 12ad015..571e861 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,13 +2,15 @@ Orientation lives in **`README.md`** (it's written for an agent: Job A = install/run, Job B = work on the code). This file is the short list of things that are easy to get wrong. Read the **Building & signing** rule before you build anything. +> **The great cleanup (`remove-local-ai`, `remove-device-link`):** the on-device AI stack (Gemma/MLX, the AI command band + canvas, background agents, the notch timeline, voice/computer-use), the Files band, and the iPhone↔Mac device link (QR pairing, the local-network clipboard/file bridge, the vendored `DeviceLinkKit` package, the Hub Devices page) were **removed** — the app is refocused on the **switcher, the launcher, and clipboard history**. The `v1` branch / `v1.0.0` release preserve the full-featured app; don't reintroduce those features casually. + ## Building & signing — read this first **Do not assemble or install the `.app` from the agent's shell.** The sandboxed shell has **no keychain access**, so `scripts/build-app.sh` falls back to **ad-hoc signing**. Ad-hoc signing changes the app's code identity (CDHash) on every build, which **silently invalidates the macOS TCC permission grants** the app depends on — Accessibility, Input Monitoring, Screen Recording. The result: the app launches but quietly does nothing (no gesture capture, no thumbnails), which looks like a bug but is really a broken signature. An agent-built `.app` also **collides with the user's own stable-signed install** at the same path. So the division of labor is: -- **Agent does:** edit code, and verify with **`swift build`** / **`swift test`** (the MLX-free `ThreeFingerSwitcherCore` + test target) and, for the MLX-linked `GemmaRuntime`/app target, **`xcodebuild`** to *compile-verify only*. These compile and run logic — they don't sign, install, or launch the app, so they're safe and useful. To compile-check a *subset* of the tree in isolation (e.g. one feature without another's uncommitted files), use a throwaway **`git worktree`** and `swift build` there — never the shared working tree's `.app`. +- **Agent does:** edit code, and verify with **`swift build`** / **`swift test`** (the `ThreeFingerSwitcherCore` library + test target hold ALL app logic). These compile and run logic — they don't sign, install, or launch the app, so they're safe and useful. To compile-check a *subset* of the tree in isolation (e.g. one feature without another's uncommitted files), use a throwaway **`git worktree`** and `swift build` there — never the shared working tree's `.app`. - **User does (in their own Terminal):** the real build for any in-app or permission testing — ```bash INSTALL=1 ./scripts/build-app.sh # stable-signed, installed in place to /Applications @@ -18,63 +20,58 @@ So the division of labor is: **Releases are never built locally.** Pushing a `vX.Y.Z` git tag triggers `.github/workflows/release.yml`, which builds, **Developer-ID-signs + notarizes + staples**, and publishes a DMG to GitHub Releases (see `docs/RELEASING.md`). Don't try to notarize or Developer-ID-sign from the agent shell — that's the CI runner's job, and it has the secrets. -## On-device AI (the AI Command Band) — build & landmines +## Error handling — one taxonomy per domain, mapped at the boundary, surfaced bounded + non-blocking -The AI band runs **Gemma 4 in-process via MLX**. Two targets, on purpose: **`ThreeFingerSwitcherCore` stays MLX-free** (the `LLMRuntime` seam + a `StubLLMRuntime`/`DevAIRuntime`, the executor, tasks, selection, canvas — all verify under `swift build`/`swift test`); the real model lives in **`GemmaRuntime`**, which links MLX and therefore builds via **`xcodebuild` only** (MLX compiles Metal shaders — one-time `xcodebuild -downloadComponent MetalToolchain`). The app injects the real runtime at the seam in `main.swift`. +New failure-surfacing code inherits this convention (established by the archived `harden-ai-error-handling` change and kept after the AI removal): -- **The metallib landmine:** MLX ships `default.metallib` as a SwiftPM resource bundle (`mlx-swift_Cmlx.bundle`). `build-app.sh` **must copy `*.bundle` into `Contents/Resources/`** — if it doesn't, the app launches but is **SIGKILL'd at first GPU use with no crash report**. This is already handled; don't regress it. -- **Errors: one taxonomy, one translator, mapped at the boundary, surfaced bounded + non-blocking.** New AI code inherits this convention (see the `harden-ai-error-handling` change): - - **One taxonomy:** classify every AI failure into the shared `RuntimeError` (Core), which conforms to `LocalizedError` with a clean per-case string. **One translator:** `AIError.message(for:) -> AIPresentedError` (a clean `headline` + opt-in copyable `details`) is the SINGLE error→message function — every surface (Settings row, canvas, any alert) routes through it, so the same error reads identically everywhere. - - **Ban raw interpolation in UI strings:** never put `"\(error)"` / `String(describing: error)` / an OS error's `.localizedDescription` into a headline. Raw text is allowed only in logs and as `AIPresentedError.details`. - - **Map at the layer boundary:** convert vendor/OS errors (`Gemma4DownloadError`, `NSURLError`, EventKit, `FileManager`/`Process`) into the taxonomy where they cross into app code (e.g. `GemmaMLXRuntime.prepare`, the sinks) — Core stays MLX-free, so it can't see vendor types; only `RuntimeError`/`TaskError` cross into feature/UI code. - - **Failure is observable state, never silence:** a failure transitions to a `.failed` state carrying the clean headline (never leaves a state stuck mid-flight; cancellation is not a failure). A side effect that didn't land (write/open/paste/task) becomes `.failed`, never a false "Done." - - **Bounded + non-blocking UI:** never surface a background AI failure via app-modal `NSAlert.runModal()` (it freezes the Settings window) — use the in-window `.failed` row + Retry. Cap long messages (`.lineLimit` + `.truncationMode(.middle)`), put raw text behind a "Show details / Copy" disclosure, and keep layouts scroll-safe. -- **Swipe-to-resolve, not lift-to-commit:** while the preview canvas is open it's resolved by a *fresh **two-finger** swipe* — **down = commit/apply, horizontal = discard, up = ignored**; a stray re-lift is a no-op (the firing lift already raised the fingers). *(Changed from four-finger by `positional-navigation`: the grammar is now **4 fingers open/dismiss the platform, 2 fingers act within it**. The resolve excursion threshold sits **above incidental two-finger scroll** — `canvasResolveThreshold` — so reading the canvas never resolves it.)* -- **Vision is supported** (Gemma 4 is multimodal via MLX; the `LLMRuntime` seam carries an optional `image:` and the registry advertises `.vision`). An image input comes from a **captured screen region** (`.screenRegion`) or the **live clipboard image** (`.clipboardImage`, on-demand — copying an image never auto-fires); both statically require a vision-capable model. A `clipboardImage` read uses the live pasteboard (symmetric with the clipboard-text read, normalized to PNG), not `ClipboardStore`. The model is **Apple-Silicon-only** (no Intel/low-end fallback). The seam exists so another backend can replace Gemma without touching feature code. *(The interactive screen-region picker — drag a rectangle instead of grabbing the whole display — is the `add-region-capture-picker` change.)* +- **One taxonomy per domain** (e.g. `DockPreviewError`, `ClaudeLaunchError`): a small Core `LocalizedError` enum with a clean per-case string. +- **Ban raw interpolation in UI strings:** never put `"\(error)"` / `String(describing: error)` / an OS error's `.localizedDescription` into a headline. Raw text is allowed only in logs and as opt-in copyable details. +- **Map at the layer boundary:** convert vendor/OS errors (`NSURLError`, `FileManager`/`Process`) into the domain taxonomy where they cross into app code; only typed errors cross into feature/UI code. +- **Failure is observable state, never silence:** a failure transitions to a `.failed` state carrying the clean headline (never leaves a state stuck mid-flight; cancellation is not a failure). A side effect that didn't land becomes `.failed`, never a false "Done." +- **Bounded + non-blocking UI:** never surface a background failure via app-modal `NSAlert.runModal()` (it freezes the window) — use an in-window `.failed` row/card + Retry. Cap long messages (`.lineLimit` + `.truncationMode(.middle)`), put raw text behind a "Show details / Copy" disclosure, and keep layouts scroll-safe. -## SwiftUI "liveness" animations — the idle-CPU-spin landmine +## Progressive-degradation guardrails (change `fix-progressive-cpu-degradation`) -A repeating `TimelineView(.periodic)` / "breathing" animation hosted in an `NSHostingView` **keeps ticking after its window is hidden** — `orderOut` does NOT stop the SwiftUI animation clock, and neither does wrapping it in `.opacity(0)` or `if isActive { … }`. In a window kept alive by **`isReleasedWhenClosed = false`** (the Hub, the first-touch wizard) or an autonomously-shown panel (the now-removed notch needs-you glow), each tick drives a **non-converging Auto-Layout ⇄ render ⇄ Observation loop** that **pins the main thread at ~100% forever** — which starves the main-thread gesture→switcher path and reads as "the switcher is slow after a break," clearing only on restart. Full repro + stack: **`docs/postmortem-idle-cpu-spin.md`**. **Rule:** gate every repeating animation on **real window visibility** (`NSWindow.occlusionState` / `didChangeOcclusionStateNotification`, or an explicit active flag the controller sets on show/hide) — **never** on `onAppear`/`onDisappear` (they don't fire for a hidden-but-retained window) — or release the hosting controller on close. The AI-canvas "Thinking" pulse and the notch needs-you glow were **removed** for this reason; the Hub gesture preview was reduced to a **visibility-gated autoplay** (live finger-tracking + the free-running model driver deleted — the Switcher AND Launcher/band miniatures now follow the ghost hand only via the preview's **clockless sync seam**, `GhostSyncPose` frames emitted from inside the gated `TimelineView`, so it owns no timer and a hidden Hub drives nothing; don't "upgrade" it back to a self-ticking driver); the onboarding wizard (`Onboarding/WizardMotion.swift`) uses the same breathers — first-run-only, but the same pattern. +The app once got slower the longer it ran (growing CPU, stale previews, a gesture trigger that "needed to wake up"). The fixes are small and easy to delete by accident — don't: -## The Files band (the in-launcher Finder) — build & landmines +- **`TouchEngine.neutralizeFrameworkSleepWakeObservers()` is load-bearing.** The vendored `OpenMTManager` (binary XCFramework) registers its own sleep/wake observers whose wake handler double-starts multitouch devices — one orphaned, still-running device per sleep/wake cycle, each re-processing every touch frame (N wakes → N+1× processing). The odd-looking ObjC-runtime `removeObserver` at TouchEngine init is the fix; `AppCoordinator` is the sole owner of sleep/wake restart. It fails safe (no-op) if the framework changes. +- **The `AXUIElementSetMessagingTimeout(systemWide, 0.5)` + App Nap opt-out in `AppDelegate` are why the trigger stays instant under load.** AX calls default to a 6 s per-call timeout serviced by the *target* app's main thread, and a napped LSUIElement accessory is demoted exactly when other apps are busy. Keep the activity option `.userInitiatedAllowingIdleSystemSleep` — plain `.userInitiated` would keep the Mac from ever sleeping. +- **Idempotent `start()`s + `.removeDuplicates()` on settings sinks.** `@Published` emits on every WRITE; `enable()` re-writes `settings.enabled` internally, and in the no-trackpad case that ping-ponged forever, stacking `NSWorkspace` activation observers (`MRUTracker`, `KeyboardLanguageService`). New observer-owning services must guard `start()` like `WindowFocusTracker` does. +- **One sweep, one launcher graph, LRU + prune.** `ThumbnailService.prefetch` is single-slot (skip-if-busy, cancelled on overlay hide) — don't re-add fire-and-forget sweep Tasks; its cache is LRU and pruned to the snapshot's live ids on each open. `LauncherOverlayController` reuses ONE `NSHostingView` across its deliberately-disposable panels (the panel destruction is the ghost-on-Space-switch fix; the graph reuse avoids per-gesture SwiftUI construction) — don't move the hosting view back into `makePanel`'s per-call scope. +- **The wizard is destroyed on close, not suspended** (`releaseWizardReferences` + `contentViewController = nil` in the willClose observer) — its `TimelineView` breathers are ungated, so a retained tree is the postmortem spin. If you make the wizard window reusable, gate the breathers like the Hub first. +- **Never read `is…Effective` / `isFree` / `isClaimed` on the gesture path.** The native-gesture configs (`VerticalGestureConfig`, `FourFingerGestureConfig`, …) answer those by **spawning `/usr/bin/defaults`** (fork + waitpid, 30–100 ms). `openSwitcher` reads `recognizer.rowSwitchingEnabled` (the gate `refreshRowSwitchingGate()` computed when the opt-in last changed) — keep it that way. Same rule for anything else that forks, hits disk, or walks a full payload: off the gesture path, cached for it (`IconCache`, `ClipboardStore.boundedCache`, `FirstRunStore`'s stage mirror, `StageManager`'s TTL). +- **`CGWindowID(NSWindow.windowNumber)` traps.** `windowNumber` is ≤ 0 for a window with no window device (the retained, closed Hub), and `CGWindowID(Int)` aborts on a negative. Use `AppCoordinator.hubWindowID` / guard `> 0` — this crashed every switcher commit after the Hub had been opened once. +- **No `as! AXUIElement` / `as! AXValue` on Accessibility reads.** `axCopy` is untyped and a misbehaving app's AX server can answer with any CF type; use `axElement(_:_:)` / `axValue(_:_:)` (type-checked) from `AXPrivate.swift`. +- **The scroll tap's finger count is time-bounded.** `currentFingerCount` is reset wherever the touch engine stops AND treated as 0 once `lastTouchFrameTime` is > 0.5 s old — a sleep with three fingers down used to leave the tap swallowing every scroll in every app until quit. `missionControlOpen` is likewise cleared on regular-app activation / Space change / sleep / `hideOverlay` (stale-true posted a stray Escape into the user's app on every commit). +- **Deferred actions carry a token.** Every `asyncAfter` / retry chain on the commit or Space-switch path checks `commitSeq` / a generation counter before acting (`afterSpaceSettles`, `raiseDeminimizing`, `recover`, the MC-dismiss deferral, the seed-retry sweeps). A new deferred step without one re-introduces "the previous target steals focus back". -The Files band (`files-band`) is a **local-only Finder** that lives as a synthetic launcher band: land on it and you're in a bounded **column navigator** (icon-rail ancestors + current folder list + live preview). It's all in **MLX-free Core** (`Sources/ThreeFingerSwitcher/Files/` + `Overlay/FilesBandView.swift` + `Overlay/BubbleMorph.swift` + `Hub/HubFilesPage.swift`), so it verifies under `swift build` / `swift test`. Opt-in, default off; no gesture relocation, no re-login, no new permission (it reads the filesystem on demand — like `keepClipboardHistory`). +## SwiftUI "liveness" animations — the idle-CPU-spin landmine -- **Synthetic band, NOT a band-type enum.** Like the Clipboard band, it's recognized by a sentinel `FilesBandBuilder.bandID` + a threaded `filesBandIndex`, switched on `LauncherModel.currentBandIsFiles`. Its items are an **ephemeral `.fileEntry(FileEntry)`** kind (never persisted, like `.clipboardEntry` — it *is* `Codable` only because the enclosing `LaunchItemKind` is); the band is appended to a local copy in `AppCoordinator.launcherDidActivate()` and **never written to `FavoritesStore`**. `FileEntry.id` is the standardized path; `FilesBandBuilder` derives a deterministic UUID from it for `LaunchItem.id` (stable across re-lists → no highlight strobe). -- **Lift-to-open, NOT swipe-to-resolve.** Resolution is: **lift opens** the highlighted item (file → default app, folder → Finder window) on the current Space; **+1-finger lift** opens the **Open-With picker** (a scrubbable popup of the apps that can open it); a **four-finger horizontal swipe discards** (defuses a *pending* open — it **never terminates a running app**). The AI canvas's swipe-to-resolve (above) exists to let you *review a generated result*; do **not** generalize it to this navigation surface — the spec was corrected after the implementation surfaced that drift. -- **The drill modal sub-state.** While the navigator is open, `GestureRecognizer.filesDrillActive` routes `feed()` to `trackFilesDrill` (a second early short-circuit, mirroring `launcherCanvasResolutionActive`) — finger-count deltas are re-interpreted there. **Open-With is a *relative* +1 finger** (`count > drillContacts`), not an absolute three (you may be holding three the whole time). **Re-baseline the origin on every contact-count change** or a leaving finger fires a phantom step. Resolution is one-shot (`drillResolved`); **any handler that leaves the navigator open must call `recognizer.rearmDrill()`** or navigation goes inert (this is the non-obvious bit — the Open-With picker and the picker-back-out both re-arm). -- **No search — the Files band is pure-trackpad, no keypresses.** Type-to-filter search was removed (it broke the no-keypress model and went unused); an up-step at the top of the column simply **clamps**. There is NO search field, no `@FocusState`, and the overlay panel **never becomes key/main** for the Files band (unlike the AI canvas) — don't reintroduce a key-interactive flip here. `FilesNavigationModel.visibleEntries` is retained as the column read-seam but is always the unfiltered `entries`. -- **The remember-last-folder toggle gates restore at BOTH init and `enterRoot`.** `FilesNavigationModel.restoreLastLocation` is a stored property: when OFF the band opens on the roots list AND descending into a root lands on the root's **top level** (never the remembered deep folder); when ON it opens displaying / re-enters the remembered location. Deepest-location *tracking* runs regardless of the flag (so flipping it ON later restores). The bug to avoid: gating only the init landing — that let `enterRoot` jump to the last-visited folder even with the toggle off (a visual/state desync). -- **The sync/async cache seam.** `FilesNavigationModel` is a **pure, synchronous** state machine; the real `DirectoryLister` is **async / off-main**. `FilesColumnController` bridges them with a listing **cache**: the model's sync lister reads `cache[path] ?? []`; a miss spawns a coalesced off-main listing that stores, re-feeds the column, and republishes. The folder-peek preview flows through the same cache. **Don't make the pure model touch `FileManager`.** -- **BubbleMorph — the first spring, on containers only.** `Overlay/BubbleMorph.swift` (scale-from-0.02 + opacity on a soft spring) animates columns / rows / preview / menus; the **single sliding `FilesRowHighlight` is NOT bubble-morphed** (per-row morphs reintroduce the documented scrub strobe). Depth uses the `SwitcherView` `.id`/`.transition` idiom but **scaling, not sliding**. Don't retrofit the charge ramp / arm snap to a spring, and add **no new haptics**. -- **Errors map at the boundary into `FileActionError`** (a Core `LocalizedError` taxonomy parallel to `RuntimeError`); a failed open surfaces as a **bounded, non-blocking** card (clean headline + opt-in copyable details + Retry/Dismiss) over the navigator — **never** an `NSAlert`, never raw error text in a headline. -- **Local-only, navigate-and-open only (v1).** No file ops (move/rename/delete/tag), no iCloud/network. The `FileWorkspace`/provider seam is built so other providers could come later. -- **Deferred (don't "fix" casually):** the *whole-panel* recede-on-leave teardown. Per-element transitions recede inside the live panel, but the panel's `orderOut`+`close` stays **synchronous** — deferring it behind an exit animation re-opens the documented **ghost-on-Space-switch** bug. If you wire a receding exit, you must NOT break the synchronous teardown for a Space-switching open. +A repeating `TimelineView(.periodic)` / "breathing" animation hosted in an `NSHostingView` **keeps ticking after its window is hidden** — `orderOut` does NOT stop the SwiftUI animation clock, and neither does wrapping it in `.opacity(0)` or `if isActive { … }`. In a window kept alive by **`isReleasedWhenClosed = false`** (the Hub, the first-touch wizard), each tick drives a **non-converging Auto-Layout ⇄ render ⇄ Observation loop** that **pins the main thread at ~100% forever** — which starves the main-thread gesture→switcher path and reads as "the switcher is slow after a break," clearing only on restart. Full repro + stack: **`docs/postmortem-idle-cpu-spin.md`**. **Rule:** gate every repeating animation on **real window visibility** (`NSWindow.occlusionState` / `didChangeOcclusionStateNotification`, or an explicit active flag the controller sets on show/hide) — **never** on `onAppear`/`onDisappear` (they don't fire for a hidden-but-retained window) — or release the hosting controller on close. The Hub gesture preview is a **visibility-gated autoplay** (live finger-tracking + the free-running model driver deleted — the Switcher AND Launcher/band miniatures follow the ghost hand only via the preview's **clockless sync seam**, `GhostSyncPose` frames emitted from inside the gated `TimelineView`, so it owns no timer and a hidden Hub drives nothing; don't "upgrade" it back to a self-ticking driver); the onboarding wizard (`Onboarding/WizardMotion.swift`) uses the same breathers — first-run-only, but the same pattern. ## The Dock window previews (the switcher "from another angle") — build & landmines -The Dock-preview feature (`dock-window-previews`) is the switcher reached by **mouse**, not trackpad: hover an app's Dock tile → a row of that app's **current-Space** windows (normal **and minimized**) pops above the tile; hover a thumbnail to **peek its live content**; click to raise it. Opt-in, default off (`showDockPreviews`); no gesture relocation, no re-login, **no new permission** (reuses the already-granted Accessibility + Screen Recording). All of it is MLX-free Core (`Sources/ThreeFingerSwitcher/Dock/` + `Overlay/DockPreviewOverlay.swift`), so it verifies under `swift build`/`swift test`; only the live AX/cursor behavior needs the real app. +The Dock-preview feature (`dock-window-previews`) is the switcher reached by **mouse**, not trackpad: hover an app's Dock tile → a row of that app's **current-Space** windows (normal **and minimized**) pops above the tile; hover a thumbnail to **peek its live content**; click to raise it. Opt-in, default off (`showDockPreviews`); no gesture relocation, no re-login, **no new permission** (reuses the already-granted Accessibility + Screen Recording). All of it is Core (`Sources/ThreeFingerSwitcher/Dock/` + `Overlay/DockPreviewOverlay.swift`), so it verifies under `swift build`/`swift test`; only the live AX/cursor behavior needs the real app. - **Overlay on the REAL Dock — never build a Dock.** `Dock.app` has no hover event and no plugin surface, so hover is *inferred*: `AXDockReader` reads `Dock.app`'s AX tree for app-tile frames (`AXApplicationDockItem` only — folders/Trash/Downloads/separators/**minimized-window tiles** are filtered out; minimized windows come from the app's own enumeration, NOT the Dock's minimized-window items), and a passive global `.mouseMoved` monitor (`GlobalCursorMonitor`, no Input Monitoring needed) feeds the cursor. Don't try to extend or replace the system Dock. -- **The first cursor-first, mouse-INTERACTIVE overlay.** Every other overlay sets `ignoresMouseEvents = true`; `DockPreviewOverlayController` is the lone exception — it sets `ignoresMouseEvents = false` + `acceptsMouseMovedEvents = true` so thumbnails take hover/click. It stays a `.nonactivatingPanel` and **never** becomes key/main (no keyboard), so it never steals focus (the previously focused window stays the raise target). Teardown is **synchronous** (`orderOut`) — the files-band ghost-on-Space-switch landmine applies here too. The popup anchors in the gap between tile and content so a native Dock-icon click still falls through to the system. +- **The first cursor-first, mouse-INTERACTIVE overlay.** Every other overlay sets `ignoresMouseEvents = true`; `DockPreviewOverlayController` is the lone exception — it sets `ignoresMouseEvents = false` + `acceptsMouseMovedEvents = true` so thumbnails take hover/click. It stays a `.nonactivatingPanel` and **never** becomes key/main (no keyboard), so it never steals focus (the previously focused window stays the raise target). Teardown is **synchronous** (`orderOut`) — the ghost-on-Space-switch landmine applies here too. The popup anchors in the gap between tile and content so a native Dock-icon click still falls through to the system. - **Peek = front the REAL window, then restore it (the macOS-forced model).** macOS won't render fresh pixels for an off-screen window, so an in-popup *projection* of an occluded/minimized window is stale/icon-only — the only true live preview is to bring the real window to the front. So hover front-raises via `WindowService.peekRaise`, which must **genuinely front the window so its app renders it live** for capture — two parts, both load-bearing: (1) the **SkyLight `setFront` handshake** (`_SLPSSetFrontProcessWithOptions` + `makeKeyWindow`, the AltTab idiom) reliably activates a **background app** in one shot — `kAXRaise` + `activate()` alone often leaves a background app un-activated (which is why the *commit* path needs a watchdog to retry until it sticks), so its windows stay throttled and the capture comes back **stale** (the bug: a peek's live preview only worked for the app already in front); (2) `kAXMain` + the app's `kAXFocusedWindow` make THIS window the app's focused one (an app with several windows otherwise keeps drawing its previously-focused window). It's still lightweight + reversible otherwise (**no** focus-history promotion, **no** watchdog/hold-guard — those fight the put-back), and it **skips both the SkyLight handshake and the singletons under Stage Manager** (they make WindowManager's stage arbiter oscillate — the switcher landmine; a peek there falls back to raise + activate). NB: a peek now performs a *real* app switch on hover (menu bar follows) — that's intrinsic to getting live pixels from a background app; restore on leave switches back. The previously-front window is captured at session start (`WindowService.frontmostWindow()`) and restored on leave-without-commit; a **click** commits via the hardened `raiseDeminimizing` (un-minimize then `raise()`) and skips restore. **Minimized** windows are NOT fronted to peek (would need de-minimizing) — they surface only on commit. Raise on hover-**enter** only (not per tick) to bound z-order churn; current-Space-only keeps it on the cheap AX raise path. **This REVERSES the original "project, never raise" decision** (`design.md` D2) — don't switch it back to in-card projection thinking it's safer; it just shows icons. The **tabs** keep the switcher's cache-first / last-good-frame safety (`isDegradedCapture`/`isStripProxy`) so they never show a sideways proxy; a peeked (fronted) window yields a clean tab frame that persists. **The hovered tab shows a STATIC last-good image — a single one-shot capture after the window settles, NOT a continuous stream / screen recording** (a prior `SCStream`/`WindowLivePreview` version was deliberately reverted). The live view is the *fronted real window itself*; the tab is just a selector. On `peek`, after a **~0.5s settle delay** (`captureDelay`, a cancellable `Task` killed on retarget/dismiss — the window animates forward when fronted and an immediate capture grabs the mid-transition "sideways, coming-from-the-Dock" frame), the controller takes ONE `thumbnails.liveCapture` of the settled window and stores it. Don't reintroduce a per-frame pump or a stream. Each **tab is sized to its window's own aspect ratio** (`DockPreviewLayout.cardWidth(forAspect:)`, fixed height × aspect, clamped); the image then **fills** that aspect-correct card (`.aspectRatio(.fill)` + `.clipped()`). The order matters: the card MUST be aspect-sized FIRST — filling a *fixed-resolution* box mangles a portrait window into a landscape crop (don't do that). Given an aspect-correct card, fill crops only the hairline difference between the AX `realFrame` aspect (which sizes the card) and the captured image's aspect, and — unlike `scaledToFit` — it leaves no letterbox gap and lands every frame edge-to-edge in the same place, so the seed→capture swap neither sits off-center nor jumps to re-center. The capture routes through `thumbnails.inject` → dock cache + dock tab + switcher cache/model (cross-population). Minimized windows aren't captured (can't front → no fresh frame) — they hold their seeded frame and surface on commit. **`openApp` must SEED each tab from `thumbnails.cached(id)` on open** (the switcher's `seed` pattern) — `dismiss` wipes `overlay.model`, and the immediate per-window refresh of still-occluded windows degrades (mac won't capture off-screen), so without the seed the popup re-opens on bare icons and the good cached frame is never shown. The `ThumbnailService` cache (persistent; `dismiss` only calls `endLiveSession`, never `clear`) is the last-good-frame store; the dock controller uses its OWN `ThumbnailService` instance (no live-session contention with the switcher's) but **cross-populates** the switcher's: a peek fronts a window → captures a good frame → the dock `onThumbnail` calls `switcherThumbnails.inject(image, for:)` so the switcher's cache + live model refresh too, and `openApp` seeds tabs from `thumbnails.cached(id) ?? switcherThumbnails.cached(id)` so each surface benefits from the other's captures. (`inject` has no degraded gate — only feed it known-good frames, which the `onThumbnail` path already guarantees.) - **The pure brain is `DockHoverModel`; time is an INPUT.** Hit-test, orientation-aware anchor (bottom = above, left/right = beside, clamped on-screen), and the open/swap/keep/dismiss lifecycle with a unified tile+popup **live zone** + **grace dismiss** are all pure and unit-tested — `feed(...)` takes a `now:` timestamp so grace timing is deterministic. Coordinates are **Cocoa global (bottom-left)**; `AXDockReader` converts AX's top-left space at the boundary so the model never juggles handedness. - **Edge-gated tracking.** While nothing is shown the Dock is read only when the cursor is near a screen edge (`nearDockEdge`); while the popup is open a ~60ms timer re-reads + re-feeds so grace, **magnification** (the anchor re-glues to the growing tile via `reanchor`), and the live peek all advance without depending on move events. The reader caches nothing, so an **auto-hidden** Dock simply reads empty (idle) until it reveals. `DockPreviewController` is gated by the opt-in (`setEnabled`); when off, the monitor isn't even installed. - **Menu parity (change `dock-preview-menu-parity`) — the popup yields to the Dock's own menu.** A passive global `.rightMouseDown` monitor (`GlobalCursorMonitor` — it **never consumes** the event, so the native Dock menu opens unmodified) feeds the pure `DockHoverModel.rightClick(at:tiles:)`; a right-click on a tile **dismisses** the popup (restoring any peeked window) so the native action menu owns the stage. Two reinforcing pieces keep it gone: (a) the per-tick `reanchor` calls `overlay.move(to:)` (**reposition only**) — never `show`/`orderFrontRegardless` — so a menu that opens above the popup stays above it (the old per-tick re-front was exactly what stomped the menu behind it); (b) the controller records the right-clicked tile (`menuSuppressedPID`) and **short-circuits `handleCursor` while the cursor stays on that tile**, so a stray move (the live global `.mouseMoved` monitor is still running) doesn't re-open the popup behind the menu. Suppression clears the instant the cursor leaves the tile (the proxy for "menu interaction over" — there's no cheap signal for the Dock menu actually *closing*; the one edge is Escape-without-moving, which stays suppressed until you leave the tile). - **Left-click on the shown tile commits the highlighted preview (change `commit-dock-preview-on-icon-click`) — the mirror of the right-click rule.** A passive global `.leftMouseDown` monitor (the `CursorMonitor.onLeftDown` seam — same never-consumes contract as the right-click pair; the `.leftMouseDown` infra already exists for window-groups snap) feeds the pure `DockHoverModel.leftClick(at:tiles:) -> pid_t?` (returns the **active** app's pid iff the click hit *its* tile). `handleLeftClick` then commits `overlay.model.highlightedID` via the normal `commit` path (`raiseDeminimizing` + `dismiss(restore: false)`) — **only when a card is actually highlighted (peeked)**. Why it exists: an icon click falls through to the native Dock but never routed through commit, so the popup treated it as "not a commit" and the leave-restore (`dismiss(restore: true)` → re-front the pre-peek window) **silently undid it** — clicking the card stuck, clicking the icon reverted. With no card highlighted, `handleLeftClick` no-ops: native activation stands and nothing was peeked, so there's nothing to restore/undo. **We do NOT (and can't cheaply) consume the click**, so the native Dock *also* activates the app; for a live window the two converge (the peek already made it the app's focused window), for a minimized card our `raiseDeminimizing` de-minimizes the right one (native may not) — the one behavior to confirm on the real signed build. - **Keeping an auto-hide Dock VISIBLE under the popup is infeasible — don't retry.** Investigated exhaustively in `dock-preview-menu-parity` (see its archived `design.md` → "Rejected"): disabling auto-hide (`CoreDockSetAutoHideEnabled(false)`) makes the Dock reserve space and **reflows/shrinks windows**; the Dock **polls the real HID cursor**, so synthetic `.mouseMoved` posted to Dock.app (the reverted `DockRevealKeeper`) is ignored; the full `CoreDock*` surface (HIServices, ~80 syms) has **no "suspend auto-hide"** primitive (only the reflow toggle); the native menu's hold is **modal-menu-tracking** state, inseparable from the visible menu. Only the Dock can hold its own peek. **Landed behavior:** when an auto-hide Dock slides away the popup **freezes in place and stays usable** (graceful, no reflow). Affects only users who auto-hide their Dock. -- **App with no current-Space windows shows NOTHING** (no empty popup) — tracked via `emptyPID` so it doesn't re-enumerate per move. **Minimized windows are included** (the enumeration variant `WindowService.currentSpaceWindows(forApp:)` stops excluding the minimized subrole — the switcher's all-Spaces `snapshot()` is untouched and still excludes them; `WindowInfo.isMinimized` defaults false so the switcher path is unaffected). Errors map to `DockPreviewError` (Core `LocalizedError`, parallel to `FileActionError`) surfaced as a bounded, non-blocking card — never an `NSAlert`, never raw error text in a headline. +- **App with no current-Space windows shows NOTHING** (no empty popup) — tracked via `emptyPID` so it doesn't re-enumerate per move. **Minimized windows are included** (the enumeration variant `WindowService.currentSpaceWindows(forApp:)` stops excluding the minimized subrole — the switcher's all-Spaces `snapshot()` is untouched and still excludes them; `WindowInfo.isMinimized` defaults false so the switcher path is unaffected). Errors map to `DockPreviewError` (a Core `LocalizedError` taxonomy) surfaced as a bounded, non-blocking card — never an `NSAlert`, never raw error text in a headline. ## In-overlay navigation: the odometer (change `restore-odometer-navigation`) — landmines -Post-activation **launcher** and **Files-drill** navigation is the **odometer**: the recognizer accumulates signed centroid travel per axis (`acc += Δcentroid`) and emits one step each time the accumulator crosses the per-axis step distance, **with carry** (`while |acc| ≥ step { acc ∓= step; emit }`). The anchored-positional "joystick" (`PositionalNavigator` / `AxisZone` / `RepeatCadence` / directional axis-lock / the `positional*` tunables / the `Hub/PositionalTrackpadPreview` aim-wedge) was **reverted** — do not reintroduce it. The opening fling and the three-finger **window switcher** were always odometer and are untouched. +Post-activation **launcher** navigation is the **odometer**: the recognizer accumulates signed centroid travel per axis (`acc += Δcentroid`) and emits one step each time the accumulator crosses the per-axis step distance, **with carry** (`while |acc| ≥ step { acc ∓= step; emit }`). The anchored-positional "joystick" (`PositionalNavigator` / `AxisZone` / `RepeatCadence` / directional axis-lock / the `positional*` tunables / the `Hub/PositionalTrackpadPreview` aim-wedge) was **reverted** — do not reintroduce it. The opening fling and the three-finger **window switcher** were always odometer and are untouched. -- **One mechanic, three emit-closures.** `updateLauncher` (item / band) and `updateFilesDrill` (depth = X, highlight = Y) each run the same accumulate-and-emit loop, differing only in what a step *does*. The switcher's `update` is the same loop for windows / Space-rows. There is **no** per-surface navigator object and **no** axis-lock — both axes accumulate independently, so a diagonal steps both. -- **Auto-repeat is the physical trackpad EDGE, not a dwell-eased zone.** `updateEdges`/`edgeAxis` (enter `edgeEnterZone` 0.16 / exit `edgeExitZone` 0.24 hysteresis) emit `launcherEdgeChanged(dx,dy)` when the controlling contact is held near a trackpad border; the controller's `edgeTimer` repeats on `LauncherOverlayController.edgeInterval(tick:acceleration:)` (hyperbolic 0.18s → 0.03s). The launcher and Files drill share `edgeDX/DY`, since they're mutually exclusive sub-states. -- **Edge auto-repeat suppression is Clipboard-only.** `setEdgeAutoScroll` suppresses **horizontal** auto-repeat only for the **Clipboard** band (there horizontal is the deliberate pin / return-to-band action). The **Files** band keeps horizontal auto-repeat — holding depth at the edge **auto-drills** the folder tree (uniform both-axis auto-repeat, the user's choice). -- **Re-baseline the origin on every contact-count change.** `startCentroid`/`lastCentroid`/`stepAccumulator{,Y}` (launcher) and `drillStart`/`drillLast`/`drillAccum{X,Y}` (Files) all reset on a count change so a leaving/landing finger emits no phantom step. Files also clears its held-edge state across the re-baseline. -- **`+1`-finger = action menu (intent).** The Files drill binds the relative `+1` (`count > drillContacts`, one-shot) to **Open-With** (`filesOpenWith`). Detected BEFORE re-baselining the count change. -- **Tuning is feel-only.** `launcherStepDistance` (item / Files depth+highlight) and `launcherContextStepDistance` (band switch, coarser) are **travel distances** (normalized, with carry) — defaults `0.04` / `0.09`. Edge-repeat cadence is the `edgeInterval` ramp; `edgeAcceleration` is pushed from `clipboardEdgeAcceleration`. Surfaced on the Hub Launcher page (plain sliders, no trackpad preview). `axisLockRatio` is the **switcher's** horizontal-vs-vertical dominance gate (pre-existing, unrelated to the deleted positional axis-lock) and stays. +- **One mechanic, shared emit-closures.** `updateLauncher` (item / band) runs the accumulate-and-emit loop; the switcher's `update` is the same loop for windows / Space-rows. There is **no** per-surface navigator object and **no** axis-lock — both axes accumulate independently, so a diagonal steps both. +- **Auto-repeat is the physical trackpad EDGE, not a dwell-eased zone.** `updateEdges`/`edgeAxis` (enter `edgeEnterZone` 0.16 / exit `edgeExitZone` 0.24 hysteresis) emit `launcherEdgeChanged(dx,dy)` when the controlling contact is held near a trackpad border; the controller's `edgeTimer` repeats on `LauncherOverlayController.edgeInterval(tick:acceleration:)` (hyperbolic 0.18s → 0.03s). +- **Edge auto-repeat suppression is Clipboard-only.** `setEdgeAutoScroll` suppresses **horizontal** auto-repeat only for the **Clipboard** band (there horizontal is the deliberate pin / return-to-band action). +- **Re-baseline the origin on every contact-count change.** `startCentroid`/`lastCentroid`/`stepAccumulator{,Y}` all reset on a count change so a leaving/landing finger emits no phantom step. +- **Tuning is feel-only.** `launcherStepDistance` (item step) and `launcherContextStepDistance` (band switch, coarser) are **travel distances** (normalized, with carry) — defaults `0.04` / `0.09`. Edge-repeat cadence is the `edgeInterval` ramp; `edgeAcceleration` is pushed from `clipboardEdgeAcceleration`. Surfaced on the Hub Launcher page (plain sliders, no trackpad preview). `axisLockRatio` is the **switcher's** horizontal-vs-vertical dominance gate (pre-existing, unrelated to the deleted positional axis-lock) and stays. ## Final gesture mechanism @@ -83,7 +80,7 @@ Post-activation **launcher** and **Files-drill** navigation is the **odometer**: ## Minimize-all + first-class minimized windows (change `minimize-all-and-reachable-minimized`) — landmines -Two coupled opt-ins (both default OFF): **`swipeDownMinimizesAll`** (three-finger DOWN minimizes all current-Space windows, revealing the desktop) and **`includeMinimizedWindows`** (minimized windows appear in the switcher + ⌘-Tab, restored on select). All MLX-free Core → `swift build`/`swift test`; the live AX behavior needs the user's signed build. +Two coupled opt-ins (both default OFF): **`swipeDownMinimizesAll`** (three-finger DOWN minimizes all current-Space windows, revealing the desktop) and **`includeMinimizedWindows`** (minimized windows appear in the switcher + ⌘-Tab, restored on select). All Core → `swift build`/`swift test`; the live AX behavior needs the user's signed build. - **Real minimize, current-Space, NOT `showDesktop()`.** `WindowService.minimizeAllWindows()` writes `kAXMinimized = true` per window (genuine minimize into the Dock, Windows Win+D) — deliberately **not** the native slide-aside `MissionControl.showDesktop()` (which doesn't minimize, is a toggle, and never populates the switcher). It reuses the `isSwitchable` gate (own app / floating / non-standard excluded), **skips already-minimized** (idempotent), scopes to the **current Space** (`kAXWindowsAttribute` returns current-Space + minimized; the minimized ones are skipped, off-Space aren't returned), and one failed write never blocks the rest (returns `(minimized, failed)` counts — observable state, no `NSAlert`). - **Only reachable with the vertical opt-in.** The DOWN action is dispatched in `AppCoordinator.gestureDidTriggerMissionControl(up:false)`, which only fires when `manageVerticalGesture` is effective (else the OS owns three-finger-vertical). So `swipeDownMinimizesAll` is gated on the Space-row opt-in; App Exposé stays the default down-action when off. diff --git a/DeviceLinkKit/Package.swift b/DeviceLinkKit/Package.swift deleted file mode 100644 index d54eddf..0000000 --- a/DeviceLinkKit/Package.swift +++ /dev/null @@ -1,49 +0,0 @@ -// swift-tools-version: 6.2 -import PackageDescription - -// The shared, cross-platform device-link packages: the wire contract, the iOS "moved items" store, and -// the pairing crypto. ZERO external dependencies (no MLX/AppKit/UIKit), declared for BOTH macOS and iOS -// so the macOS app and the iOS companion app can each consume the products. Verified under `swift test`. -let package = Package( - name: "DeviceLinkKit", - platforms: [ - .macOS(.v13), - .iOS(.v15) - ], - products: [ - .library(name: "DeviceLinkProtocol", targets: ["DeviceLinkProtocol"]), - .library(name: "DeviceLinkMirror", targets: ["DeviceLinkMirror"]), - .library(name: "DeviceLinkPairing", targets: ["DeviceLinkPairing"]) - ], - targets: [ - .target( - name: "DeviceLinkProtocol", - swiftSettings: [.swiftLanguageMode(.v6)] - ), - .target( - name: "DeviceLinkMirror", - dependencies: ["DeviceLinkProtocol"], - swiftSettings: [.swiftLanguageMode(.v6)] - ), - .target( - name: "DeviceLinkPairing", - dependencies: ["DeviceLinkProtocol"], // for DeviceIdentity (QR payload + pairing exchange) - swiftSettings: [.swiftLanguageMode(.v6)] - ), - .testTarget( - name: "DeviceLinkProtocolTests", - dependencies: ["DeviceLinkProtocol"], - swiftSettings: [.swiftLanguageMode(.v5)] - ), - .testTarget( - name: "DeviceLinkMirrorTests", - dependencies: ["DeviceLinkMirror"], - swiftSettings: [.swiftLanguageMode(.v5)] - ), - .testTarget( - name: "DeviceLinkPairingTests", - dependencies: ["DeviceLinkPairing", "DeviceLinkProtocol"], - swiftSettings: [.swiftLanguageMode(.v5)] - ) - ] -) diff --git a/DeviceLinkKit/Sources/DeviceLinkMirror/MovedItem.swift b/DeviceLinkKit/Sources/DeviceLinkMirror/MovedItem.swift deleted file mode 100644 index 8915c9b..0000000 --- a/DeviceLinkKit/Sources/DeviceLinkMirror/MovedItem.swift +++ /dev/null @@ -1,64 +0,0 @@ -import Foundation -import DeviceLinkProtocol - -/// Which way a thing moved between the iPhone and the Mac. -public enum MoveDirection: String, Codable, Sendable, Equatable { - case sent // the phone sent it to the Mac - case received // the phone received it from the Mac -} - -/// One record in the iPhone app's "what moved" list. Carries enough to show the row and to re-share a -/// received item (its representation bytes). Pure value type; no UIKit. -public struct MovedItem: Codable, Equatable, Identifiable, Sendable { - public var id: UUID - public var direction: MoveDirection - public var kind: LinkItemKind - /// A single-line label for the row. - public var title: String - /// The other device's name, when known. - public var peerName: String? - public var movedAt: Date - /// Materialized representation bytes keyed by UTI (loaded from blobs by the store). - public var representations: [String: Data] - - public init(id: UUID, direction: MoveDirection, kind: LinkItemKind, title: String, - peerName: String?, movedAt: Date, representations: [String: Data]) { - self.id = id - self.direction = direction - self.kind = kind - self.title = title - self.peerName = peerName - self.movedAt = movedAt - self.representations = representations - } - - /// Build a moved-item record from a wire `LinkItem`. - public static func from(_ item: LinkItem, direction: MoveDirection, at date: Date) -> MovedItem { - MovedItem(id: item.messageID, direction: direction, kind: item.kind, - title: title(for: item), peerName: item.origin?.name, movedAt: date, - representations: item.representations) - } - - /// A single-line title: first line of text/url, the file's suggested name, or a fixed label. - static func title(for item: LinkItem) -> String { - switch item.kind { - case .text, .url, .richText: - let data = item.representations[LinkUTI.plainText] - ?? item.representations[LinkUTI.url] - ?? item.representations.values.first - ?? Data() - return firstLine(String(decoding: data, as: UTF8.self)) - case .image: return "Image" - case .color: return "Color" - case .file: return item.suggestedName ?? "File" - } - } - - static func firstLine(_ text: String, max: Int = 80) -> String { - let line = text.split(whereSeparator: \.isNewline) - .first { !$0.trimmingCharacters(in: .whitespaces).isEmpty } - .map(String.init) ?? text - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.count <= max ? trimmed : String(trimmed.prefix(max)) + "…" - } -} diff --git a/DeviceLinkKit/Sources/DeviceLinkMirror/MovedItemStore.swift b/DeviceLinkKit/Sources/DeviceLinkMirror/MovedItemStore.swift deleted file mode 100644 index 4c844e8..0000000 --- a/DeviceLinkKit/Sources/DeviceLinkMirror/MovedItemStore.swift +++ /dev/null @@ -1,126 +0,0 @@ -import Foundation -import DeviceLinkProtocol - -/// Persists the iPhone app's moved-items list. Every representation's bytes go to a **blob file**; the -/// JSON index holds only metadata + blob filenames, so the index stays small and the store never holds -/// all payloads in memory (it materializes to `MovedItem` only on `list()`). Newest-first, replace-by-id, -/// with a count cap that evicts the oldest and deletes their blobs. Injectable directory for tests. -public final class MovedItemStore { - private let directory: URL - private var blobsDir: URL { directory.appendingPathComponent("blobs", isDirectory: true) } - private var indexURL: URL { directory.appendingPathComponent("index.json") } - - public var maxCount: Int - - private var stored: [StoredItem] = [] - - public init(directory: URL, maxCount: Int = 300) { - self.directory = directory - self.maxCount = maxCount - load() - } - - public var count: Int { stored.count } - - /// Items newest-first, with representation bytes materialized from blobs. - public func list() -> [MovedItem] { - stored.sorted { $0.movedAt > $1.movedAt }.compactMap(materialize) - } - - /// Insert (or replace a same-id record), evicting the oldest beyond the cap. - public func insert(_ item: MovedItem) { - removeBlobs(forID: item.id) - stored.removeAll { $0.id == item.id } - stored.append(writeBlobs(for: item)) - evict() - save() - } - - public func remove(id: UUID) { - removeBlobs(forID: id) - stored.removeAll { $0.id == id } - save() - } - - public func clear() { - for item in stored { removeBlobs(forID: item.id) } - stored.removeAll() - save() - } - - // MARK: - On-disk model - - private struct StoredItem: Codable { - var id: UUID - var direction: MoveDirection - var kind: LinkItemKind - var title: String - var peerName: String? - var movedAt: Date - var repFiles: [String: String] // uti -> blob filename - } - - // MARK: - Blobs - - private func writeBlobs(for item: MovedItem) -> StoredItem { - try? FileManager.default.createDirectory(at: blobsDir, withIntermediateDirectories: true) - var repFiles: [String: String] = [:] - for (uti, data) in item.representations { - let name = "\(item.id.uuidString)-\(stableName(uti)).bin" - let url = blobsDir.appendingPathComponent(name) - try? data.write(to: url, options: .atomic) - repFiles[uti] = name - } - return StoredItem(id: item.id, direction: item.direction, kind: item.kind, - title: item.title, peerName: item.peerName, movedAt: item.movedAt, repFiles: repFiles) - } - - private func materialize(_ s: StoredItem) -> MovedItem? { - var reps: [String: Data] = [:] - for (uti, name) in s.repFiles { - if let data = try? Data(contentsOf: blobsDir.appendingPathComponent(name)) { reps[uti] = data } - } - return MovedItem(id: s.id, direction: s.direction, kind: s.kind, title: s.title, - peerName: s.peerName, movedAt: s.movedAt, representations: reps) - } - - private func removeBlobs(forID id: UUID) { - guard let item = stored.first(where: { $0.id == id }) else { return } - for name in item.repFiles.values { - try? FileManager.default.removeItem(at: blobsDir.appendingPathComponent(name)) - } - } - - private func evict() { - guard stored.count > maxCount else { return } - let sorted = stored.sorted { $0.movedAt > $1.movedAt } - let keep = Array(sorted.prefix(maxCount)) - let drop = sorted.dropFirst(maxCount) - for item in drop { - for name in item.repFiles.values { - try? FileManager.default.removeItem(at: blobsDir.appendingPathComponent(name)) - } - } - stored = keep - } - - // MARK: - Persistence - - private func load() { - guard let data = try? Data(contentsOf: indexURL), - let decoded = try? JSONDecoder().decode([StoredItem].self, from: data) else { return } - stored = decoded - } - - private func save() { - try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - guard let data = try? JSONEncoder().encode(stored) else { return } - try? data.write(to: indexURL, options: .atomic) - } - - private func stableName(_ uti: String) -> String { - var hash: UInt64 = 0xcbf29ce484222325 - for byte in uti.utf8 { hash ^= UInt64(byte); hash = hash &* 0x100000001b3 } - return String(hash, radix: 16) - } -} diff --git a/DeviceLinkKit/Sources/DeviceLinkPairing/LinkSession.swift b/DeviceLinkKit/Sources/DeviceLinkPairing/LinkSession.swift deleted file mode 100644 index 1b03971..0000000 --- a/DeviceLinkKit/Sources/DeviceLinkPairing/LinkSession.swift +++ /dev/null @@ -1,211 +0,0 @@ -import Foundation -import CryptoKit -import DeviceLinkProtocol - -/// The authenticated link handshake (a Noise-KK-shaped exchange), reusing the proven `DeviceLinkPairing` -/// primitives: X25519 ECDH, role-independent HKDF-SHA256 derivation (sorted public keys + info), and -/// constant-time HMAC-SHA256 confirmations. -/// -/// Both ends already pinned the other's long-lived X25519 public key during pairing (by -/// `SHA256(rawRepresentation)`). Each side generates a fresh per-connection **ephemeral** key and sends -/// `authHello(staticPub, ephemeralPub, identity)`. On the peer's `authHello` the receiver: -/// (a) **rejects (fail closed)** if `SHA256(peer staticPub)` is not in the supplied pinned fingerprint set, -/// (b) derives a **role-independent** session `SymmetricKey` mixing the static–static term `ss` (which -/// authenticates: only the pinned-key holders can compute it) with the ephemeral term `ee` and the -/// two cross terms `se`/`es` (per-session freshness / forward secrecy), -/// (c) produces / constant-time-verifies an `authConfirm` HMAC under that key. -/// -/// A peer that presents a pinned public key but does **not** hold its private key cannot compute `ss`, -/// derives a different key, and fails the confirmation → the caller drops the connection. -/// -/// No I/O: a transport ferries the `Frame.authHello` / `Frame.authConfirm` control frames. -public struct LinkSession { - /// Why a handshake failed (rejected before / at confirmation). All are fail-closed: no session key. - public enum Failure: Error, Equatable { - /// The peer's presented long-lived key is malformed, or its ephemeral key is malformed. - case badKey - /// `SHA256(peer staticPub)` is not in the pinned fingerprint set. - case unpinned - /// The peer's `authConfirm` did not verify under the derived key (forged identity / wrong key). - case confirmationFailed - } - - private static let infoPrefix = "device-link-session-v1" - - public let identity: DeviceIdentity - public let staticKey: Curve25519.KeyAgreement.PrivateKey - public let ephemeralKey: Curve25519.KeyAgreement.PrivateKey - - /// - Parameters: - /// - identity: this device's identity, carried in `authHello`. - /// - staticKey: the local long-lived (pinned) X25519 key agreement private key. - /// - ephemeralKey: a fresh per-connection ephemeral; defaults to a new random key. - public init(identity: DeviceIdentity, - staticKey: Curve25519.KeyAgreement.PrivateKey, - ephemeralKey: Curve25519.KeyAgreement.PrivateKey = Curve25519.KeyAgreement.PrivateKey()) { - self.identity = identity - self.staticKey = staticKey - self.ephemeralKey = ephemeralKey - } - - /// This side's opening handshake frame: its pinned static public key, fresh ephemeral, and identity. - public func hello() -> Frame { - .authHello(staticPub: staticKey.publicKey.rawRepresentation, - ephemeralPub: ephemeralKey.publicKey.rawRepresentation, - identity: identity) - } - - /// `true` iff `SHA256(peerStaticRaw)` is a pinned fingerprint (fail closed when absent). - public static func isPinned(peerStaticRaw: Data, pinnedFingerprints: Set) -> Bool { - pinnedFingerprints.contains(Data(SHA256.hash(data: peerStaticRaw))) - } - - /// Consume the peer's `authHello`. Verifies the peer's static key is pinned (else `.unpinned`), - /// then derives the role-independent session key. On success returns the established session. - /// - /// - Throws: `Failure.badKey` (malformed peer key), `Failure.unpinned` (not in `pinnedFingerprints`). - public func accept(peerHello frame: Frame, - pinnedFingerprints: Set) throws -> Established { - guard case let .authHello(peerStaticRaw, peerEphemeralRaw, peerIdentity) = frame else { - throw Failure.badKey - } - // Reject a peer presenting OUR own static key (a reflection): it would collapse the role-label - // tie-break to an acceptable self-confirm. Unreachable in practice (we never pin our own key), - // but a cheap, decisive guard. - guard peerStaticRaw != staticKey.publicKey.rawRepresentation else { - throw Failure.badKey - } - guard Self.isPinned(peerStaticRaw: peerStaticRaw, pinnedFingerprints: pinnedFingerprints) else { - throw Failure.unpinned - } - guard let peerStatic = try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: peerStaticRaw), - let peerEphemeral = try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: peerEphemeralRaw) else { - throw Failure.badKey - } - let key = try deriveSessionKey(peerStatic: peerStatic, peerEphemeral: peerEphemeral) - // Role-independent confirm labels: the side whose static key sorts lower sends the "low" label - // and verifies the peer's "high" label (and vice versa). Both sides agree on the assignment from - // the sorted statics, so each can produce its own confirm and verify the peer's distinct one. - let localIsLow = staticKey.publicKey.rawRepresentation.lexicographicallyPrecedes(peerStaticRaw) - return Established(sessionKey: key, - peerIdentity: peerIdentity, - peerStaticFingerprint: Data(SHA256.hash(data: peerStaticRaw)), - localIsLow: localIsLow) - } - - // MARK: - Key derivation - - /// Role-independent session key: `HKDF-SHA256` over `ss ‖ ee ‖ se‖es` (cross terms ordered - /// role-independently) with `info = "device-link-session-v1" ‖ sorted(statics) ‖ sorted(ephemerals)`. - /// Mirrors `PairingHandshake.confirmationKey`'s sorted-public-key construction. - func deriveSessionKey(peerStatic: Curve25519.KeyAgreement.PublicKey, - peerEphemeral: Curve25519.KeyAgreement.PublicKey) throws -> SymmetricKey { - let localStaticRaw = staticKey.publicKey.rawRepresentation - let localEphemeralRaw = ephemeralKey.publicKey.rawRepresentation - let peerStaticRaw = peerStatic.rawRepresentation - let peerEphemeralRaw = peerEphemeral.rawRepresentation - - let ss = try staticKey.sharedSecretFromKeyAgreement(with: peerStatic) - let ee = try ephemeralKey.sharedSecretFromKeyAgreement(with: peerEphemeral) - // Cross terms: ECDH(localStatic, peerEphemeral) and ECDH(localEphemeral, peerStatic). The two ends - // compute the SAME two physical DH values but with the roles of "local"/"peer" swapped, so each - // cross term must be ordered by a key that is symmetric in its two participating public keys — - // `sorted(staticRaw, ephemeralRaw)` of that DH pair — which both ends see identically. - let crossA = try staticKey.sharedSecretFromKeyAgreement(with: peerEphemeral) // localStatic · peerEph - let crossB = try ephemeralKey.sharedSecretFromKeyAgreement(with: peerStatic) // localEph · peerStatic - let crossAKey = Self.symmetricKeyBytes(localStaticRaw, peerEphemeralRaw) - let crossBKey = Self.symmetricKeyBytes(localEphemeralRaw, peerStaticRaw) - let (firstCross, secondCross) = crossAKey.lexicographicallyPrecedes(crossBKey) - ? (crossA, crossB) : (crossB, crossA) - - var ikm = Data() - ikm.append(rawBytes(ss)) - ikm.append(rawBytes(ee)) - ikm.append(rawBytes(firstCross)) - ikm.append(rawBytes(secondCross)) - - let (lowStatic, highStatic) = Self.ordered(localStaticRaw, peerStaticRaw) - let (lowEph, highEph) = Self.ordered(localEphemeralRaw, peerEphemeralRaw) - var info = Data(Self.infoPrefix.utf8) - info.append(lowStatic) - info.append(highStatic) - info.append(lowEph) - info.append(highEph) - - return HKDF.deriveKey(inputKeyMaterial: SymmetricKey(data: ikm), - info: info, - outputByteCount: 32) - } - - private func rawBytes(_ secret: SharedSecret) -> Data { - secret.withUnsafeBytes { Data($0) } - } - - private static func ordered(_ a: Data, _ b: Data) -> (Data, Data) { - a.lexicographicallyPrecedes(b) ? (a, b) : (b, a) - } - - /// A role-independent ordering key for a DH pair: `sorted(a, b)` concatenated. Both ends produce the - /// identical bytes regardless of which key they call "local". - private static func symmetricKeyBytes(_ a: Data, _ b: Data) -> Data { - let (low, high) = ordered(a, b) - return low + high - } - - /// An authenticated session: the derived key plus the verified peer identity. The confirm helpers - /// reuse the constant-time HMAC-SHA256 confirmations from `PairingHandshake`, with distinct role - /// labels so a confirmation can't be reflected back. - public struct Established: Sendable { - public let sessionKey: SymmetricKey - public let peerIdentity: DeviceIdentity - /// `SHA256(peer staticPub)` — the pinned fingerprint the peer authenticated as. - public let peerStaticFingerprint: Data - /// Whether this side's static key sorts before the peer's — selects the confirm role labels. - let localIsLow: Bool - - private static let labelLow = "device-link-confirm-low" - private static let labelHigh = "device-link-confirm-high" - - /// The label this side sends; the peer verifies it with the same string. - private var sendLabel: String { localIsLow ? Self.labelLow : Self.labelHigh } - /// The label the peer sends; this side verifies the peer's confirm with it. - private var peerLabel: String { localIsLow ? Self.labelHigh : Self.labelLow } - - // MARK: Directional record keys - // - // The session key is role-INDEPENDENT (both ends derive the identical key), so sealing BOTH stream - // directions under it would reuse `(key, counter-nonce)` between the two `Sealer`s — a catastrophic - // AEAD nonce reuse. Instead each direction gets its OWN key, derived from the session key by an - // HKDF label that names the DIRECTION (low→high vs high→low), not the local role. Both ends agree on - // the assignment from the sorted statics, so this side's `sealKey` equals the peer's `openKey`. - private static let labelLowToHigh = "device-link-record-low-to-high" - private static let labelHighToLow = "device-link-record-high-to-low" - - private static func directionKey(_ session: SymmetricKey, _ label: String) -> SymmetricKey { - HKDF.deriveKey(inputKeyMaterial: session, info: Data(label.utf8), outputByteCount: 32) - } - - /// The key for records THIS side SEALS (its transmit direction). - public var sealKey: SymmetricKey { - Self.directionKey(sessionKey, localIsLow ? Self.labelLowToHigh : Self.labelHighToLow) - } - /// The key for records THIS side OPENS (its receive direction) — equals the peer's `sealKey`. - public var openKey: SymmetricKey { - Self.directionKey(sessionKey, localIsLow ? Self.labelHighToLow : Self.labelLowToHigh) - } - - /// The `authConfirm` frame this side sends — HMAC over its role label under the session key. - public func confirm() -> Frame { - .authConfirm(mac: Data(HMAC.authenticationCode(for: Data(sendLabel.utf8), using: sessionKey))) - } - - /// Constant-time verify the peer's received `authConfirm`. A peer that derived a different key - /// (e.g. it doesn't hold the pinned private key) produces a non-matching MAC → `false`. - public func verify(peerConfirm frame: Frame) -> Bool { - guard case let .authConfirm(mac) = frame else { return false } - return HMAC.isValidAuthenticationCode(mac, - authenticating: Data(peerLabel.utf8), - using: sessionKey) - } - } -} diff --git a/DeviceLinkKit/Sources/DeviceLinkPairing/LocalAddresses.swift b/DeviceLinkKit/Sources/DeviceLinkPairing/LocalAddresses.swift deleted file mode 100644 index bce56eb..0000000 --- a/DeviceLinkKit/Sources/DeviceLinkPairing/LocalAddresses.swift +++ /dev/null @@ -1,66 +0,0 @@ -import Foundation - -/// Enumerates this device's reachable unicast IP addresses to embed in a pairing QR, so a scanner can -/// dial directly without mDNS/Bonjour discovery. Returns Wi-Fi/Ethernet (`en*`) first and IPv4 before -/// IPv6, excluding loopback, link-local (`169.254.*` / `fe80::`), and tunnel/VM interfaces (AWDL, utun, -/// bridges, …). Pure + dependency-free (`getifaddrs`), so it's unit-testable and shared by both ends. -public enum LocalAddresses { - - /// Current routable unicast addresses, most-likely-reachable first, capped to `limit`. - public static func current(limit: Int = 4) -> [String] { - var ifaddr: UnsafeMutablePointer? - guard getifaddrs(&ifaddr) == 0 else { return [] } - defer { freeifaddrs(ifaddr) } - - struct Candidate { let address: String; let isIPv4: Bool; let isEthernet: Bool } - var candidates: [Candidate] = [] - - var ptr = ifaddr - while let p = ptr { - defer { ptr = p.pointee.ifa_next } - - let flags = Int32(bitPattern: p.pointee.ifa_flags) - guard (flags & IFF_UP) == IFF_UP, (flags & IFF_LOOPBACK) == 0 else { continue } - guard let sa = p.pointee.ifa_addr else { continue } - - let family = sa.pointee.sa_family - let isIPv4 = family == sa_family_t(AF_INET) - let isIPv6 = family == sa_family_t(AF_INET6) - guard isIPv4 || isIPv6 else { continue } - - let name = String(cString: p.pointee.ifa_name) - guard !isExcludedInterface(name) else { continue } - - var host = [CChar](repeating: 0, count: Int(NI_MAXHOST)) - guard getnameinfo(sa, socklen_t(sa.pointee.sa_len), - &host, socklen_t(host.count), nil, 0, NI_NUMERICHOST) == 0 else { continue } - - var address = host.withUnsafeBufferPointer { String(cString: $0.baseAddress!) } - if let pct = address.firstIndex(of: "%") { address = String(address[.. Bool { - ["lo", "awdl", "llw", "utun", "ipsec", "ppp", "bridge", "vmnet", "tap", "tun", "gif", "stf"] - .contains { name.hasPrefix($0) } - } - - private static func isLinkLocalOrUnspecified(_ address: String, isIPv4: Bool) -> Bool { - if address.isEmpty { return true } - if isIPv4 { return address.hasPrefix("169.254.") || address == "0.0.0.0" } - let lower = address.lowercased() - return lower.hasPrefix("fe80:") || lower == "::" || lower == "::1" - } -} diff --git a/DeviceLinkKit/Sources/DeviceLinkPairing/PairingCode.swift b/DeviceLinkKit/Sources/DeviceLinkPairing/PairingCode.swift deleted file mode 100644 index 40a9b57..0000000 --- a/DeviceLinkKit/Sources/DeviceLinkPairing/PairingCode.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Foundation - -/// A short, high-entropy pairing code shown on the Mac and entered on the iPhone. It is a low-entropy -/// secret used only to *authenticate* a strong key agreement — never sent on the wire, never used -/// directly as a key. Generated from the system CSPRNG. Shared by both ends. -public enum PairingCode { - public static let defaultDigits = 8 - - /// A fresh code of `digits` decimal digits (default 8 → ~27 bits). - public static func generate(digits: Int = defaultDigits) -> String { - var rng = SystemRandomNumberGenerator() - return (0.. Bool { - code.count == digits && code.allSatisfy { $0.isASCII && $0.isNumber } - } -} diff --git a/DeviceLinkKit/Sources/DeviceLinkPairing/PairingExchange.swift b/DeviceLinkKit/Sources/DeviceLinkPairing/PairingExchange.swift deleted file mode 100644 index 9720be9..0000000 --- a/DeviceLinkKit/Sources/DeviceLinkPairing/PairingExchange.swift +++ /dev/null @@ -1,101 +0,0 @@ -import Foundation -import CryptoKit -import DeviceLinkProtocol - -/// The pure, authenticated QR pairing exchange. The **joiner** (scanned the QR) and the **host** (showed -/// it) exchange ephemeral X25519 keys and HMAC confirmations keyed by `HKDF(ECDH, salt: the QR secret)`. -/// Both end pinned to the other's long-lived fingerprint + identity, and only if they used the same -/// secret — a man-in-the-middle who couldn't read the QR derives a different key and fails confirmation. -/// No I/O: a transport ferries the `PairingMessage`s. -public struct PairingExchange { - public enum Role: Sendable { case host, joiner } - - public enum Result: Equatable, Sendable { - /// Pinned the peer: its identity + long-lived SPKI fingerprint. - case pinned(DeviceIdentity, Data) - case failed - } - - public let role: Role - - private let secret: Data - private let identity: DeviceIdentity - private let spki: Data - private let ephemeral: Curve25519.KeyAgreement.PrivateKey - - // Host-side state carried between its two `consume` calls. - private var sharedKey: SymmetricKey? - private var peerIdentity: DeviceIdentity? - private var peerSPKI: Data? - - public init(role: Role, secret: Data, identity: DeviceIdentity, spkiFingerprint: Data, - ephemeral: Curve25519.KeyAgreement.PrivateKey = Curve25519.KeyAgreement.PrivateKey()) { - self.role = role - self.secret = secret - self.identity = identity - self.spki = spkiFingerprint - self.ephemeral = ephemeral - } - - /// Joiner only: the opening message. - public func start() -> PairingMessage? { - guard role == .joiner else { return nil } - return .joinerHello(ephemeral: ephemeral.publicKey.rawRepresentation, identity: identity, spki: spki) - } - - /// Consume a message; return the reply to send (if any) and a terminal result (if reached). - public mutating func consume(_ message: PairingMessage) throws -> (reply: PairingMessage?, result: Result?) { - switch (role, message) { - case let (.host, .joinerHello(ephData, joinerID, joinerSPKI)): - let key = try deriveKey(peerEphemeral: ephData) - sharedKey = key - peerIdentity = joinerID - peerSPKI = joinerSPKI - let confirm = mac(key, label: "host") - return (.hostHello(ephemeral: ephemeral.publicKey.rawRepresentation, identity: identity, spki: spki, confirm: confirm), nil) - - case let (.joiner, .hostHello(ephData, hostID, hostSPKI, hostConfirm)): - let key = try deriveKey(peerEphemeral: ephData) - guard verify(hostConfirm, key: key, label: "host") else { return (nil, .failed) } - return (.joinerConfirm(confirm: mac(key, label: "joiner")), .pinned(hostID, hostSPKI)) - - case let (.host, .joinerConfirm(joinerConfirm)): - guard let key = sharedKey, let pid = peerIdentity, let psp = peerSPKI, - verify(joinerConfirm, key: key, label: "joiner") else { return (nil, .failed) } - return (nil, .pinned(pid, psp)) - - default: - return (nil, .failed) - } - } - - // MARK: - Crypto - - private func deriveKey(peerEphemeral: Data) throws -> SymmetricKey { - guard let peerPub = try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: peerEphemeral) else { - throw PairingExchangeError.badKey - } - let shared = try ephemeral.sharedSecretFromKeyAgreement(with: peerPub) - let (low, high) = ordered(ephemeral.publicKey.rawRepresentation, peerEphemeral) - var info = Data("device-link-qr-v1".utf8) - info.append(low) - info.append(high) - return shared.hkdfDerivedSymmetricKey(using: SHA256.self, salt: secret, sharedInfo: info, outputByteCount: 32) - } - - private func mac(_ key: SymmetricKey, label: String) -> Data { - Data(HMAC.authenticationCode(for: Data(label.utf8), using: key)) - } - - private func verify(_ tag: Data, key: SymmetricKey, label: String) -> Bool { - HMAC.isValidAuthenticationCode(tag, authenticating: Data(label.utf8), using: key) - } - - private func ordered(_ a: Data, _ b: Data) -> (Data, Data) { - a.lexicographicallyPrecedes(b) ? (a, b) : (b, a) - } -} - -public enum PairingExchangeError: Error, Equatable { - case badKey -} diff --git a/DeviceLinkKit/Sources/DeviceLinkPairing/PairingHandshake.swift b/DeviceLinkKit/Sources/DeviceLinkPairing/PairingHandshake.swift deleted file mode 100644 index 701e833..0000000 --- a/DeviceLinkKit/Sources/DeviceLinkPairing/PairingHandshake.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation -import CryptoKit - -/// The cryptographic core of serverless, CA-free pairing, shared by the Mac and the iPhone. Each side -/// holds an ephemeral X25519 key pair. Given the peer's public key and the shared code, both derive the -/// SAME confirmation key — and only if they used the same code — by HKDF over the ECDH shared secret, -/// salted by the code and bound to both public keys. An active man-in-the-middle who substitutes keys but -/// does not know the code derives a different key and cannot forge a matching HMAC confirmation. -public struct PairingHandshake { - public let privateKey: Curve25519.KeyAgreement.PrivateKey - public var publicKey: Curve25519.KeyAgreement.PublicKey { privateKey.publicKey } - - public init(privateKey: Curve25519.KeyAgreement.PrivateKey = Curve25519.KeyAgreement.PrivateKey()) { - self.privateKey = privateKey - } - - /// Derive the shared confirmation key. Role-independent: both sides sort the two public keys, so - /// initiator and responder compute the identical key. - public func confirmationKey(peerPublicKey: Curve25519.KeyAgreement.PublicKey, code: String) throws -> SymmetricKey { - let shared = try privateKey.sharedSecretFromKeyAgreement(with: peerPublicKey) - let (low, high) = Self.ordered(publicKey.rawRepresentation, peerPublicKey.rawRepresentation) - var info = Data("device-link-pairing-v1".utf8) - info.append(low) - info.append(high) - return shared.hkdfDerivedSymmetricKey(using: SHA256.self, - salt: Data(code.utf8), - sharedInfo: info, - outputByteCount: 32) - } - - /// The confirmation MAC a side sends to prove it derived the same key. - public func confirmation(_ key: SymmetricKey, label: String) -> Data { - Data(HMAC.authenticationCode(for: Data(label.utf8), using: key)) - } - - /// Constant-time verification of a received confirmation MAC. - public func verify(_ mac: Data, key: SymmetricKey, label: String) -> Bool { - HMAC.isValidAuthenticationCode(mac, authenticating: Data(label.utf8), using: key) - } - - private static func ordered(_ a: Data, _ b: Data) -> (Data, Data) { - a.lexicographicallyPrecedes(b) ? (a, b) : (b, a) - } -} diff --git a/DeviceLinkKit/Sources/DeviceLinkPairing/PairingMessage.swift b/DeviceLinkKit/Sources/DeviceLinkPairing/PairingMessage.swift deleted file mode 100644 index edbc2ce..0000000 --- a/DeviceLinkKit/Sources/DeviceLinkPairing/PairingMessage.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Foundation -import DeviceLinkProtocol - -/// The three messages of the QR pairing exchange. X25519 public keys are carried as their raw -/// representation; confirmations are HMAC tags. `Codable` so a transport can ferry them. -public enum PairingMessage: Codable, Equatable, Sendable { - /// Joiner (scanned the QR) opens with its ephemeral public key, identity, and long-lived fingerprint. - case joinerHello(ephemeral: Data, identity: DeviceIdentity, spki: Data) - /// Host (showed the QR) replies with its own + a confirmation it knew the secret. - case hostHello(ephemeral: Data, identity: DeviceIdentity, spki: Data, confirm: Data) - /// Joiner confirms it, too, knew the secret. - case joinerConfirm(confirm: Data) -} diff --git a/DeviceLinkKit/Sources/DeviceLinkPairing/PairingQRPayload.swift b/DeviceLinkKit/Sources/DeviceLinkPairing/PairingQRPayload.swift deleted file mode 100644 index cac4619..0000000 --- a/DeviceLinkKit/Sources/DeviceLinkPairing/PairingQRPayload.swift +++ /dev/null @@ -1,94 +0,0 @@ -import Foundation -import DeviceLinkProtocol - -/// What a pairing QR encodes: the showing device's identity, a fresh high-entropy secret (the -/// out-of-band authenticator — far stronger than an 8-digit code), and the device's long-lived -/// public-key (SPKI) fingerprint so the scanner can pin its TLS identity. v2 additionally carries the -/// shower's reachable network address(es) + its listener port so the scanner can dial it **directly** -/// (unicast) without relying on mDNS/Bonjour discovery, which routers commonly filter between clients. -/// Encoded as a scheme-tagged, versioned base64url string. -public struct PairingQRPayload: Equatable, Sendable { - /// Bumped to 2 for the optional `addresses` + `port` endpoint. v1 (no endpoint) still decodes. - public static let currentVersion = 2 - public static let scheme = "tfslink:" - - public var version: Int - public var device: DeviceIdentity - public var secret: Data - public var spkiFingerprint: Data - /// The shower's reachable unicast address(es), most-likely-reachable first (Wi-Fi/Ethernet, IPv4 first). - /// Empty for a v1 payload or when none could be enumerated → the scanner falls back to discovery. - public var addresses: [String] - /// The bound TCP port of the shower's pairing listener. `nil` for a v1 payload. - public var port: UInt16? - - public init(device: DeviceIdentity, - secret: Data, - spkiFingerprint: Data, - addresses: [String] = [], - port: UInt16? = nil, - version: Int = PairingQRPayload.currentVersion) { - self.version = version - self.device = device - self.secret = secret - self.spkiFingerprint = spkiFingerprint - self.addresses = addresses - self.port = port - } - - /// True when the payload carries a directly-dialable endpoint (at least one address + a port). - public var hasEndpoint: Bool { !addresses.isEmpty && port != nil } - - /// 32 cryptographically-random bytes (the global RNG is a CSPRNG). - public static func makeSecret() -> Data { - Data((0..<32).map { _ in UInt8.random(in: UInt8.min...UInt8.max) }) - } - - public func encodedString() -> String { - let wire = Wire(v: version, id: device.id, name: device.name, secret: secret, fp: spkiFingerprint, - addrs: addresses.isEmpty ? nil : addresses, port: port) - let json = (try? JSONEncoder().encode(wire)) ?? Data() - return Self.scheme + Self.base64url(json) - } - - public init(string: String) throws { - guard string.hasPrefix(Self.scheme) else { throw PairingQRError.badScheme } - let body = String(string.dropFirst(Self.scheme.count)) - guard let json = Self.base64urlDecode(body) else { throw PairingQRError.malformed } - guard let wire = try? JSONDecoder().decode(Wire.self, from: json) else { throw PairingQRError.malformed } - // Accept v1 (no endpoint) and the current version; reject anything else. - guard wire.v == 1 || wire.v == Self.currentVersion else { throw PairingQRError.unsupportedVersion } - self.init(device: DeviceIdentity(id: wire.id, name: wire.name), - secret: wire.secret, spkiFingerprint: wire.fp, - addresses: wire.addrs ?? [], port: wire.port, version: wire.v) - } - - private struct Wire: Codable { - var v: Int - var id: String - var name: String - var secret: Data - var fp: Data - var addrs: [String]? // v2+, optional — omitted on the wire when empty/v1 - var port: UInt16? // v2+, optional - } - - static func base64url(_ data: Data) -> String { - data.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } - - static func base64urlDecode(_ s: String) -> Data? { - var b = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") - while b.count % 4 != 0 { b += "=" } - return Data(base64Encoded: b) - } -} - -public enum PairingQRError: Error, Equatable { - case badScheme - case unsupportedVersion - case malformed -} diff --git a/DeviceLinkKit/Sources/DeviceLinkPairing/SealedRecord.swift b/DeviceLinkKit/Sources/DeviceLinkPairing/SealedRecord.swift deleted file mode 100644 index 80d438f..0000000 --- a/DeviceLinkKit/Sources/DeviceLinkPairing/SealedRecord.swift +++ /dev/null @@ -1,118 +0,0 @@ -import Foundation -import CryptoKit - -/// The confidential-channel layer for an authenticated link: every outbound buffer is wrapped as a -/// length-prefixed `ChaChaPoly` sealed record under the session key, and opened on receive — transparently, -/// *below* the framing pump so the tested `LinkPump` / wire `LinkItem` is unchanged. -/// -/// **Nonce discipline (security-critical):** the 96-bit nonce is a strictly-monotonic per-direction -/// counter, NOT carried on the wire. Each direction owns one `Sealer` and the peer owns the matching -/// `Opener`; both start at 0 and advance in lockstep. A fresh session key resets the counters. Because the -/// counter is implicit, records must be opened in the exact order they were sealed — a dropped, reordered, -/// duplicated, or bit-flipped record fails AEAD authentication and `open` throws (the caller closes the -/// connection; no partial item is surfaced). -/// -/// Wire record: `length(UInt32 BE) ‖ ChaChaPoly(ciphertext ‖ tag)`. The 16-byte tag is included in the -/// length. Only the long-lived key holders share the session key, so an on-path tap can neither read nor -/// forge records. -public enum SealedRecord { - /// AEAD overhead per record: the 4-byte length prefix plus ChaChaPoly's 16-byte authentication tag. - public static let tagSize = 16 - public static let lengthPrefixSize = 4 - - public enum Error: Swift.Error, Equatable { - /// A record was shorter than the length prefix, or its declared length exceeded the buffer. - case truncated - /// AEAD authentication failed: a tampered/reordered/duplicated record, or a nonce-counter skew. - case authenticationFailed - /// The per-direction counter would overflow 2^64 records (never reached in practice). - case counterExhausted - } - - /// Seals outbound buffers for one direction under the session key with a monotonic counter nonce. - /// One instance per connection-direction; not thread-safe (the transport owns its serial context). - public struct Sealer { - private let key: SymmetricKey - private var counter: UInt64 = 0 - - public init(key: SymmetricKey) { self.key = key } - - /// The current (next-to-use) counter value — for tests / diagnostics. - public var nextCounter: UInt64 { counter } - - /// Seal one buffer into a length-prefixed record and advance the counter. Throws only if the - /// counter is exhausted (2^64 records). - public mutating func seal(_ plaintext: Data) throws -> Data { - guard counter < UInt64.max else { throw Error.counterExhausted } - let nonce = try ChaChaPoly.Nonce(data: SealedRecord.nonceData(counter)) - let box = try ChaChaPoly.seal(plaintext, using: key, nonce: nonce) - // `box.combined` is nonce(12) ‖ ciphertext ‖ tag(16). The nonce is implicit (the counter), so - // we transmit only ciphertext ‖ tag — strictly enforcing in-order opening. - let payload = box.ciphertext + box.tag - counter &+= 1 - var out = Data() - SealedRecord.appendU32BE(UInt32(payload.count), to: &out) - out.append(payload) - return out - } - } - - /// Opens inbound records for one direction. Mirrors a peer `Sealer`: same key, counter starting at 0. - public struct Opener { - private let key: SymmetricKey - private var counter: UInt64 = 0 - - public init(key: SymmetricKey) { self.key = key } - - /// The current (next-expected) counter value — for tests / diagnostics. - public var nextCounter: UInt64 { counter } - - /// Open exactly one length-prefixed record from the front of `record`, returning the plaintext and - /// the number of bytes consumed. Throws `.truncated` if the buffer is short, `.authenticationFailed` - /// on any AEAD failure (tamper / reorder / wrong key / counter skew). - public mutating func open(_ record: Data) throws -> (plaintext: Data, consumed: Int) { - guard record.count >= SealedRecord.lengthPrefixSize else { throw Error.truncated } - let s = record.startIndex - let length = Int(SealedRecord.u32(record, 0)) - guard length >= SealedRecord.tagSize else { throw Error.authenticationFailed } - let total = SealedRecord.lengthPrefixSize + length - guard record.count >= total else { throw Error.truncated } - let payload = record[(s + SealedRecord.lengthPrefixSize)..<(s + total)] - let cipherEnd = payload.endIndex - SealedRecord.tagSize - let ciphertext = payload[payload.startIndex.. Data { - var d = Data(repeating: 0, count: 4) - var be = counter.bigEndian - withUnsafeBytes(of: &be) { d.append(contentsOf: $0) } - return d - } - - static func appendU32BE(_ v: UInt32, to data: inout Data) { - data.append(UInt8((v >> 24) & 0xff)) - data.append(UInt8((v >> 16) & 0xff)) - data.append(UInt8((v >> 8) & 0xff)) - data.append(UInt8(v & 0xff)) - } - - static func u32(_ d: Data, _ offset: Int) -> UInt32 { - let s = d.startIndex + offset - return (UInt32(d[s]) << 24) | (UInt32(d[s + 1]) << 16) | (UInt32(d[s + 2]) << 8) | UInt32(d[s + 3]) - } -} diff --git a/DeviceLinkKit/Sources/DeviceLinkProtocol/Frame.swift b/DeviceLinkKit/Sources/DeviceLinkProtocol/Frame.swift deleted file mode 100644 index 9676858..0000000 --- a/DeviceLinkKit/Sources/DeviceLinkProtocol/Frame.swift +++ /dev/null @@ -1,89 +0,0 @@ -import Foundation - -/// The header that opens an item: its kind, the per-representation byte manifest (UTI → total bytes, -/// so the receiver knows the full size before any bytes arrive), and optional metadata. `Codable` — -/// encoded as a control body by the codec. -public struct ItemHeader: Equatable, Sendable, Codable { - public var messageID: UUID - public var kind: LinkItemKind - public var manifest: [String: UInt32] - public var suggestedName: String? - public var capturedAt: Date? - public var origin: DeviceIdentity? - - public init(messageID: UUID, - kind: LinkItemKind, - manifest: [String: UInt32], - suggestedName: String? = nil, - capturedAt: Date? = nil, - origin: DeviceIdentity? = nil) { - self.messageID = messageID - self.kind = kind - self.manifest = manifest - self.suggestedName = suggestedName - self.capturedAt = capturedAt - self.origin = origin - } -} - -/// One bounded slice of one representation's bytes. Hand-encoded as raw bytes (never JSON/base64) so a -/// large file streams without inflation. `seq` is the 0-based, per-representation chunk index. -public struct ChunkFrame: Equatable, Sendable { - public var messageID: UUID - public var uti: String - public var seq: UInt32 - public var bytes: Data - - public init(messageID: UUID, uti: String, seq: UInt32, bytes: Data) { - self.messageID = messageID - self.uti = uti - self.seq = seq - self.bytes = bytes - } -} - -/// The closed set of wire frames. Item-bearing frames carry their `messageID` so frames for different -/// items can be interleaved on a single stream. -public enum Frame: Equatable, Sendable { - case hello(DeviceIdentity, ProtocolVersion) - case ack(UUID) - case error(LinkProtocolError.Code) - case itemBegin(ItemHeader) - case chunk(ChunkFrame) - case itemEnd(UUID) - case cancel(UUID) - /// First control frame of the authenticated link handshake: the sender's pinned long-lived X25519 - /// public key (raw), a fresh per-connection ephemeral X25519 public key (raw), and its identity. - /// Carried in the clear (only public keys + identity); the receiver pin-verifies the static key. - case authHello(staticPub: Data, ephemeralPub: Data, identity: DeviceIdentity) - /// Second handshake frame: an HMAC over the session key proving the sender derived the same key - /// (and therefore holds the private key behind its pinned fingerprint). Constant-time verified. - case authConfirm(mac: Data) - - /// The message id this frame belongs to, when it is item-scoped (nil for `hello`/`error`/auth frames). - public var messageID: UUID? { - switch self { - case .hello, .error, .authHello, .authConfirm: - return nil - case let .ack(id), let .itemEnd(id), let .cancel(id): - return id - case let .itemBegin(header): - return header.messageID - case let .chunk(chunk): - return chunk.messageID - } - } -} - -/// The 1-byte wire tag for each frame type (written in the envelope). Internal to the codec. -enum FrameType: UInt8 { - case hello = 1 - case ack = 2 - case error = 3 - case itemBegin = 4 - case chunk = 5 - case itemEnd = 6 - case cancel = 7 - case authHello = 8 - case authConfirm = 9 -} diff --git a/DeviceLinkKit/Sources/DeviceLinkProtocol/FrameStreamEncoder.swift b/DeviceLinkKit/Sources/DeviceLinkProtocol/FrameStreamEncoder.swift deleted file mode 100644 index 40728ed..0000000 --- a/DeviceLinkKit/Sources/DeviceLinkProtocol/FrameStreamEncoder.swift +++ /dev/null @@ -1,51 +0,0 @@ -import Foundation - -/// The send-side counterpart to `InboundAssembler`: splits a `LinkItem` into the ordered frame sequence -/// `itemBegin → chunk… → itemEnd`, honoring a chunk byte bound so large representations stream. Pure and -/// deterministic (representations in sorted-UTI order, 0-based per-representation sequence numbers), so -/// `encode(item) → InboundAssembler` reconstructs an equal item. -public struct FrameStreamEncoder { - /// Max bytes per `chunk` frame's representation slice. - public var chunkByteBound: Int - - public init(chunkByteBound: Int = LinkProtocol.defaultChunkByteBound) { - self.chunkByteBound = max(1, chunkByteBound) - } - - /// The ordered frames for an item: one header, then bounded chunks per representation, then a terminator. - public func frames(for item: LinkItem) -> [Frame] { - var frames: [Frame] = [] - - let manifest = item.representations.mapValues { UInt32($0.count) } - let header = ItemHeader(messageID: item.messageID, - kind: item.kind, - manifest: manifest, - suggestedName: item.suggestedName, - capturedAt: item.capturedAt, - origin: item.origin) - frames.append(.itemBegin(header)) - - // Deterministic representation order so output is reproducible and byte-stable. - for uti in item.representations.keys.sorted() { - let data = item.representations[uti] ?? Data() - if data.isEmpty { - // Emit one empty chunk so the assembler records the (empty) representation and the - // round-trip preserves it. - frames.append(.chunk(ChunkFrame(messageID: item.messageID, uti: uti, seq: 0, bytes: Data()))) - continue - } - var seq: UInt32 = 0 - var offset = 0 - while offset < data.count { - let end = min(offset + chunkByteBound, data.count) - let slice = data.subdata(in: (data.startIndex + offset)..<(data.startIndex + end)) - frames.append(.chunk(ChunkFrame(messageID: item.messageID, uti: uti, seq: seq, bytes: slice))) - seq += 1 - offset = end - } - } - - frames.append(.itemEnd(item.messageID)) - return frames - } -} diff --git a/DeviceLinkKit/Sources/DeviceLinkProtocol/InboundAssembler.swift b/DeviceLinkKit/Sources/DeviceLinkProtocol/InboundAssembler.swift deleted file mode 100644 index f5271c6..0000000 --- a/DeviceLinkKit/Sources/DeviceLinkProtocol/InboundAssembler.swift +++ /dev/null @@ -1,93 +0,0 @@ -import Foundation - -/// Reassembles streamed frames into complete `LinkItem`s. Pure (no I/O); holds only the bytes of items -/// currently in flight. Feed it decoded `Frame`s in arrival order; it emits an item on `itemEnd`, passes -/// control frames through, and throws a typed `LinkProtocolError` on a protocol violation. -/// -/// For very large files a transport MAY bypass this and stream chunks straight to disk (see the design's -/// D4 disk-streaming seam); this assembler is the simple, correct in-memory path used for control and -/// small/medium items. -public struct InboundAssembler { - /// The result of consuming one frame. - public enum Output: Equatable, Sendable { - case item(LinkItem) // a complete item was reassembled - case control(Frame) // a hello/ack/error passed through for the transport to handle - case none // progress was made; nothing to surface yet - } - - private struct InFlight { - var header: ItemHeader - var buffers: [String: Data] // uti -> accumulated bytes - var nextSeq: [String: UInt32] // uti -> expected next chunk index - } - - private var inFlight: [UUID: InFlight] = [:] - - public init() {} - - /// Items currently being reassembled (diagnostics / tests). - public var inFlightCount: Int { inFlight.count } - - public mutating func consume(_ frame: Frame) throws -> Output { - switch frame { - case .hello, .ack, .error, .authHello, .authConfirm: - return .control(frame) - - case let .itemBegin(header): - guard inFlight[header.messageID] == nil else { - throw LinkProtocolError(.duplicateMessage) - } - inFlight[header.messageID] = InFlight(header: header, buffers: [:], nextSeq: [:]) - return .none - - case let .chunk(chunk): - guard var flight = inFlight[chunk.messageID] else { - throw LinkProtocolError(.unknownMessage) - } - guard let total = flight.header.manifest[chunk.uti] else { - inFlight[chunk.messageID] = nil - throw LinkProtocolError(.manifestMismatch) - } - let expected = flight.nextSeq[chunk.uti] ?? 0 - guard chunk.seq == expected else { - inFlight[chunk.messageID] = nil - throw LinkProtocolError(.badSequence) - } - var accumulated = flight.buffers[chunk.uti] ?? Data() - accumulated.append(chunk.bytes) - guard accumulated.count <= Int(total) else { - inFlight[chunk.messageID] = nil - throw LinkProtocolError(.manifestMismatch) - } - flight.buffers[chunk.uti] = accumulated - flight.nextSeq[chunk.uti] = expected + 1 - inFlight[chunk.messageID] = flight - return .none - - case let .itemEnd(id): - guard let flight = inFlight[id] else { - throw LinkProtocolError(.unknownMessage) - } - // Every declared representation must be exactly complete. - for (uti, total) in flight.header.manifest { - let have = flight.buffers[uti]?.count ?? 0 - guard have == Int(total) else { - inFlight[id] = nil - throw LinkProtocolError(.manifestMismatch) - } - } - inFlight[id] = nil - let item = LinkItem(messageID: id, - kind: flight.header.kind, - representations: flight.buffers, - suggestedName: flight.header.suggestedName, - capturedAt: flight.header.capturedAt, - origin: flight.header.origin) - return .item(item) - - case let .cancel(id): - inFlight[id] = nil // discard partial state; not an error - return .none - } - } -} diff --git a/DeviceLinkKit/Sources/DeviceLinkProtocol/LinkCodec.swift b/DeviceLinkKit/Sources/DeviceLinkProtocol/LinkCodec.swift deleted file mode 100644 index c55235d..0000000 --- a/DeviceLinkKit/Sources/DeviceLinkProtocol/LinkCodec.swift +++ /dev/null @@ -1,220 +0,0 @@ -import Foundation - -/// The length-prefixed binary codec. Frame envelope on the wire: -/// -/// magic(4) | wireFormatVersion(1) | frameType(1) | length(UInt32 BE) | payload(length) -/// -/// Control/header frame bodies (`hello`/`ack`/`error`/`itemBegin`/`itemEnd`/`cancel`) are encoded with -/// a deterministic JSON encoder (small, structural, forward-evolvable). `chunk` bodies are raw bytes -/// with a fixed sub-header — never JSON/base64 — so large transfers stream without inflation. -public enum LinkCodec { - static let magic: [UInt8] = [0x54, 0x46, 0x53, 0x4C] // "TFSL" - static let wireFormatVersion: UInt8 = 1 - /// Envelope size: magic(4) + version(1) + type(1) + length(4). - static let envelopePrefix = 10 - - // MARK: Encode - - public static func encode(_ frame: Frame) throws -> Data { - let (type, payload) = try encodePayload(frame) - var out = Data() - out.append(contentsOf: magic) - out.append(wireFormatVersion) - out.append(type.rawValue) - appendU32BE(UInt32(payload.count), to: &out) - out.append(payload) - return out - } - - static func encodePayload(_ frame: Frame) throws -> (FrameType, Data) { - switch frame { - case let .hello(identity, version): - return (.hello, try json.encode(HelloBody(identity: identity, version: version))) - case let .ack(id): - return (.ack, try json.encode(IDBody(messageID: id))) - case let .error(code): - return (.error, try json.encode(ErrorBody(code: code))) - case let .itemBegin(header): - return (.itemBegin, try json.encode(header)) - case let .chunk(chunk): - return (.chunk, encodeChunk(chunk)) - case let .itemEnd(id): - return (.itemEnd, try json.encode(IDBody(messageID: id))) - case let .cancel(id): - return (.cancel, try json.encode(IDBody(messageID: id))) - case let .authHello(staticPub, ephemeralPub, identity): - return (.authHello, try json.encode(AuthHelloBody(staticPub: staticPub, ephemeralPub: ephemeralPub, identity: identity))) - case let .authConfirm(mac): - return (.authConfirm, try json.encode(AuthConfirmBody(mac: mac))) - } - } - - static func encodeChunk(_ chunk: ChunkFrame) -> Data { - var out = Data() - out.append(uuidBytes(chunk.messageID)) // 16 - let utiBytes = Array(chunk.uti.utf8) - appendU16BE(UInt16(utiBytes.count), to: &out) // 2 - out.append(contentsOf: utiBytes) // utiLen - appendU32BE(chunk.seq, to: &out) // 4 - out.append(chunk.bytes) // rest - return out - } - - // MARK: Decode - - static func decodePayload(type: FrameType, payload: Data) throws -> Frame { - do { - switch type { - case .hello: - let body = try json.decode(HelloBody.self, from: payload) - return .hello(body.identity, body.version) - case .ack: - return .ack(try json.decode(IDBody.self, from: payload).messageID) - case .error: - return .error(try json.decode(ErrorBody.self, from: payload).code) - case .itemBegin: - return .itemBegin(try json.decode(ItemHeader.self, from: payload)) - case .chunk: - return .chunk(try decodeChunk(payload)) - case .itemEnd: - return .itemEnd(try json.decode(IDBody.self, from: payload).messageID) - case .cancel: - return .cancel(try json.decode(IDBody.self, from: payload).messageID) - case .authHello: - let body = try json.decode(AuthHelloBody.self, from: payload) - return .authHello(staticPub: body.staticPub, ephemeralPub: body.ephemeralPub, identity: body.identity) - case .authConfirm: - return .authConfirm(mac: try json.decode(AuthConfirmBody.self, from: payload).mac) - } - } catch let error as LinkProtocolError { - throw error - } catch { - throw LinkProtocolError(.malformedPayload) - } - } - - static func decodeChunk(_ payload: Data) throws -> ChunkFrame { - guard payload.count >= 16 + 2 else { throw LinkProtocolError(.malformedPayload) } - let messageID = uuid(from: payload, offset: 0) - let utiLen = Int(u16(payload, 16)) - let utiStart = 18 - guard payload.count >= utiStart + utiLen + 4 else { throw LinkProtocolError(.malformedPayload) } - let s = payload.startIndex - let uti = String(decoding: payload[(s + utiStart)..<(s + utiStart + utiLen)], as: UTF8.self) - let seq = u32(payload, utiStart + utiLen) - let bytesStart = utiStart + utiLen + 4 - let bytes = Data(payload[(s + bytesStart)...]) - return ChunkFrame(messageID: messageID, uti: uti, seq: seq, bytes: bytes) - } - - // MARK: Codable bodies (control/header frames) - - static let json: JSONCoder = JSONCoder() - - struct HelloBody: Codable { var identity: DeviceIdentity; var version: ProtocolVersion } - struct IDBody: Codable { var messageID: UUID } - struct ErrorBody: Codable { var code: LinkProtocolError.Code } - struct AuthHelloBody: Codable { var staticPub: Data; var ephemeralPub: Data; var identity: DeviceIdentity } - struct AuthConfirmBody: Codable { var mac: Data } - - // MARK: Byte helpers - - static func appendU16BE(_ v: UInt16, to data: inout Data) { - data.append(UInt8((v >> 8) & 0xff)) - data.append(UInt8(v & 0xff)) - } - - static func appendU32BE(_ v: UInt32, to data: inout Data) { - data.append(UInt8((v >> 24) & 0xff)) - data.append(UInt8((v >> 16) & 0xff)) - data.append(UInt8((v >> 8) & 0xff)) - data.append(UInt8(v & 0xff)) - } - - static func u16(_ d: Data, _ offset: Int) -> UInt16 { - let s = d.startIndex + offset - return (UInt16(d[s]) << 8) | UInt16(d[s + 1]) - } - - static func u32(_ d: Data, _ offset: Int) -> UInt32 { - let s = d.startIndex + offset - return (UInt32(d[s]) << 24) | (UInt32(d[s + 1]) << 16) | (UInt32(d[s + 2]) << 8) | UInt32(d[s + 3]) - } - - static func uuidBytes(_ uuid: UUID) -> Data { - var u = uuid.uuid - return withUnsafeBytes(of: &u) { Data($0) } - } - - static func uuid(from d: Data, offset: Int) -> UUID { - var bytes = [UInt8](repeating: 0, count: 16) - let s = d.startIndex + offset - for i in 0..<16 { bytes[i] = d[s + i] } - return bytes.withUnsafeBytes { UUID(uuid: $0.load(as: uuid_t.self)) } - } -} - -/// A small deterministic JSON encode/decode pair (sorted keys → reproducible bytes). Wrapped so the -/// codec holds one instance and tests can rely on byte-stable output. -struct JSONCoder { - private let encoder: JSONEncoder - private let decoder: JSONDecoder - - init() { - let e = JSONEncoder() - e.outputFormatting = [.sortedKeys] - self.encoder = e - self.decoder = JSONDecoder() - } - - func encode(_ value: T) throws -> Data { try encoder.encode(value) } - func decode(_ type: T.Type, from data: Data) throws -> T { try decoder.decode(type, from: data) } -} - -/// A streaming frame splitter. Push bytes as they arrive; pull complete `Frame`s until `next()` returns -/// nil (needs more bytes). Reassembles a frame across multiple reads, enforces a max-frame cap, and -/// throws a typed `LinkProtocolError` on a malformed/oversize stream. -public struct FrameDecoder { - public var maxFrameLength: Int - private var buffer = Data() - - public init(maxFrameLength: Int = LinkProtocol.defaultMaxFrameLength) { - self.maxFrameLength = maxFrameLength - } - - /// Append newly-received bytes. - public mutating func push(_ data: Data) { - buffer.append(data) - } - - /// The next complete frame, or nil if more bytes are needed. Throws on a malformed/oversize stream. - public mutating func next() throws -> Frame? { - guard buffer.count >= LinkCodec.envelopePrefix else { return nil } - let s = buffer.startIndex - for i in 0..<4 where buffer[s + i] != LinkCodec.magic[i] { - throw LinkProtocolError(.badMagic) - } - guard buffer[s + 4] == LinkCodec.wireFormatVersion else { - throw LinkProtocolError(.unsupportedVersion) - } - guard let type = FrameType(rawValue: buffer[s + 5]) else { - throw LinkProtocolError(.unknownFrameType) - } - let length = Int(LinkCodec.u32(buffer, 6)) - guard length <= maxFrameLength else { throw LinkProtocolError(.oversizeLength) } - let total = LinkCodec.envelopePrefix + length - guard buffer.count >= total else { return nil } // need more bytes - let payload = Data(buffer[(s + LinkCodec.envelopePrefix)..<(s + total)]) - buffer = Data(buffer[(s + total)...]) // advance + reset indices - return try LinkCodec.decodePayload(type: type, payload: payload) - } - - /// Number of buffered bytes not yet consumed (a partial frame). - public var bufferedByteCount: Int { buffer.count } - - /// Call when the underlying stream has closed AFTER draining all frames via `next()`. Throws - /// `.truncatedFrame` if a partial frame remains. - public func close() throws { - if !buffer.isEmpty { throw LinkProtocolError(.truncatedFrame) } - } -} diff --git a/DeviceLinkKit/Sources/DeviceLinkProtocol/LinkItem.swift b/DeviceLinkKit/Sources/DeviceLinkProtocol/LinkItem.swift deleted file mode 100644 index 85103d2..0000000 --- a/DeviceLinkKit/Sources/DeviceLinkProtocol/LinkItem.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation - -/// What kind of content a moved item holds — mirrors the Mac clipboard's `ClipboardKind` shape so the -/// inbound adapter can map 1:1, but defined here independently (the wire never couples to storage). -public enum LinkItemKind: String, Codable, Equatable, Sendable, CaseIterable { - case text - case richText - case image - case color - case url - case file -} - -/// The transport DTO for one moved item: a kind plus its representations keyed by UTI string, with -/// optional metadata. Pure value type, `Sendable`, no AppKit/UIKit. The Mac side maps `LinkItem ⇄ -/// ClipboardEntry` at its boundary; the iOS side stores it directly. `representations` holds the -/// **materialized** bytes of an assembled item (the assembler bounds this to items in flight). -public struct LinkItem: Equatable, Sendable { - public var messageID: UUID - public var kind: LinkItemKind - /// UTI string → representation bytes. The same UTIs that appear in the item's `ItemHeader.manifest`. - public var representations: [String: Data] - public var suggestedName: String? - public var capturedAt: Date? - /// The device that originated the item (set by the sender; used for a "from iPhone/Mac" chip). - public var origin: DeviceIdentity? - - public init(messageID: UUID, - kind: LinkItemKind, - representations: [String: Data], - suggestedName: String? = nil, - capturedAt: Date? = nil, - origin: DeviceIdentity? = nil) { - self.messageID = messageID - self.kind = kind - self.representations = representations - self.suggestedName = suggestedName - self.capturedAt = capturedAt - self.origin = origin - } -} diff --git a/DeviceLinkKit/Sources/DeviceLinkProtocol/LinkProtocolError.swift b/DeviceLinkKit/Sources/DeviceLinkProtocol/LinkProtocolError.swift deleted file mode 100644 index 73ad3af..0000000 --- a/DeviceLinkKit/Sources/DeviceLinkProtocol/LinkProtocolError.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation - -/// The single error taxonomy for the protocol. Every decode/reassembly failure maps to one of these -/// cases — the package never surfaces a raw Foundation/decoding error to callers. Transports map these -/// at their boundary into their own presented errors. Mirrors the app's one-taxonomy convention. -public struct LinkProtocolError: Error, Equatable, Sendable { - public enum Code: String, Codable, Equatable, Sendable { - case badMagic // frame did not begin with the protocol magic - case unsupportedVersion // wire-format version byte (or negotiated major) not understood - case unknownFrameType // frame type tag not recognized - case oversizeLength // declared frame length exceeds the configured cap - case truncatedFrame // stream ended mid-frame - case malformedPayload // a frame body failed to decode - case manifestMismatch // accumulated bytes do not match the header manifest - case unknownMessage // a chunk/end for a message with no live header - case duplicateMessage // a second itemBegin for a live message id - case badSequence // a chunk out of sequence - case cancelled // the message was cancelled mid-flight - } - - public var code: Code - - public init(_ code: Code) { - self.code = code - } -} - -extension LinkProtocolError: LocalizedError { - public var errorDescription: String? { - switch code { - case .badMagic: return "Not a device-link stream." - case .unsupportedVersion: return "The other device speaks an incompatible link version." - case .unknownFrameType: return "Received an unrecognized message." - case .oversizeLength: return "A message exceeded the allowed size." - case .truncatedFrame: return "The connection ended mid-transfer." - case .malformedPayload: return "A message was malformed." - case .manifestMismatch: return "A transfer did not match its declared size." - case .unknownMessage: return "Received data for an unknown transfer." - case .duplicateMessage: return "Received a duplicate transfer." - case .badSequence: return "A transfer arrived out of order." - case .cancelled: return "The transfer was cancelled." - } - } -} diff --git a/DeviceLinkKit/Sources/DeviceLinkProtocol/LinkPump.swift b/DeviceLinkKit/Sources/DeviceLinkProtocol/LinkPump.swift deleted file mode 100644 index cc5ff89..0000000 --- a/DeviceLinkKit/Sources/DeviceLinkProtocol/LinkPump.swift +++ /dev/null @@ -1,59 +0,0 @@ -import Foundation - -/// The single synchronous bridge between `LinkItem`s and the raw bytes on a channel. Composes the -/// frame encoder, codec, decoder, and inbound assembler so a transport never re-wires them: call -/// `outbound(_:)` to get the byte buffers to write, and `ingest(_:)` on each received buffer to get -/// back completed items / control frames. Pure (no I/O); the transport owns the async channel and its -/// own serial context, so the pump is a plain `mutating struct`, one per connection. -public struct LinkPump { - private let encoder: FrameStreamEncoder - private var decoder: FrameDecoder - private var assembler: InboundAssembler - - /// A completed inbound result from `ingest`. - public enum Inbound: Equatable, Sendable { - case item(LinkItem) - case control(Frame) // hello / ack / error — for the transport's handshake/ack logic - } - - public init(chunkByteBound: Int = LinkProtocol.defaultChunkByteBound, - maxFrameLength: Int = LinkProtocol.defaultMaxFrameLength) { - self.encoder = FrameStreamEncoder(chunkByteBound: chunkByteBound) - self.decoder = FrameDecoder(maxFrameLength: maxFrameLength) - self.assembler = InboundAssembler() - } - - // MARK: Outbound - - /// The ordered encoded byte buffers for an item (one complete encoded frame each). - public func outbound(_ item: LinkItem) throws -> [Data] { - try encoder.frames(for: item).map { try LinkCodec.encode($0) } - } - - /// Encode a single control frame (hello/ack/error) to bytes. - public func outbound(control frame: Frame) throws -> Data { - try LinkCodec.encode(frame) - } - - // MARK: Inbound - - /// Push received bytes; return any completed inbound results. Throws a typed `LinkProtocolError` - /// on a malformed/violating stream. - public mutating func ingest(_ data: Data) throws -> [Inbound] { - decoder.push(data) - var out: [Inbound] = [] - while let frame = try decoder.next() { - switch try assembler.consume(frame) { - case .item(let item): out.append(.item(item)) - case .control(let f): out.append(.control(f)) - case .none: break - } - } - return out - } - - /// Assert the stream ended cleanly (no partial frame buffered). Throws `.truncatedFrame` otherwise. - public func finish() throws { - try decoder.close() - } -} diff --git a/DeviceLinkKit/Sources/DeviceLinkProtocol/LinkUTI.swift b/DeviceLinkKit/Sources/DeviceLinkProtocol/LinkUTI.swift deleted file mode 100644 index 24f140f..0000000 --- a/DeviceLinkKit/Sources/DeviceLinkProtocol/LinkUTI.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Foundation - -/// Shared UTI string constants so both ends label representations identically (the manifest keys in an -/// `ItemHeader` and the `uti` in a `ChunkFrame` must match across devices). Deliberately mirrors the -/// Mac's `ClipboardUTI` values; kept as plain strings so this package stays AppKit/UIKit-free. -public enum LinkUTI { - public static let plainText = "public.utf8-plain-text" - public static let rtf = "public.rtf" - public static let png = "public.png" - public static let tiff = "public.tiff" - public static let fileURL = "public.file-url" - public static let url = "public.url" - public static let color = "com.apple.cocoa.pasteboard.color" -} diff --git a/DeviceLinkKit/Sources/DeviceLinkProtocol/ProtocolVersion.swift b/DeviceLinkKit/Sources/DeviceLinkProtocol/ProtocolVersion.swift deleted file mode 100644 index e4164f4..0000000 --- a/DeviceLinkKit/Sources/DeviceLinkProtocol/ProtocolVersion.swift +++ /dev/null @@ -1,46 +0,0 @@ -import Foundation - -/// The negotiated protocol version (carried in `hello`). Major bumps are breaking — a peer with a -/// different major is refused at the handshake rather than mis-parsed. A newer minor is accepted -/// (additive, optional fields default). -public struct ProtocolVersion: Equatable, Sendable, Codable { - public var major: UInt16 - public var minor: UInt16 - - public init(major: UInt16, minor: UInt16) { - self.major = major - self.minor = minor - } - - /// Compatible iff the major versions match. The receiver tolerates a peer on any minor. - public func isCompatible(with other: ProtocolVersion) -> Bool { - major == other.major - } -} - -/// A device's identity on the link: a stable id plus a human-readable name (shown in pairing UI). -public struct DeviceIdentity: Equatable, Sendable, Codable { - public var id: String - public var name: String - - public init(id: String, name: String) { - self.id = id - self.name = name - } -} - -/// Protocol-wide constants. Distinct from `ProtocolVersion` (the negotiated semantic version): these -/// are the wire-format/codec knobs. -public enum LinkProtocol { - /// The semantic protocol version this build speaks. - public static let version = ProtocolVersion(major: 1, minor: 0) - - /// Default upper bound on a single `chunk` frame's representation bytes. Senders SHOULD split a - /// representation larger than this into multiple chunks. (Tunable; not part of the wire contract.) - public static let defaultChunkByteBound = 256 * 1024 - - /// Default hard cap on a single decoded frame's declared length — a guard against an oversize-length - /// stream consuming unbounded memory. Large representations are *many* chunks, so no single frame is - /// huge. (Tunable; not part of the wire contract.) - public static let defaultMaxFrameLength = 8 * 1024 * 1024 -} diff --git a/DeviceLinkKit/Tests/DeviceLinkMirrorTests/MovedItemStoreTests.swift b/DeviceLinkKit/Tests/DeviceLinkMirrorTests/MovedItemStoreTests.swift deleted file mode 100644 index 9a8a28e..0000000 --- a/DeviceLinkKit/Tests/DeviceLinkMirrorTests/MovedItemStoreTests.swift +++ /dev/null @@ -1,93 +0,0 @@ -import XCTest -import DeviceLinkProtocol -@testable import DeviceLinkMirror - -final class MovedItemStoreTests: XCTestCase { - - private var dir: URL! - override func setUpWithError() throws { - dir = FileManager.default.temporaryDirectory.appendingPathComponent("tfs-mirror-\(UUID().uuidString)") - } - override func tearDownWithError() throws { try? FileManager.default.removeItem(at: dir) } - - private func blobCount() -> Int { - (try? FileManager.default.contentsOfDirectory(at: dir.appendingPathComponent("blobs"), includingPropertiesForKeys: nil))?.count ?? 0 - } - - private func textItem(_ s: String, at t: TimeInterval, direction: MoveDirection = .received) -> MovedItem { - let link = LinkItem(messageID: UUID(), kind: .text, representations: [LinkUTI.plainText: Data(s.utf8)], - origin: DeviceIdentity(id: "mac", name: "Mac")) - return MovedItem.from(link, direction: direction, at: Date(timeIntervalSince1970: t)) - } - - // MARK: Mapping - - func testMappingTextTitleAndReps() { - let item = textItem("hello\nworld", at: 1) - XCTAssertEqual(item.kind, .text) - XCTAssertEqual(item.title, "hello") - XCTAssertEqual(item.peerName, "Mac") - XCTAssertEqual(item.representations[LinkUTI.plainText], Data("hello\nworld".utf8)) - } - - func testMappingFileTitle() { - let link = LinkItem(messageID: UUID(), kind: .file, representations: ["public.data": Data([1, 2, 3])], - suggestedName: "report.pdf") - let item = MovedItem.from(link, direction: .sent, at: Date(timeIntervalSince1970: 1)) - XCTAssertEqual(item.title, "report.pdf") - XCTAssertEqual(item.direction, .sent) - } - - // MARK: Store - - func testInsertListNewestFirst() { - let store = MovedItemStore(directory: dir) - store.insert(textItem("old", at: 100)) - store.insert(textItem("new", at: 200)) - XCTAssertEqual(store.list().map(\.title), ["new", "old"]) - } - - func testBytesSurviveReload() { - let bytes = Data((0..<5000).map { UInt8($0 & 0xff) }) - let link = LinkItem(messageID: UUID(), kind: .image, representations: [LinkUTI.png: bytes]) - let store = MovedItemStore(directory: dir) - store.insert(MovedItem.from(link, direction: .received, at: Date(timeIntervalSince1970: 1))) - - let reloaded = MovedItemStore(directory: dir) - XCTAssertEqual(reloaded.list().first?.representations[LinkUTI.png], bytes) - } - - func testReplaceBySameID() { - let store = MovedItemStore(directory: dir) - var item = textItem("first", at: 1) - store.insert(item) - item.title = "second" - store.insert(item) // same id - XCTAssertEqual(store.count, 1) - XCTAssertEqual(store.list().first?.title, "second") - } - - func testRemoveAndClearDeleteBlobs() { - let store = MovedItemStore(directory: dir) - let a = textItem("a", at: 1) - store.insert(a) - store.insert(textItem("b", at: 2)) - XCTAssertEqual(blobCount(), 2) - store.remove(id: a.id) - XCTAssertEqual(store.count, 1) - XCTAssertEqual(blobCount(), 1) - store.clear() - XCTAssertEqual(store.count, 0) - XCTAssertEqual(blobCount(), 0) - } - - func testCountCapEvictsOldestAndDeletesBlobs() { - let store = MovedItemStore(directory: dir, maxCount: 2) - store.insert(textItem("a", at: 100)) - store.insert(textItem("b", at: 200)) - store.insert(textItem("c", at: 300)) // evicts "a" - XCTAssertEqual(store.list().map(\.title), ["c", "b"]) - XCTAssertEqual(store.count, 2) - XCTAssertEqual(blobCount(), 2, "evicted item's blob is deleted") - } -} diff --git a/DeviceLinkKit/Tests/DeviceLinkPairingTests/PairingTests.swift b/DeviceLinkKit/Tests/DeviceLinkPairingTests/PairingTests.swift deleted file mode 100644 index 58ba6d8..0000000 --- a/DeviceLinkKit/Tests/DeviceLinkPairingTests/PairingTests.swift +++ /dev/null @@ -1,58 +0,0 @@ -import XCTest -import CryptoKit -@testable import DeviceLinkPairing - -/// The shared pairing crypto: code format, and the code-authenticated X25519 confirmation including the -/// MITM-resistance property and role independence. -final class PairingTests: XCTestCase { - - // MARK: Code - - func testCodeGenerationFormat() { - let code = PairingCode.generate() - XCTAssertEqual(code.count, 8) - XCTAssertTrue(code.allSatisfy { $0.isNumber }) - XCTAssertTrue(PairingCode.isValid(code)) - } - - func testCodeValidation() { - XCTAssertTrue(PairingCode.isValid("12345678")) - XCTAssertFalse(PairingCode.isValid("1234567")) - XCTAssertFalse(PairingCode.isValid("1234567a")) - XCTAssertFalse(PairingCode.isValid("123456789")) - } - - // MARK: Handshake - - func testSameCodeBothSidesAgree() throws { - let mac = PairingHandshake() - let phone = PairingHandshake() - let code = "12345678" - let kMac = try mac.confirmationKey(peerPublicKey: phone.publicKey, code: code) - let kPhone = try phone.confirmationKey(peerPublicKey: mac.publicKey, code: code) - - let macConfirm = mac.confirmation(kMac, label: "mac→phone") - XCTAssertTrue(phone.verify(macConfirm, key: kPhone, label: "mac→phone")) - let phoneConfirm = phone.confirmation(kPhone, label: "phone→mac") - XCTAssertTrue(mac.verify(phoneConfirm, key: kMac, label: "phone→mac")) - } - - func testDifferentCodeDefeatsMITM() throws { - let mac = PairingHandshake() - let phone = PairingHandshake() - let kMac = try mac.confirmationKey(peerPublicKey: phone.publicKey, code: "12345678") - let kPhoneWrong = try phone.confirmationKey(peerPublicKey: mac.publicKey, code: "87654321") - let macConfirm = mac.confirmation(kMac, label: "mac→phone") - XCTAssertFalse(phone.verify(macConfirm, key: kPhoneWrong, label: "mac→phone"), - "a different code must fail confirmation — this is the MITM defense") - } - - func testRoleIndependentDerivation() throws { - let mac = PairingHandshake() - let phone = PairingHandshake() - let code = "55554444" - let kMac = try mac.confirmationKey(peerPublicKey: phone.publicKey, code: code) - let kPhone = try phone.confirmationKey(peerPublicKey: mac.publicKey, code: code) - XCTAssertEqual(mac.confirmation(kMac, label: "x"), phone.confirmation(kPhone, label: "x")) - } -} diff --git a/DeviceLinkKit/Tests/DeviceLinkPairingTests/QRPairingTests.swift b/DeviceLinkKit/Tests/DeviceLinkPairingTests/QRPairingTests.swift deleted file mode 100644 index 40133d2..0000000 --- a/DeviceLinkKit/Tests/DeviceLinkPairingTests/QRPairingTests.swift +++ /dev/null @@ -1,136 +0,0 @@ -import XCTest -import DeviceLinkProtocol -@testable import DeviceLinkPairing - -/// QR pairing: the payload string codec, and the authenticated exchange ending in mutual pinning -/// (including MITM and tamper resistance). -final class QRPairingTests: XCTestCase { - - private let host = DeviceIdentity(id: "mac-1", name: "Mac") - private let joiner = DeviceIdentity(id: "phone-1", name: "iPhone") - private let hostSPKI = Data((0..<32).map { UInt8($0) }) - private let joinerSPKI = Data((0..<32).map { UInt8(255 - $0) }) - - // MARK: Payload - - func testPayloadRoundTrips() throws { - let secret = PairingQRPayload.makeSecret() - let payload = PairingQRPayload(device: host, secret: secret, spkiFingerprint: hostSPKI) - let decoded = try PairingQRPayload(string: payload.encodedString()) - XCTAssertEqual(decoded, payload) - XCTAssertEqual(decoded.device, host) - XCTAssertEqual(decoded.secret, secret) - XCTAssertEqual(decoded.spkiFingerprint, hostSPKI) - } - - func testSecretIs32Bytes() { - XCTAssertEqual(PairingQRPayload.makeSecret().count, 32) - // Two secrets should (overwhelmingly) differ. - XCTAssertNotEqual(PairingQRPayload.makeSecret(), PairingQRPayload.makeSecret()) - } - - func testBadSchemeRejected() { - XCTAssertThrowsError(try PairingQRPayload(string: "https://example.com/x")) { - XCTAssertEqual($0 as? PairingQRError, .badScheme) - } - } - - func testBadVersionRejected() throws { - var payload = PairingQRPayload(device: host, secret: PairingQRPayload.makeSecret(), spkiFingerprint: hostSPKI) - payload.version = 99 - XCTAssertThrowsError(try PairingQRPayload(string: payload.encodedString())) { - XCTAssertEqual($0 as? PairingQRError, .unsupportedVersion) - } - } - - func testV2EndpointRoundTrips() throws { - let secret = PairingQRPayload.makeSecret() - let payload = PairingQRPayload(device: host, secret: secret, spkiFingerprint: hostSPKI, - addresses: ["10.0.0.21", "2a06:c701::1"], port: 52344) - let decoded = try PairingQRPayload(string: payload.encodedString()) - XCTAssertEqual(decoded, payload) - XCTAssertEqual(decoded.version, PairingQRPayload.currentVersion) - XCTAssertEqual(decoded.addresses, ["10.0.0.21", "2a06:c701::1"]) - XCTAssertEqual(decoded.port, 52344) - XCTAssertTrue(decoded.hasEndpoint) - } - - func testV1BackCompatDecodesWithNoEndpoint() throws { - // A v1 payload (no addresses/port) must still decode — the scanner then falls back to discovery. - let secret = PairingQRPayload.makeSecret() - let v1 = PairingQRPayload(device: host, secret: secret, spkiFingerprint: hostSPKI, version: 1) - let decoded = try PairingQRPayload(string: v1.encodedString()) - XCTAssertEqual(decoded.version, 1) - XCTAssertTrue(decoded.addresses.isEmpty) - XCTAssertNil(decoded.port) - XCTAssertFalse(decoded.hasEndpoint) - XCTAssertEqual(decoded, v1) - } - - func testV2WithEmptyAddressesDecodesWithoutEndpoint() throws { - let payload = PairingQRPayload(device: host, secret: PairingQRPayload.makeSecret(), spkiFingerprint: hostSPKI) - XCTAssertEqual(payload.version, PairingQRPayload.currentVersion) - let decoded = try PairingQRPayload(string: payload.encodedString()) - XCTAssertTrue(decoded.addresses.isEmpty) - XCTAssertNil(decoded.port) - XCTAssertFalse(decoded.hasEndpoint) - XCTAssertEqual(decoded, payload) - } - - func testLocalAddressesAreRoutableAndCapped() { - let addrs = LocalAddresses.current(limit: 4) - XCTAssertLessThanOrEqual(addrs.count, 4) - for a in addrs { - XCTAssertFalse(a.hasPrefix("169.254."), "link-local IPv4 must be excluded") - XCTAssertFalse(a.lowercased().hasPrefix("fe80:"), "link-local IPv6 must be excluded") - XCTAssertNotEqual(a, "127.0.0.1") - XCTAssertNotEqual(a, "::1") - } - } - - // MARK: Exchange - - /// Drive a full host↔joiner exchange and return the two results. - private func runExchange(hostSecret: Data, joinerSecret: Data) throws -> (joiner: PairingExchange.Result?, host: PairingExchange.Result?) { - var j = PairingExchange(role: .joiner, secret: joinerSecret, identity: joiner, spkiFingerprint: joinerSPKI) - var h = PairingExchange(role: .host, secret: hostSecret, identity: host, spkiFingerprint: hostSPKI) - let m1 = j.start()! - let (m2, r2) = try h.consume(m1) - XCTAssertNil(r2) - let (m3, rJoiner) = try j.consume(m2!) - let (_, rHost) = m3 != nil ? try h.consume(m3!) : (nil, nil) - return (rJoiner, rHost) - } - - func testMatchingSecretMutuallyPins() throws { - let secret = PairingQRPayload.makeSecret() - let (rJoiner, rHost) = try runExchange(hostSecret: secret, joinerSecret: secret) - - guard case let .pinned(pinnedHostID, pinnedHostSPKI) = rJoiner else { return XCTFail("joiner not pinned") } - XCTAssertEqual(pinnedHostID, host) - XCTAssertEqual(pinnedHostSPKI, hostSPKI) - - guard case let .pinned(pinnedJoinerID, pinnedJoinerSPKI) = rHost else { return XCTFail("host not pinned") } - XCTAssertEqual(pinnedJoinerID, joiner) - XCTAssertEqual(pinnedJoinerSPKI, joinerSPKI) - } - - func testDifferentSecretDefeatsMITM() throws { - let (rJoiner, _) = try runExchange(hostSecret: PairingQRPayload.makeSecret(), - joinerSecret: PairingQRPayload.makeSecret()) - XCTAssertEqual(rJoiner, .failed, "a different secret must fail confirmation") - } - - func testTamperedConfirmationFails() throws { - let secret = PairingQRPayload.makeSecret() - var j = PairingExchange(role: .joiner, secret: secret, identity: joiner, spkiFingerprint: joinerSPKI) - var h = PairingExchange(role: .host, secret: secret, identity: host, spkiFingerprint: hostSPKI) - let m1 = j.start()! - let (m2, _) = try h.consume(m1) - guard case let .hostHello(e, i, s, c) = m2! else { return XCTFail("expected hostHello") } - var bad = c; bad[0] ^= 0xFF - let tampered = PairingMessage.hostHello(ephemeral: e, identity: i, spki: s, confirm: bad) - let (_, result) = try j.consume(tampered) - XCTAssertEqual(result, .failed, "a tampered confirmation must fail") - } -} diff --git a/DeviceLinkKit/Tests/DeviceLinkProtocolTests/FrameStreamEncoderTests.swift b/DeviceLinkKit/Tests/DeviceLinkProtocolTests/FrameStreamEncoderTests.swift deleted file mode 100644 index 497adc6..0000000 --- a/DeviceLinkKit/Tests/DeviceLinkProtocolTests/FrameStreamEncoderTests.swift +++ /dev/null @@ -1,74 +0,0 @@ -import XCTest -@testable import DeviceLinkProtocol - -/// The send-side encoder + the closed encode→decode round-trip against InboundAssembler. -final class FrameStreamEncoderTests: XCTestCase { - - private let id = UUID(uuidString: "DDDDDDDD-0000-0000-0000-000000000001")! - - /// Drive every encoded frame through an assembler and return the reassembled item. - private func reassemble(_ frames: [Frame]) throws -> LinkItem? { - var assembler = InboundAssembler() - var out: LinkItem? - for f in frames { - if case let .item(item) = try assembler.consume(f) { out = item } - } - return out - } - - func testSmallSingleRepEncoding() { - let item = LinkItem(messageID: id, kind: .text, representations: [LinkUTI.plainText: Data("hi".utf8)]) - let frames = FrameStreamEncoder().frames(for: item) - XCTAssertEqual(frames.count, 3) - guard case let .itemBegin(header) = frames[0] else { return XCTFail("expected itemBegin") } - XCTAssertEqual(header.manifest[LinkUTI.plainText], 2) - guard case let .chunk(c) = frames[1] else { return XCTFail("expected chunk") } - XCTAssertEqual(c.seq, 0) - XCTAssertEqual(c.bytes, Data("hi".utf8)) - guard case .itemEnd = frames[2] else { return XCTFail("expected itemEnd") } - } - - func testLargeRepIsBoundedAndConsecutive() { - let big = Data((0..<1000).map { UInt8($0 & 0xff) }) - let item = LinkItem(messageID: id, kind: .file, representations: [LinkUTI.fileURL: big], suggestedName: "x.bin") - let bound = 256 - let frames = FrameStreamEncoder(chunkByteBound: bound).frames(for: item) - let chunks: [ChunkFrame] = frames.compactMap { if case let .chunk(c) = $0 { return c }; return nil } - XCTAssertEqual(chunks.count, (1000 + bound - 1) / bound) // ceil - XCTAssertEqual(chunks.map(\.seq), Array(0.. bound → multi-chunk - LinkUTI.plainText: Data("caption".utf8), // small - ], - suggestedName: "pic.png", - capturedAt: nil, - origin: DeviceIdentity(id: "dev", name: "iPhone")) - let frames = FrameStreamEncoder(chunkByteBound: 128).frames(for: item) - let back = try reassemble(frames) - XCTAssertEqual(back, item) - } - - func testRoundTripEmptyRepresentation() throws { - let item = LinkItem(messageID: id, kind: .text, - representations: [LinkUTI.plainText: Data(), LinkUTI.url: Data("u".utf8)]) - let frames = FrameStreamEncoder().frames(for: item) - let back = try reassemble(frames) - XCTAssertEqual(back, item, "empty representation must survive the round-trip") - XCTAssertEqual(back?.representations[LinkUTI.plainText], Data()) - } -} diff --git a/DeviceLinkKit/Tests/DeviceLinkProtocolTests/InboundAssemblerTests.swift b/DeviceLinkKit/Tests/DeviceLinkProtocolTests/InboundAssemblerTests.swift deleted file mode 100644 index abfe86f..0000000 --- a/DeviceLinkKit/Tests/DeviceLinkProtocolTests/InboundAssemblerTests.swift +++ /dev/null @@ -1,125 +0,0 @@ -import XCTest -@testable import DeviceLinkProtocol - -/// Reassembly contract: emit complete items, interleave by message id, and reject every protocol -/// violation (mismatch, unknown message, duplicate, bad sequence), discarding state on failure/cancel. -final class InboundAssemblerTests: XCTestCase { - - private let a = UUID(uuidString: "AAAAAAAA-0000-0000-0000-000000000001")! - private let b = UUID(uuidString: "BBBBBBBB-0000-0000-0000-000000000002")! - - /// One-chunk text item frames. - private func textFrames(_ id: UUID, _ text: String) -> [Frame] { - let data = Data(text.utf8) - let header = ItemHeader(messageID: id, kind: .text, manifest: [LinkUTI.plainText: UInt32(data.count)]) - return [ - .itemBegin(header), - .chunk(ChunkFrame(messageID: id, uti: LinkUTI.plainText, seq: 0, bytes: data)), - .itemEnd(id), - ] - } - - func testCompleteItemEmitted() throws { - var assembler = InboundAssembler() - var emitted: LinkItem? - for frame in textFrames(a, "hello world") { - if case let .item(item) = try assembler.consume(frame) { emitted = item } - } - XCTAssertEqual(emitted?.messageID, a) - XCTAssertEqual(emitted?.kind, .text) - XCTAssertEqual(emitted?.representations[LinkUTI.plainText], Data("hello world".utf8)) - XCTAssertEqual(assembler.inFlightCount, 0) - } - - func testInterleavedSmallAheadOfLarge() throws { - // B is a 2-chunk "file"; A is a small text item that arrives and completes mid-B. - let big = Data((0..<5000).map { UInt8($0 & 0xff) }) - let half = big.count / 2 - let bHeader = ItemHeader(messageID: b, kind: .file, manifest: [LinkUTI.fileURL: UInt32(big.count)]) - let aData = Data("ping".utf8) - let aHeader = ItemHeader(messageID: a, kind: .text, manifest: [LinkUTI.plainText: UInt32(aData.count)]) - - let sequence: [Frame] = [ - .itemBegin(bHeader), - .itemBegin(aHeader), - .chunk(ChunkFrame(messageID: a, uti: LinkUTI.plainText, seq: 0, bytes: aData)), - .chunk(ChunkFrame(messageID: b, uti: LinkUTI.fileURL, seq: 0, bytes: big.prefix(half))), - .itemEnd(a), // A completes while B is still mid-flight - .chunk(ChunkFrame(messageID: b, uti: LinkUTI.fileURL, seq: 1, bytes: big.suffix(from: big.startIndex + half))), - .itemEnd(b), - ] - - var assembler = InboundAssembler() - var emitted: [LinkItem] = [] - for frame in sequence { - if case let .item(item) = try assembler.consume(frame) { emitted.append(item) } - } - XCTAssertEqual(emitted.map(\.messageID), [a, b], "A should emit before B") - XCTAssertEqual(emitted.first?.representations[LinkUTI.plainText], aData) - XCTAssertEqual(emitted.last?.representations[LinkUTI.fileURL], big) - XCTAssertEqual(assembler.inFlightCount, 0) - } - - func testByteCountMismatchRejectedAndStateDiscarded() { - var assembler = InboundAssembler() - // Manifest says 10 bytes; we send 3, then end. - let header = ItemHeader(messageID: a, kind: .text, manifest: [LinkUTI.plainText: 10]) - XCTAssertNoThrow(try assembler.consume(.itemBegin(header))) - XCTAssertNoThrow(try assembler.consume(.chunk(ChunkFrame(messageID: a, uti: LinkUTI.plainText, seq: 0, bytes: Data("abc".utf8))))) - XCTAssertThrowsError(try assembler.consume(.itemEnd(a))) { - XCTAssertEqual(($0 as? LinkProtocolError)?.code, .manifestMismatch) - } - XCTAssertEqual(assembler.inFlightCount, 0, "failed message must be discarded") - } - - func testOverflowBeyondManifestRejected() { - var assembler = InboundAssembler() - let header = ItemHeader(messageID: a, kind: .text, manifest: [LinkUTI.plainText: 2]) - XCTAssertNoThrow(try assembler.consume(.itemBegin(header))) - XCTAssertThrowsError(try assembler.consume(.chunk(ChunkFrame(messageID: a, uti: LinkUTI.plainText, seq: 0, bytes: Data("abcdef".utf8))))) { - XCTAssertEqual(($0 as? LinkProtocolError)?.code, .manifestMismatch) - } - XCTAssertEqual(assembler.inFlightCount, 0) - } - - func testCancelDiscardsPartialState() throws { - var assembler = InboundAssembler() - let header = ItemHeader(messageID: a, kind: .text, manifest: [LinkUTI.plainText: 10]) - _ = try assembler.consume(.itemBegin(header)) - _ = try assembler.consume(.chunk(ChunkFrame(messageID: a, uti: LinkUTI.plainText, seq: 0, bytes: Data("abc".utf8)))) - let out = try assembler.consume(.cancel(a)) - XCTAssertEqual(out, .none) - XCTAssertEqual(assembler.inFlightCount, 0) - } - - func testChunkForUnknownMessageRejected() { - var assembler = InboundAssembler() - XCTAssertThrowsError(try assembler.consume(.chunk(ChunkFrame(messageID: a, uti: LinkUTI.plainText, seq: 0, bytes: Data("x".utf8))))) { - XCTAssertEqual(($0 as? LinkProtocolError)?.code, .unknownMessage) - } - } - - func testDuplicateItemBeginRejected() throws { - var assembler = InboundAssembler() - let header = ItemHeader(messageID: a, kind: .text, manifest: [LinkUTI.plainText: 1]) - _ = try assembler.consume(.itemBegin(header)) - XCTAssertThrowsError(try assembler.consume(.itemBegin(header))) { - XCTAssertEqual(($0 as? LinkProtocolError)?.code, .duplicateMessage) - } - } - - func testBadSequenceRejected() throws { - var assembler = InboundAssembler() - let header = ItemHeader(messageID: a, kind: .image, manifest: [LinkUTI.png: 100]) - _ = try assembler.consume(.itemBegin(header)) - XCTAssertThrowsError(try assembler.consume(.chunk(ChunkFrame(messageID: a, uti: LinkUTI.png, seq: 1, bytes: Data(repeating: 0, count: 10))))) { - XCTAssertEqual(($0 as? LinkProtocolError)?.code, .badSequence) - } - } - - func testControlFramesPassThrough() throws { - var assembler = InboundAssembler() - let hello = Frame.hello(DeviceIdentity(id: "x", name: "y"), ProtocolVersion(major: 1, minor: 0)) - XCTAssertEqual(try assembler.consume(hello), .control(hello)) - } -} diff --git a/DeviceLinkKit/Tests/DeviceLinkProtocolTests/LinkCodecTests.swift b/DeviceLinkKit/Tests/DeviceLinkProtocolTests/LinkCodecTests.swift deleted file mode 100644 index 63cb20a..0000000 --- a/DeviceLinkKit/Tests/DeviceLinkProtocolTests/LinkCodecTests.swift +++ /dev/null @@ -1,130 +0,0 @@ -import XCTest -@testable import DeviceLinkProtocol - -/// Codec contract: round-trip every frame, reassemble across reads, preserve trailing bytes, and reject -/// bad-magic / unknown-tag / oversize / truncated streams with the right typed error. -final class LinkCodecTests: XCTestCase { - - private let id = UUID(uuidString: "11111111-2222-3333-4444-555555555555")! - - private func allFrames() -> [Frame] { - [ - .hello(DeviceIdentity(id: "device-1", name: "Amit's Mac"), ProtocolVersion(major: 1, minor: 0)), - .ack(id), - .error(.manifestMismatch), - .itemBegin(ItemHeader(messageID: id, kind: .text, - manifest: [LinkUTI.plainText: 5], - suggestedName: "note.txt", - capturedAt: nil, - origin: DeviceIdentity(id: "device-2", name: "iPhone"))), - .chunk(ChunkFrame(messageID: id, uti: LinkUTI.plainText, seq: 0, bytes: Data("hello".utf8))), - .itemEnd(id), - .cancel(id), - ] - } - - /// Build a raw envelope by hand, for the adversarial (malformed) cases. - private func rawEnvelope(type: UInt8, - declaredLength: UInt32, - payload: Data = Data(), - magic: [UInt8] = LinkCodec.magic, - version: UInt8 = LinkCodec.wireFormatVersion) -> Data { - var d = Data(magic) - d.append(version) - d.append(type) - d.append(UInt8((declaredLength >> 24) & 0xff)) - d.append(UInt8((declaredLength >> 16) & 0xff)) - d.append(UInt8((declaredLength >> 8) & 0xff)) - d.append(UInt8(declaredLength & 0xff)) - d.append(payload) - return d - } - - func testRoundTripEveryFrame() throws { - for frame in allFrames() { - let bytes = try LinkCodec.encode(frame) - var decoder = FrameDecoder() - decoder.push(bytes) - let decoded = try decoder.next() - XCTAssertEqual(decoded, frame, "round-trip mismatch for \(frame)") - XCTAssertNil(try decoder.next(), "decoder should be drained after one frame") - try decoder.close() - } - } - - func testPartialBufferReassembles() throws { - let frame = Frame.chunk(ChunkFrame(messageID: id, uti: LinkUTI.png, seq: 0, bytes: Data(repeating: 7, count: 5000))) - let bytes = try LinkCodec.encode(frame) - var decoder = FrameDecoder() - let split = bytes.count / 3 - decoder.push(bytes.prefix(split)) - XCTAssertNil(try decoder.next(), "should need more bytes") - decoder.push(bytes.suffix(from: bytes.startIndex + split)) - XCTAssertEqual(try decoder.next(), frame) - XCTAssertNil(try decoder.next()) - } - - func testTrailingBytesPreservedAcrossFrames() throws { - let a = Frame.ack(id) - let b = Frame.itemEnd(id) - var stream = Data() - stream.append(try LinkCodec.encode(a)) - stream.append(try LinkCodec.encode(b)) - var decoder = FrameDecoder() - decoder.push(stream) - XCTAssertEqual(try decoder.next(), a) - XCTAssertEqual(try decoder.next(), b) - XCTAssertNil(try decoder.next()) - } - - func testBadMagicRejected() { - let bytes = rawEnvelope(type: FrameType.ack.rawValue, declaredLength: 0, magic: [0x00, 0x00, 0x00, 0x00]) - var decoder = FrameDecoder() - decoder.push(bytes) - XCTAssertThrowsError(try decoder.next()) { - XCTAssertEqual(($0 as? LinkProtocolError)?.code, .badMagic) - } - } - - func testUnknownFrameTypeRejected() { - let bytes = rawEnvelope(type: 99, declaredLength: 0) - var decoder = FrameDecoder() - decoder.push(bytes) - XCTAssertThrowsError(try decoder.next()) { - XCTAssertEqual(($0 as? LinkProtocolError)?.code, .unknownFrameType) - } - } - - func testOversizeLengthRejected() { - let bytes = rawEnvelope(type: FrameType.ack.rawValue, declaredLength: 100) - var decoder = FrameDecoder(maxFrameLength: 16) - decoder.push(bytes) - XCTAssertThrowsError(try decoder.next()) { - XCTAssertEqual(($0 as? LinkProtocolError)?.code, .oversizeLength) - } - } - - func testTruncatedFrameRejectedAtClose() throws { - let frame = Frame.chunk(ChunkFrame(messageID: id, uti: LinkUTI.plainText, seq: 0, bytes: Data("abcdef".utf8))) - let bytes = try LinkCodec.encode(frame) - var decoder = FrameDecoder() - decoder.push(bytes.dropLast(2)) // stream ends mid-frame - XCTAssertNil(try decoder.next(), "incomplete frame should not be emitted") - XCTAssertThrowsError(try decoder.close()) { - XCTAssertEqual(($0 as? LinkProtocolError)?.code, .truncatedFrame) - } - } - - func testChunkBytesSurviveExactly() throws { - let payload = Data((0..<1024).map { UInt8($0 & 0xff) }) - let frame = Frame.chunk(ChunkFrame(messageID: id, uti: LinkUTI.fileURL, seq: 42, bytes: payload)) - let bytes = try LinkCodec.encode(frame) - var decoder = FrameDecoder() - decoder.push(bytes) - guard case let .chunk(decoded)? = try decoder.next() else { return XCTFail("expected chunk") } - XCTAssertEqual(decoded.bytes, payload) - XCTAssertEqual(decoded.seq, 42) - XCTAssertEqual(decoded.uti, LinkUTI.fileURL) - XCTAssertEqual(decoded.messageID, id) - } -} diff --git a/DeviceLinkKit/Tests/DeviceLinkProtocolTests/LinkPumpTests.swift b/DeviceLinkKit/Tests/DeviceLinkProtocolTests/LinkPumpTests.swift deleted file mode 100644 index ce3ef95..0000000 --- a/DeviceLinkKit/Tests/DeviceLinkProtocolTests/LinkPumpTests.swift +++ /dev/null @@ -1,82 +0,0 @@ -import XCTest -@testable import DeviceLinkProtocol - -/// The pump's loopback fidelity under arbitrary buffer fragmentation — the end-to-end proof of the -/// encode→codec→decode→assemble stack. -final class LinkPumpTests: XCTestCase { - - private let id = UUID(uuidString: "EEEEEEEE-0000-0000-0000-000000000001")! - - private func sampleItem() -> LinkItem { - LinkItem(messageID: id, kind: .image, - representations: [ - LinkUTI.png: Data((0..<900).map { UInt8($0 & 0xff) }), // multi-chunk at small bound - LinkUTI.plainText: Data("caption".utf8), - ], - suggestedName: "p.png", - origin: DeviceIdentity(id: "d", name: "iPhone")) - } - - func testOutboundIsFramePerBufferAndIngestsToItem() throws { - var sender = LinkPump(chunkByteBound: 64) - var receiver = LinkPump() - let item = sampleItem() - let buffers = try sender.outbound(item) - XCTAssertGreaterThan(buffers.count, 3, "expected itemBegin + several chunks + itemEnd") - - var got: [LinkPump.Inbound] = [] - for b in buffers { got += try receiver.ingest(b) } - XCTAssertEqual(got, [.item(item)]) - } - - func testLoopbackConcatenated() throws { - var sender = LinkPump(chunkByteBound: 64) - var receiver = LinkPump() - let item = sampleItem() - let all = try sender.outbound(item).reduce(Data(), +) - let got = try receiver.ingest(all) - XCTAssertEqual(got, [.item(item)]) - } - - func testLoopbackReSplitAtArbitraryBoundaries() throws { - var sender = LinkPump(chunkByteBound: 64) - var receiver = LinkPump() - let item = sampleItem() - let all = try sender.outbound(item).reduce(Data(), +) - - // Re-slice into 37-byte pieces that don't align with frame boundaries. - var got: [LinkPump.Inbound] = [] - var offset = all.startIndex - while offset < all.endIndex { - let end = min(offset + 37, all.endIndex) - got += try receiver.ingest(all[offset..