Skip to content

feat(mac): Pair iPhone… — native QR pairing in the menu bar (M3)#171

Merged
oratis merged 1 commit into
mainfrom
feat/mac-pair-qr
Jun 26, 2026
Merged

feat(mac): Pair iPhone… — native QR pairing in the menu bar (M3)#171
oratis merged 1 commit into
mainfrom
feat/mac-pair-qr

Conversation

@oratis

@oratis oratis commented Jun 26, 2026

Copy link
Copy Markdown
Owner

M3 of docs/PLAN_IOS_ONBOARDING_v1.0.md — the Mac-side GUI QR, so users who don't live in a terminal can pair Lisa Pocket without running lisa pair.

What

The menu-bar popover gains a "Pair iPhone…" button (shown when the backend is up). It:

  1. POSTs the loopback-only /api/pair/start to mint a per-device token,
  2. detects the Mac's LAN IP (first non-internal IPv4 — same heuristic as pair.ts),
  3. builds the same lisa-pair://v1?host=&port=&token=&name= deep-link,
  4. renders it as a QR (CoreImage) in a window to scan — with a Copy pairing link fallback and the host:port shown so the user can sanity-check the address.

PairController.swift mirrors src/cli/pair.ts exactly, so the URL round-trips through the iOS parsePairing (verified). Pairs with #169 (decision ②): the phone reaches the Mac at the LAN IP and authenticates with the minted device token.

Scope / independence

Mac-only — PairController.swift (new) + a button in MenuBarController.swift. No iOS / server changes. Independent of the onboarding PRs (#167 / #170); most useful once #169 (LAN bind) lands.

Notes

Host auto-detect mirrors pair.ts (first non-internal IPv4); the window shows the IP so a wrong pick (e.g. a VPN interface) is visible. A future refinement could let the user override it.

Verification

  • swift build --package-path packaging/mac-clientBuild complete (only the pre-existing WKProcessPool deprecation warning).
  • Pairing-URL format + LAN detection verified via the macOS swift interpreter (ALL OK); the URL parses back through the same q("host") / q("token") logic the iOS app uses.

🤖 Generated with Claude Code

A GUI counterpart to `lisa pair` so non-terminal users can pair Lisa Pocket. The
menu-bar popover gains a "Pair iPhone…" button (shown when the backend is up) that:
  - POSTs the loopback-only /api/pair/start to mint a per-device token,
  - detects the Mac's LAN IP (first non-internal IPv4, like pair.ts),
  - builds the same lisa-pair://v1?host=&port=&token=&name= deep-link,
  - renders it as a QR (CoreImage) in a window to scan, with a Copy-link fallback
    and the host:port shown for a sanity check.

New PairController.swift mirrors src/cli/pair.ts exactly, so the URL round-trips
through the iOS parsePairing. Pairs with decision ② (#169): the phone reaches the
Mac at the LAN IP and authenticates with the minted device token.

Verify: swift build → Build complete; URL format + LAN detection verified via the
swift interpreter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@oratis

oratis commented Jun 26, 2026

Copy link
Copy Markdown
Owner Author

Review — native QR pairing ✅

PairController.buildPairUrl round-trips through the same lisa-pair://v1?host=&port=&token=&name= shape as src/cli/pair.ts:72, and the /api/pair/start mint mirrors runPairCommand. Rendering at 2× then displaying at 240pt keeps the QR crisp; the "Copy pairing link" + visible host:port fallbacks are a nice safety net.

detectLanHost() is actually more robust than the CLI — it filters IFF_UP/IFF_LOOPBACK and skips 169.254 link-local, which pair.ts:60 does not. 👍

Minor (non-blocking), worth a follow-up:

  • The mint request hardcodes 127.0.0.1:5757, but pair.ts honors LISA_WEB_PORT (pair.ts:33). A user who set a custom port would hit "Lisa's backend isn't running" — misleading. Consider resolving the port from LISA_WEB_PORT / ~/.lisa/config.env to match the CLI.
  • getifaddrs ordering ≠ os.networkInterfaces() ordering, so with multiple NICs the GUI and CLI can advertise different IPs. Both are shown to the user, so acceptable — the on-screen host:port makes a wrong pick visible.

LGTM — merging.

@oratis oratis merged commit ed498dd into main Jun 26, 2026
1 check passed
@oratis

oratis commented Jun 26, 2026

Copy link
Copy Markdown
Owner Author

Review (post-merge) — solid; integrates correctly with #169

This merged before I got to it, so a review-after-the-fact. No action needed — it holds up. I verified the parts most likely to be wrong against the source it claims to mirror:

  • Response decoding is correct. /api/pair/start returns { ok, id, token, port, device } (server.ts:936), and PairController.R decodes token + port at the top level. Bonus: it uses the server's reported port (opts.port) over the hardcoded 5757, so a non-default backend port still produces a correct QR. ✅
  • LAN heuristic matches pair.ts ("first non-internal IPv4", pair.ts:59) — and actually improves on it by skipping 169.254 link-local, which pair.ts doesn't. The "mirrors exactly" comment is therefore slightly generous, but in the safe direction. ✅
  • feat(mac): bind backend LAN-reachable by default, token-gated (decision ②) #169 integration is right. The mint POSTs http://127.0.0.1:5757/api/pair/start over loopback, which the auth gate exempts (server.ts:568 — non-cloud loopback needs no token), so the token-gated 0.0.0.0 bind from feat(mac): bind backend LAN-reachable by default, token-gated (decision ②) #169 doesn't block it. The QR carries the LAN IP; the phone authenticates with the minted per-device token. The two PRs compose exactly as intended. ✅

Integration build I ran (CI can't): the repo's CI is typecheck + tests on ubuntu-latest — it never compiles the Swift mac-client. #169 (BackendController) and #171 (PairController + MenuBarController) merged separately, so their combination on main had never been built by anyone. I built it: swift build --package-path packaging/mac-client on main@ed498ddBuild complete, only the pre-existing WKProcessPool warning. Good.

Minor / future (non-blocking)

  1. detectLanHost can pick a virtual interface. "First non-loopback IPv4" lands on whatever the kernel lists first — which can be a Tailscale utun, bridge100 (Internet Sharing), or a Parallels/VMware vnicN rather than en0. The window does show host:port so a bad pick is visible, and the PR already flags this as a future refinement — worth it to prefer en0/en1 or let the user override.
  2. Repeated taps mint a new device each time. Each "Pair iPhone…" press calls mintDevice, so clicking it N times leaves N device entries (same as running lisa pair N times). The loopback-only /api/devices/revoke covers cleanup; a future Devices list in the popover would make the orphans visible.

oratis added a commit that referenced this pull request Jun 26, 2026
#173)

Two follow-ups to #171's "Pair iPhone…":

- detectLanHost now ranks interfaces instead of taking the first non-loopback
  IPv4: real Ethernet/Wi-Fi (en*) beats VPN/virtual/Internet-Sharing (utun,
  bridge, vmnet, …) and AWDL (awdl/llw, up but not LAN-routable). A phone on the
  same Wi-Fi can't reach a Tailscale utun or a Parallels vnic, which the old
  "first IPv4" could land on. en0 still wins ties (getifaddrs order preserved).
- showPairing() refocuses an open QR window instead of minting a fresh device
  token, so repeated taps don't leave orphan entries in devices.ts.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@oratis oratis deleted the feat/mac-pair-qr branch July 2, 2026 10:19
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