Skip to content

Slim the pairing QR: compact attach payload, drop vestigial auth_token, ECC M to L - #5727

Merged
lawrencecchen merged 5 commits into
mainfrom
feat-ios-qr-slim
Jun 10, 2026
Merged

lawrencecchen merged 5 commits into
mainfrom
feat-ios-qr-slim

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jun 9, 2026 •

Copy link
Copy Markdown
Contributor

Slims the pairing QR so it scans faster from the Mac screen: the attach payload moves to a compact short-key JSON grammar and the QR renders at ECC L instead of M. The envelope is unchanged (cmux-ios://attach?v=1&payload=<base64url(JSON)>), the Mac generator stays MobileAttachTicketStore.attachURL, and the phone decode stays behind CmxAttachTicketInput.decode(String) throws -> CmxAttachTicket.

Measured effect (printed from the real encoder + CIQRCodeGenerator)

Representative Mac-wide pairing ticket (UUID device id, real-length display name, 43-char minted token):

JSON URL QR (2 routes) QR (1 route)
before (full keys + auth_token, ECC M) 495 B 690 ch version 21, 101x101 modules version 17, 85x85
after (compact, no auth_token, ECC L) 299 B 429 ch version 14, 73x73 modules version 11, 61x61

At the pairing window's fixed 220 pt render, each module grows from ~2.2 pt to ~3.0 pt (1.38x). Attribution: JSON slimming alone takes the 2-route code v21 to v16 at ECC M; ECC M to L alone takes it to v18; combined v14.

Field-by-field before/after

legacy key compact key notes
version v also the grammar discriminator: compact has v, legacy has version
workspaceID w omitted when empty (the pairing QR is Mac-wide, "")
terminalID t omitted when nil/empty
macDeviceID d
macDisplayName n omitted when nil/empty
expiresAt ISO8601 string e unix seconds (Int) rounded up so the lifetime never shortens
routes[] r[]
route.id / kind / priority / endpoint i / k / p / e p omitted when 0
endpoint.type t host_port / peer / url
endpoint.host / port h / p
endpoint.id / relay_hint / direct_addrs / relay_url i / rh / da / ru da omitted when empty
endpoint.url u
auth_token dropped vestigial in the QR; proof below

Why dropping auth_token from the QR is safe

Host side, Stack auth is the sole authorization gate: the live authorizeRequest path goes through MobileHostService.authorizationError(for:), which only verifies the same-account Stack access token and never consults the attach token. attach_token is read host-side only by recordCreatedResourcesIfNeeded (bookkeeping for tokens minted over mobile.attach_ticket.create), and the ticket scope checker is reachable only via debugTicketAuthorizationError (tests).

Phone side, MobileCoreRPCClient.requestDataWithAuth attaches attach_token only when present and always sends the Stack token for authorized requests (a token-only request is rejected host-side with missingStackTokens). With a nil token, initialWorkspaceListRequests falls through to the identical unscoped workspace.list the Mac-wide pairing ticket produced before. The hasActiveUnexpiredAttachTicket / attach-as-auth UI gate only matters for deep-link attach URLs from dev tooling, and that tooling (scripts/lib/attach-url.mjs via mobile-attach-qr.sh / dev-setup.sh, and mobile-soak.py) rebuilds its URLs from the RPC payload's ticket object in legacy full-key JSON, which still carries auth_token and which the tolerant decoder still accepts. The full ticket including the token also still rides unchanged in the mobile.attach_ticket.create response.

Compatibility matrix (all pinned by tests)

  • New phone scans new QR: compact grammar routed by the top-level v key (decodesCompactPayloadAttachURL, round-trip tests incl. peer/url endpoints).
  • New phone scans old QR / dev-script URL: legacy version payloads keep decoding through the original Codable path, auth token preserved (decodesLegacyFullKeyPayloadAttachURL).
  • Old phone scans new QR: the pre-compact decoder throws a DecodingError on the missing version key, so the user sees the pairing error UI, never a silently misread ticket (compactPayloadFailsLoudlyOnPreCompactDecoder, legacyDecoderRejectsCompactPayloadLoudly).
  • Legacy cmux-ios://pair URLs: separate branch in CmxAttachTicketInput.decode, untouched.
  • Expired or garbage compact payloads are rejected through the existing validate() path (expiredCompactTicketIsRejected, unknown kind/endpoint tests), and compactPayloadIsSmallerThanLegacyPayload pins a 220 B ceiling so payload regrowth shows up in review.

Coordination with #5713

That PR's pairing connect/error work sits above the CmxAttachTicketInput.decode(String) boundary; this PR only changes internals behind it and the two diffs share no files, so merge order is flexible. Any future edits to CmxAttachTicketInput itself should keep the two-grammar routing block intact.

Verification

  • swift test --package-path Packages/CMUXMobileCore (69 tests) and swift test --package-path Packages/CmuxMobileRPC (23 tests) pass.
  • Build-only verification: macOS xcodebuild -project cmux.xcodeproj -scheme cmux with a tagged derivedDataPath, and iOS xcodebuild -workspace ios/cmux.xcworkspace -scheme cmux-ios against an arm64 iPhone 17 Pro simulator destination. Both succeed.
  • Autoreview (codex engine + cmux Aziz policy) clean.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Note

Medium Risk
Changes pairing wire format and drops attach token from QR; mitigated by dual-grammar decode, loud failure on old clients, and documented reliance on Stack auth only.

Overview
Pairing QR codes scan faster from the Mac screen by shrinking the attach payload and lowering QR error correction.

Compact attach JSON replaces legacy full-key Codable in the pairing QR: short keys (v, d, r, …), omitted empty optionals, expiry as unix seconds (rounded up), and no auth_token in the QR (Stack access token remains the host auth gate; RPC responses still carry the full ticket). CmxAttachTicketCompactCoder and compact DTOs handle encode/decode; isCompactPayload distinguishes "v" vs legacy "version".

Decode routing in CmxAttachTicketInput: compact payloads go through the new coder; everything else keeps ISO8601 legacy decoding. Old app versions fail loudly on compact QRs instead of mis-parsing.

Mac side: MobileAttachTicketStore.attachURL emits compact payloads; MobilePairingQRImageView uses ECC L instead of M for screen-to-camera pairing.

Tests cover round-trips, cross-grammar compatibility, size ceiling (~220 B), and URL-level decode paths.

Reviewed by Cursor Bugbot for commit d6732a4. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Slims the pairing QR so it scans faster: the attach payload switches to a compact short-key JSON and the QR renders at ECC L instead of M. The URL envelope and decode entry points stay the same; the QR no longer carries a vestigial attach auth token.

  • New Features

    • Compact payload in cmux-ios://attach?v=1&payload=<base64url(JSON)>: short keys, omit empty fields, expiry in unix seconds (rounded up), no auth_token.
    • Decoder routing: top-level "v" → compact coder; "version" → legacy Codable. Validation unchanged; legacy cmux-ios://pair remains untouched.
    • Faster scan: QR now uses ECC L; a typical Mac-wide ticket drops QR version (e.g., v21→v14), yielding larger modules at the same size.
    • Implementation: MobileAttachTicketStore emits the compact payload; CmxAttachTicketInput.decode supports both grammars.
  • Refactors

    • Introduced injectable CmxAttachTicketCompactCoder; split compact DTOs (CompactAttachTicket, CompactAttachRoute, CompactAttachEndpoint) into separate files and scoped helpers to owning types per package conventions.

Written for commit d6732a4. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Support for a compact pairing QR payload format and detection so devices can encode/decode smaller attach tickets (auth tokens excluded from QR).
  • Improvements

    • QR generation uses a lower error-correction level for smaller, more scannable codes.
    • Attach URL generation now emits the compact payload by default.
  • Tests

    • Added comprehensive tests for compact/legacy decoding, payload detection, validation, and size guarantees.

lawrencecchen and others added 3 commits June 9, 2026 15:28
The repo-loss snapshot commit flattened a pre-#5708 working tree on top
of origin/main, accidentally reverting the merged sidebar row-ids
preference removal (#5708).
These four files are unrelated to the QR work; restore them to main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the CmxAttachTicketCompactCoding namespace enum with an
injectable CmxAttachTicketCompactCoder struct, move each compact DTO
(CompactAttachTicket, CompactAttachRoute, CompactAttachEndpoint) into
its own file, and turn the pure private static helpers into file-scope
private functions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 9, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jun 10, 2026 12:50pm
cmux-staging Building Building Preview, Comment Jun 10, 2026 12:50pm

@coderabbitai

coderabbitai Bot commented Jun 9, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR introduces a compact JSON wire format for attach ticket QR payloads, adds short-key Codable DTOs, a public compact coder with grammar detection, integrates compact encoding/decoding into attach URL generation and input parsing, updates QR ECC, and adds comprehensive tests.

Changes

Compact Attach Ticket QR Payload

Layer / File(s) Summary
Compact wire format DTOs
Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift, Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachRoute.swift, Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachEndpoint.swift
Internal Codable structs map to/from domain types using abbreviated keys, validate reconstructed data with throwing ticket()/route()/endpoint() methods, and omit auth tokens from the compact representation.
Compact coder API and core validation
Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxAttachTicketCompactCoder.swift, Packages/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketCompactCoderTests.swift
Public CmxAttachTicketCompactCoder encodes tickets to compact JSON (sorted keys, no slash escaping), decodes back via CompactAttachTicket, and provides isCompactPayload() to detect compact grammar by the top-level v field. Tests cover encoding shape, round-trip (auth token dropped), "Mac-wide" omissions, symmetric decoder rejections, payload detection, decoding errors for invalid routes/endpoints, and compact size regression.
Attach URL discrimination
Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/CmxAttachTicketInput.swift, Packages/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift
CmxAttachTicketInput.decode() inspects the payload with isCompactPayload() and dispatches to the compact coder or to the legacy JSONDecoder (ISO8601) accordingly. Tests validate both grammars, pre-compact decoder failure, expired-ticket rejection, and garbage payload handling.
QR payload generation
Sources/Mobile/MobileAttachTicketStore.swift
attachURL(for:) now uses CmxAttachTicketCompactCoder().encode(ticket) for the QR payload query parameter and documents that the QR payload omits the auth token while the full ticket (including token) remains available to RPC consumers.
QR error-correction tuning
Sources/Mobile/Pairing/MobilePairingQRImageView.swift
Core Image QR generation changes ECC from "M" to "L", with updated comments describing the ECC trade-offs at fixed rendered size.

Sequence Diagram

sequenceDiagram
  participant Client
  participant MobileAttachTicketStore
  participant CompactCoder
  participant CmxAttachTicketInput
  participant QRImageView

  Client->>MobileAttachTicketStore: attachURL(ticket)
  MobileAttachTicketStore->>CompactCoder: encode(ticket)
  CompactCoder-->>MobileAttachTicketStore: compact Data (auth token omitted)
  MobileAttachTicketStore-->>Client: cmux-ios://attach?payload=base64url(compact)

  Client->>CmxAttachTicketInput: decode(URL)
  CmxAttachTicketInput->>CompactCoder: isCompactPayload(data)
  CompactCoder-->>CmxAttachTicketInput: Bool
  alt compact
    CmxAttachTicketInput->>CompactCoder: decode(data)
    CompactCoder-->>CmxAttachTicketInput: CmxAttachTicket
  else legacy
    CmxAttachTicketInput->>CmxAttachTicketInput: JSON decode with ISO8601
  end
  CmxAttachTicketInput-->>Client: ticket

  Client->>QRImageView: render(qr payload)
  QRImageView->>QRImageView: setECC to "L"
  QRImageView-->>Client: QR image
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

A rabbit trims the keys so small,
QR dots hold secrets, not them all,
Tokens hidden, formats play,
Old and new both find their way,
Hopping light to ship the change today. 🐰✨

🚥 Pre-merge checks | ✅ 20 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.79% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (20 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: slimming the pairing QR through compact attach payload, dropping auth_token, and changing error correction level from M to L.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed No Swift actor isolation issues: CmxAttachTicketCompactCoder marked Sendable, internal Compact* types are immutable DTOs, MobileAttachTicketStore unchanged, no mutable Sendable without isolation.
Cmux Swift Blocking Runtime ✅ Passed No blocking/timing primitives (semaphores, waits, sleeps, asyncAfter, locks) introduced in new code; changes consist of JSON encoding/decoding and QR configuration only.
Cmux Expensive Synchronous Load ✅ Passed No expensive synchronous loaders added to main actor paths; compact JSON encoding replaces equivalent full JSON encoding on existing @MainActor context.
Cmux Cache Substitution Correctness ✅ Passed The PR changes QR payload encoding but never persists, caches, or snapshots attach_url; it's generated fresh on each call and used transiently for display.
Cmux No Hacky Sleeps ✅ Passed Rule scope is TypeScript/JavaScript/shell; all PR changes are Swift files. Swift is explicitly out of scope—covered by swift-blocking-runtime.md instead.
Cmux Algorithmic Complexity ✅ Passed All operations process tiny fixed-size collections (1-3 routes per ticket) and are called once per pairing session/QR scan, not in batch loops over scalable collections like workspaces or terminals.
Cmux Swift Concurrency ✅ Passed PR uses only synchronous throws-based APIs with standard Codable; no DispatchQueue, DispatchGroup, Combine patterns, completion handlers, or fire-and-forget Tasks introduced.
Cmux Swift @Concurrent ✅ Passed All Swift changes are synchronous, lightweight functions with no async work, no invalid @concurrent annotations, and no concurrency violations per swift-concurrent-annotation.md.
Cmux Swift File And Package Boundaries ✅ Passed New files under 400 lines with focused single responsibilities; core logic in proper SwiftPM packages; app-target changes incidental; no mixing of responsibilities.
Cmux Swift Logging ✅ Passed No logging violations found: no print/debugPrint/dump/NSLog in production code; no secret exposure in error messages or comments.
Cmux User-Facing Error Privacy ✅ Passed No user-facing error privacy violations. Error messages are DecodingError debugDescriptions for developer diagnostics, not visible to users, containing only generic field names.
Cmux Full Internationalization ✅ Passed PR adds no new user-facing text. Existing accessibility strings already localized; ECC level change is internal implementation detail.
Cmux Swiftui State Layout ✅ Passed No SwiftUI state violations found. New data models are non-View structs; MobilePairingQRImageView lacks @State/@Published/@observable, GeometryReader, store refs, and render-time mutations.
Cmux Architecture Rethink ✅ Passed No architectural anti-patterns found: no sleeps, delays, polling, locks for timing, observers, side channels, or split UI lifecycle in code.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed No NSWindow, NSPanel, NSWindowController, or SwiftUI WindowGroup added or materially changed; changes are data encoding, QR level adjustment.
Cmux Source Artifacts ✅ Passed All 9 changed files are hand-written Swift source code and tests in standard package paths, with no build artifacts, generated logs, caches, DerivedData, or other prohibited artifact types.
Description check ✅ Passed The PR description is comprehensive and well-structured, addressing all major template requirements with detailed technical justification.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-ios-qr-slim

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented Jun 9, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR slims the pairing QR by switching the attach payload from a verbose full-key Codable JSON to a compact short-key grammar (unix expiry, omitted empty fields, no auth_token) and drops ECC from M to L — cutting a representative two-route Mac-wide ticket from QR version 21 to version 14 at the same render size.

  • Compact payload encoding: Four new files introduce CmxAttachTicketCompactCoder and its DTOs (CompactAttachTicket, CompactAttachRoute, CompactAttachEndpoint); MobileAttachTicketStore.attachURL now uses the compact encoder while payload(for:)["ticket"] continues to carry the full legacy ticket for RPC consumers.
  • Dual-grammar decoding: CmxAttachTicketInput.decode routes on the presence of top-level "v" vs "version", so new phones accept both grammars, old phones fail loudly on compact payloads, and validate() is called exactly once on both paths.
  • ECC L: MobilePairingQRImageView switches correctionLevel from "M" to "L", appropriate for pristine screen-to-camera reads; tests pin the full compatibility matrix and a 220 B payload-size ceiling.

Confidence Score: 5/5

Safe to merge — the dual-grammar routing is correct, validation runs exactly once on both paths, and the compatibility matrix is locked by tests covering all four scan combinations.

The wire-format change is backward-compatible in both directions: new decoders accept both grammars, and old decoders fail loudly on compact payloads rather than silently misreading them. Auth is unchanged (Stack token remains the sole gate). The ECC reduction from M to L is appropriate for screen-to-camera use. Tests cover round-trips, grammar discrimination, error cases, and a payload-size ceiling.

No files require special attention.

Important Files Changed

Filename Overview
Packages/CMUXMobileCore/Sources/CMUXMobileCore/CmxAttachTicketCompactCoder.swift New public coder for compact QR payload; stateless, Sendable, well-documented. Grammar discriminator and round-trip logic are correct.
Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/CmxAttachTicketInput.swift Grammar-routing added correctly; validate() is called exactly once on both compact and legacy paths.
Sources/Mobile/MobileAttachTicketStore.swift attachURL now uses CmxAttachTicketCompactCoder; auth token intentionally excluded from QR, still present in payload(for:)["ticket"] for RPC consumers.
Sources/Mobile/Pairing/MobilePairingQRImageView.swift ECC level changed from M to L; one-line change with clear justification in comment.
Packages/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift Compact DTO for CmxAttachTicket; correctly rounds expiry up, omits empty optionals, and drops authToken by design.

Sequence Diagram

sequenceDiagram
    participant Mac as MobileAttachTicketStore (Mac)
    participant QR as MobilePairingQRImageView
    participant Phone as CmxAttachTicketInput (Phone)
    participant CompactCoder as CmxAttachTicketCompactCoder

    Mac->>CompactCoder: encode(ticket) — compact short-key JSON, no auth_token
    CompactCoder-->>Mac: Data
    Mac->>Mac: "base64URLEncode → cmux-ios://attach?v=1&payload=…"
    Mac->>QR: render URL as QR (ECC L)

    Note over Phone: Scans QR or receives URL

    Phone->>Phone: base64URLDecode(payload) → Data
    Phone->>CompactCoder: isCompactPayload(data) — checks top-level "v" key
    alt compact grammar (new Mac QR)
        CompactCoder-->>Phone: true
        Phone->>CompactCoder: decode(data)
        CompactCoder-->>Phone: "CmxAttachTicket (authToken = nil)"
    else legacy grammar (old Mac / dev script)
        CompactCoder-->>Phone: false
        Phone->>Phone: JSONDecoder (iso8601) → CmxAttachTicket (authToken preserved)
    end
    Phone->>Phone: ticket.validate() — expiry, required fields
    Phone-->>Phone: validated CmxAttachTicket
Loading

Reviews (3): Last reviewed commit: "Merge branch 'main' into feat-ios-qr-sli..." | Re-trigger Greptile

guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return false
}
return object["v"] != nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The grammar discriminator checks object["v"] != nil, but JSONSerialization maps a JSON null value to NSNull, so a payload like {"v":null,...} passes the check and is routed to the compact decoder — which then throws a DecodingError for the wrong reason rather than falling through to the legacy decoder. Checking for an integer value keeps the routing intent unambiguous.

Suggested change
return object["v"] != nil
return object["v"] as? Int != nil

return try encoder.encode(CompactAttachTicket(ticket))
}

/// Decode a compact JSON payload into a validated ``CmxAttachTicket``.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The doc comment says "validated" but this method only does structural decode; the semantic validate() call (expiry, required fields, etc.) is the caller's responsibility and is done in CmxAttachTicketInput.decode. A caller who reads the doc comment may reasonably skip calling validate() on the returned ticket, accepting an expired or otherwise invalid ticket.

Suggested change
/// Decode a compact JSON payload into a validated ``CmxAttachTicket``.
/// Decode a compact JSON payload into a ``CmxAttachTicket``.
/// Callers are responsible for calling ``CmxAttachTicket/validate()`` before use.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

lawrencecchen added a commit that referenced this pull request Jun 9, 2026
Every distinct way a scanned pairing code can fail must surface its own
actionable walk-through message through the shared connectionError
surface from #5713, and a scan
performed while a live session exists must never tear that session down.

Red on this commit (fix follows):
- expired code says "This code expired" + how to mint a fresh one,
  instead of the dead-end "Invalid pairing code."
- a compact short-key payload (top-level "v", the newer-Mac QR grammar
  from #5727) says "update cmux
  on this device" loudly instead of "invalid code"
- a pair-grammar payload with a newer version says the same
- unreadable garbage keeps an actionable refresh-code instruction
- scanning a bad code while connected keeps the live session

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Jun 10, 2026
Every distinct way a scanned pairing code can fail must surface its own
actionable walk-through message through the shared connectionError
surface from #5713, and a scan
performed while a live session exists must never tear that session down.

Red on this commit (fix follows):
- expired code says "This code expired" + how to mint a fresh one,
  instead of the dead-end "Invalid pairing code."
- a compact short-key payload (top-level "v", the newer-Mac QR grammar
  from #5727) says "update cmux
  on this device" loudly instead of "invalid code"
- a pair-grammar payload with a newer version says the same
- unreadable garbage keeps an actionable refresh-code instruction
- scanning a bad code while connected keeps the live session

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Jun 10, 2026
Turns the red QR-journey behavior tests green. Every distinct way a
scanned pairing code can fail now maps to its own localized message +
recovery action through the single classifier sink from
#5713:

- Decode failures get a classifier (`classify(decodeError:)`) instead of
  collapsing into one "Invalid pairing code.": expired payloads say the
  code expired and how to mint a fresh one; version mismatches split
  into codeFromNewerApp ("update cmux on this device", loud, because
  rescanning can never help) vs codeFromOlderMac ("update the Mac").
- `CmxAttachTicketInput.decode` probes undecodable payloads for a cmux
  format marker: a top-level "v" is the compact short-key QR grammar
  newer Macs mint (#5727), a
  "version" key or the URL's `v` query item names a long-key version
  this build does not speak. Markerless garbage keeps the original
  opaque decode error and the actionable refresh-code message.
- Tailnet-off seam: `refined(tailnetHint:)` upgrades hostUnreachable /
  dnsFailed / handshakeTimedOut on a tailnet-shaped address (CGNAT
  100.64/10, the Tailscale ULA, .ts.net) to an explicit "Tailscale is
  off on this device" walk-through when the injected
  `tailnetHintProvider` reports the tailnet inactive. Defaults to
  unknown, so refinement is a no-op until the composition root wires
  the #5722 detector.
- Scans while connected go through the pure `MobilePairingScanGate`
  before the destructive `beginPairingAttempt`: a code for the already
  connected Mac shows an "Already connected" notice, an undecodable
  code shows its classified message as a notice, and only a decodable
  code for a different Mac proceeds into the re-pair path. The live
  session is never torn down for a code that was never going to
  connect.
- Non-fatal outcomes (already paired, bad code while connected,
  post-pair store save failure) surface on a new `pairingNotice`
  banner with `ios_pairing_scan_rejected` analytics, separate from the
  fatal `connectionError` surface, so a healthy session never shows a
  fatal error state.
- Camera permission denial in the scanner sheet explains the recovery
  and offers an Open Settings button; a decoded QR that is not a cmux
  pairing code shows a "not a pairing code" hint (once per distinct
  code) instead of being silently ignored.
- Connect-phase failures (offline, unreachable, listener not running,
  account mismatch, auth) keep the verified #5713 classification and
  are not rebuilt here.

Pure ticket/route helpers move from MobileShellComposite to
MobileShellTicketHelpers to stay under the Swift file length budget.
All user-facing strings are localized with en + ja entries in the iOS
string catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Jun 10, 2026
Turns the red QR-journey behavior tests green. Every distinct way a
scanned pairing code can fail now maps to its own localized message +
recovery action through the single classifier sink from
#5713:

- Decode failures get a classifier (`classify(decodeError:)`) instead of
  collapsing into one "Invalid pairing code.": expired payloads say the
  code expired and how to mint a fresh one; version mismatches split
  into codeFromNewerApp ("update cmux on this device", loud, because
  rescanning can never help) vs codeFromOlderMac ("update the Mac").
- `CmxAttachTicketInput.decode` probes undecodable payloads for a cmux
  format marker: a top-level "v" is the compact short-key QR grammar
  newer Macs mint (#5727), a
  "version" key or the URL's `v` query item names a long-key version
  this build does not speak. Markerless garbage keeps the original
  opaque decode error and the actionable refresh-code message.
- Tailnet-off seam: `refined(tailnetHint:)` upgrades hostUnreachable /
  dnsFailed / handshakeTimedOut on a tailnet-shaped address (CGNAT
  100.64/10, the Tailscale ULA, .ts.net) to an explicit "Tailscale is
  off on this device" walk-through when the injected
  `tailnetHintProvider` reports the tailnet inactive. Defaults to
  unknown, so refinement is a no-op until the composition root wires
  the #5722 detector.
- Scans while connected go through the pure `MobilePairingScanGate`
  before the destructive `beginPairingAttempt`: a code for the already
  connected Mac shows an "Already connected" notice, an undecodable
  code shows its classified message as a notice, and only a decodable
  code for a different Mac proceeds into the re-pair path. The live
  session is never torn down for a code that was never going to
  connect.
- Non-fatal outcomes (already paired, bad code while connected,
  post-pair store save failure) surface on a new `pairingNotice`
  banner with `ios_pairing_scan_rejected` analytics, separate from the
  fatal `connectionError` surface, so a healthy session never shows a
  fatal error state.
- Camera permission denial in the scanner sheet explains the recovery
  and offers an Open Settings button; a decoded QR that is not a cmux
  pairing code shows a "not a pairing code" hint (once per distinct
  code) instead of being silently ignored.
- Connect-phase failures (offline, unreachable, listener not running,
  account mismatch, auth) keep the verified #5713 classification and
  are not rebuilt here.

Pure ticket/route helpers move from MobileShellComposite to
MobileShellTicketHelpers to stay under the Swift file length budget.
All user-facing strings are localized with en + ja entries in the iOS
string catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview – cmux — d6732a4c Deployed Jun 10, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant