cmux-next: Mac Screen tabs and the device hub (per-device access, no pairing links)

Merged
AZ
14d ago
+143113-51101

cmux NEXT gets a Mac Screen tab: see and control another Mac signed in to the same account, without pairing links. Each Mac registers a device key with a device hub, the host decides per device who may view or control it, and every session starts from a single-use ticket the hub mints. Settings › Devices lists your Macs, their permissions, relays and history; a menu bar item on the host shows who is connected and stops or revokes them.

This replaces #20426 (Mac Screen on feat-cmux-screen-kit) and the stacked device-auth branch; both are folded in here and merged with current feat-cmux-next. The active-team picker (#21233) lands separately.

What it adds

  • Mac Screen tab (CmuxNextApp Mac Screen pane on CmuxScreenKit/CmuxVNC): live H.264 stream, fit/letterbox, toolbar with transport, fps, RTT and codec, Quality, Send Keys, Refresh, Disconnect. Input goes to the host only while the tab's window is key.
  • Device hub (workers/device-hub, new): a Cloudflare Worker with a per-user SQLite Durable Object (devices, permissions, relays, audit) and a per-session Durable Object. Clients hold one hibernating WebSocket each, so permission and device changes arrive in real time; nothing polls. Contract: docs/device-hub/PROTOCOL.md.
  • CmuxNextDevices (new package target): device identity, hub client, access state, RelaySecretVault (an actor; Keychain work never runs on the main actor).
  • Settings › Devices: This Mac, other Macs (online state, platform, fingerprint), per-device access (None / View Only / Full Control), Ask for Access from a View Only Mac (prompts the host to upgrade), relays, History.
  • Approval flow and menu bar: an unknown or lower-scope request prompts the host; while controlled, the menu bar says who and offers Stop Sharing This Mac and Revoke This Device.
  • Relays: user-owned relay lists, shared secret or username/password, synced to the user's devices.
  • Link mode removed: cmux-vnc://pair?… links, saved link Macs and the confirm gate are gone. Pairing-link text typed into New Tab or the omnibox is refused, never searched, sent to an agent or loaded.
  • Built-in hub addresses: DEV builds use the dev hub, release builds the production hub; Settings › Development has an optional override.

Security model

  • Device keys. Each Mac generates a key pair kept in the Keychain. Registration binds the public key to the signed-in account; every later hub call is signed by that device key as well as carrying the account token.
  • Per-device permissions. The host owns a permission row per viewer device (none, view, full). The hub refuses a ticket above the saved scope; asking for more prompts the host.
  • Single-use tickets. A session needs short-lived tickets the hub mints for one viewer, one host and one scope. Each side redeems its own ticket once to join signaling, and the peer's identity is checked against its signed DTLS fingerprint. The host app enforces View Only (no input is applied).
  • Sealed relay secrets. A relay's secret or username and password is sealed to each of the user's devices; the hub stores only that ciphertext (relay names and URLs are plain), and the app keeps the secrets in the Keychain through RelaySecretVault, off the main thread. A Mac added later gets a relay's secret once someone saves the relay again.
  • Real-time revocation. Lowering a permission (to Ask, View Only or None), removing either device, turning sharing off, Stop Sharing, Revoke This Device and sign-out are pushed over the hub sockets and end live sessions, including one still being approved. Removing a device also deletes every permission that names it.
  • Audit rows (requested, approved, started, ended with duration, permission changes) hold metadata only.

Backend

  • Dev hub: cmux-device-hub-dev.debussy.workers.dev (deployed, secrets set).
  • Production hub: cmux-device-hub.debussy.workers.dev (deployed). Its TURN secrets aren't set yet, so production relays through the cmux TURN service won't work until they are; direct and user-relay paths don't need them.
  • workers/device-hub is under the Business Source License 1.1 (workers/device-hub/LICENSE).

AppKit port

feat-cmux-next now forbids new SwiftUI in macOS packages (scripts/cmux-next/check-no-swiftui.py, plans/cmux-next/no-swiftui-DESIGN.md), so the Mac Screen views are AppKit:

  • home page and device list
  • tab toolbar and its menus
  • connect, progress and end cards
  • host approval panel
  • styles, buttons and the switch

The new files are MacScreenButtons, Controls, HomeParts, Toolbar and CardView. check-no-swiftui.py reports 0 Mac Screen findings; the 17 findings it still prints are stale CmuxNextApps allowlist lines on the base. The port keeps every localization key, accessibility identifier and action.

Review: a correctness-first subagent reviewed the port and found one medium issue. Text didn't follow density, font size or interface scale changes. It is fixed in e5056c4537d9: the home page, cards and approval panel rebuild when the type scale changes, and the toolbar resets its fonts. Low findings left for follow-up:

  • the row More button has no VoiceOver name
  • the whole home page rebuilds on any change
  • the home page may not take Tab focus
  • test gaps: menu actions, switch round trip, child repaint, the approval panel in a window

Visual parity: I compared the port with the SwiftUI build (combo3) on one mini, at the same window size, with the same account, in light and dark. The screens compared are:

  • home and device list, with sharing on and off
  • the connect card under the tab toolbar
  • the host-offline end card
  • the approval panel

The comparison found these differences, fixed in e5056c4537d9:

  • device rows were 4 pt short
  • the switch knob shape and colors differed
  • card and approval glyphs were short, so the card was 10 pt short
  • wrapped text could end on a one-word line

Remaining differences:

  • The SwiftUI approval panel used a 220 pt fallback height with the content centered; the AppKit panel fits its content.
  • In a narrow pane, SwiftUI wrapped device names letter by letter; AppKit truncates them.
  • Menus weren't captured, because opening them needs a real click. Both builds use NSMenu.

Fleet runs at e5056c4537d9: suites MacScreen, CmuxNextDevicesTests, DeviceHub and Settings all pass, and check-release-compile.sh passes.

Verification

Fleet (cmux-ci run, scripts/ci/package-test-lane.sh on Packages/macOS/CmuxNext), each fix landed red first:

  • CmuxNextDevicesTests,MacScreen,NewTabIntentTableTests,SearchEngineEveryPathTests green at 372113a76c1 (h7fix-after-372113a7) and on the team-picker combination 863d51551fa (h7fix-try-863d5155).
  • DeviceHubSettingsTests,CmuxNextDevicesTests,DeviceHub,MacScreen green at c1850d152f3; CmuxNextSettingsTests,DeviceHubSettingsTests green at ece4553a479.
  • CmuxNextDevicesTests,MacScreen,SettingsPage,CmuxNextSettingsTests green at b649b93f9a5.
  • workers/device-hub: 36 vitest tests (workers pool).

These ran before the merge of current feat-cmux-next (3265798366d) in 3562ddd14cf; this PR's CI covers the merged head.

Live walkthroughs on two real Macs (agents drove the UI; agent verdicts, not a person's):

  • H1–H7, combo2 (665c290a): 44 PASS, 4 FAIL, 9 UNVERIFIED rows. The four failures (Ask for Access couldn't raise access from a View Only Mac; stale access after a hub socket reconnect; New Tab offered to search a pairing link; up to 19.8 s main-thread stalls in DeviceKeychain) are fixed in 372113a76c1 with red-first tests above.
  • H8, combo3 (863d51551fa), stopped early when we decided to merge: 7 PASS, 0 FAIL, 10 UNVERIFIED rows. Passed: default dev hub with no override (viewer), both Macs list each other, a Full Control session (Direct, H.264 HW, 3456×2234, up to 67 fps, 4–8 ms RTT), the toolbar, the host's menu bar indicator naming the controller, History rows, and a secret scan of logs and evidence (0 hits). No DeviceKeychain stalls; the longest viewer stalls were 455 ms outside the device paths. Not reached: most of the access matrix, all 20 revocation cells, Relay Only through a user relay, team switch during a session, reconnect, and the H7 fixes in the live UI (Ask for Access, refresh after reconnect, link refusal, removal cleanup), which are covered only by tests.

Review fixes after these runs (see the review comment): no ticket for a session that ends mid-approval, identity Keychain work off the main thread, lowering to Ask ends live sessions. Worker 74/74 and fleet DeviceHubIdentityThreadTests,CmuxNextDevicesTests,MacScreen,DeviceHubSettingsTests green on the fixed code (the fleet run needed the one-line base compile fix from #22821).

Known open item: the user reports pointer movement on a Mac Screen session feels slower than earlier builds. The cause hasn't been measured.

No capture: a Mac Screen session needs two signed-in Macs on the device hub, which a single fleet capture mini cannot provide; the two-Mac walkthroughs above are the UI evidence.

Since d81c180: merged current feat-cmux-next (dfd90dd, d3b87f7); the Mac Screen hosting view is no longer generic, which fixes the Release optimizer crash on its deinit (fleet check-release-compile.sh green at 588e85e4, step 2837c2b8); the connected dot uses the lit glyph color (base lint rule); the New Tab omnibox action handles the pairing-link refusal; the cmux-screen Cargo workspace is listed as skipped in .github/rust-workspaces.toml; settings exports restamped from the fleet. The Settings page's writer() no longer requires context.userGesture (7c98ac8, so accessibility presses count as the user): a trust-policy change, disclosed here.

Changelog

  • cmux NEXT: Mac Screen tabs let you view and control your other Macs, with per-device access in Settings › Devices.

🤖 Generated with Claude Code