Skip to content

iPad companion: thin client over LAN and embedded Tailscale #95

Description

@alexfoxtm

Opening this first, per CONTRIBUTING.md: this is product direction, not a small bugfix. I have a working prototype on a Mac + iPad. I am not sending a PR until you want this shape — happy to shrink it, drop parts, or throw it away.

Problem

I want to use MonoCode on an iPad as a thin client of the Mac: same sessions, files, and agents, without a second copy of the workspace. That should work on the same Wi-Fi and away from home. I do not want the Tailscale iOS app, a VPN / Network Extension, or a public port.

Proposed behavior

  • Mac: Settings → Companion. Turn on Local network, Tailscale, or both. Show a 6-digit pairing code and a QR per live route.
  • iPad: first-run pairing screen. Scan the QR, or type the code + host. After that it is the same React UI, talking to the Mac.
  • LAN stays independent of Tailscale (same Wi-Fi, no login).
  • Tailscale is embedded in both apps. Sign in with Google on each device (official login.tailscale.com “Connect this device” page). No Tailscale app on the iPad.
  • The Mac stays the source of truth. The iPad forwards invoke / listen over a token-gated WebSocket.

This is running here: Mac shows Connected — 1 device, iPad is in the live workspace.

Architecture

flowchart TB
  subgraph iPad["iPad companion"]
    UI["Same React UI"]
    RT["RemoteTransport"]
    NWS["Rust companion_ws"]
    TSN["tsnet userspace node"]
    UI --> RT --> NWS
    NWS -->|"LAN TCP"| LAN
    NWS -->|"100.x Dial"| TSN
  end

  subgraph Mac["Mac host"]
    L["TCP 0.0.0.0:17233"]
    E["tailscale-rs embed — DERP-only, Google SSO"]
    H["LocalTransport / host process\nsessions, SQLite, agents, PTY"]
    L --> H
    E -->|"tcp_listen 100.x:17233"| H
  end

  LAN -->|"ws:// + pairing token"| L
  TSN -->|"tailnet"| E
Loading

Wire protocol is JSON over WebSocket:

  • ws://<host>:17233/v1/connect?token=…
  • client → host { id, type: "invoke", command, args }
  • host → client { id, type: "result", ok, payload|error } or { type: "event", event, payload }

Pairing:

  • QR / deep link monocode://pair?host=&port=&token=&alt=
  • or a short-lived 6-digit code, claimed over ?pair=1 (no token yet)
  • alt carries the other live route so the iPad can switch LAN ↔ Tailscale without pairing again

Google SSO:

  • Mac: tailscale-rs device.is_authorized() login URL
  • iPad: tsnet status AuthURL
  • both open https://login.tailscale.com/a/… (Connect this device). After Google, tap Connect. Do not stop on the machines list.

Implementation highlights

Kept rebase-friendly on purpose. Desktop boot path stays LocalTransport. New code is additive; existing commands are called, not rewritten. UI call sites swap invoke / listen to src/lib/transport (one import).

Area What
Dual routes Listener is always 0.0.0.0:17233. LAN and Tailscale can both be on. Persist 17233:lan,tailscale.
Mac embed tailscale crate 0.5, desktop-only, Google login, hostname from the Mac. Optional system tailscale serve is off by default.
iPad embed Go tsnet c-archive (tailscale.com v1.102.3) + Rust FFI. No Network Extension. Hostname monocode-ipad (iOS gethostname is often localhost).
Native WS WKWebView is https://tauri.localhost, so a JS WebSocket to ws://LAN is mixed content and dies silently. Dial from Rust; JS gets companion-ws events.
Scan Prefer LAN, then tailnet. Tailnet dial times out instead of sitting on “Connecting…”. Ignore login.tailscale.com QRs so the camera does not open Safari.
iPad chrome Hide Mac-only settings (keybindings, providers). Disconnect does not show the Mac pairing QR.

New / ios-or-desktop-gated files (sketch):

  • src/lib/transport/ — protocol, RemoteTransport, pairing store
  • src-tauri/src/remote.rs, remote_server.rs, remote_dispatch.rs
  • src-tauri/src/companion_ws.rs — iPad client socket
  • src-tauri/src/tailnet_embed.rs — Mac node
  • src-tauri/src/tsnet_mobile.rs + src-tauri/tsnet/ — iPad node
  • src/surfaces/CompanionPage.tsx, CompanionPairing.tsx, QrScanner.tsx, TailnetStatusCard.tsx

Screenshots

Captured on a paired Mac + iPad. Source files: alexfoxtm/monocode-companion-shots.

Mac Companion connected (1 device), LAN + Tailscale QRs

Mac Companion connected — 1 device, LAN and Tailscale QRs

iPad workspace after pair

iPad workspace after pairing

iPad Settings → Companion (client, Connected, monocode-ipad)

iPad Settings Companion — client, Connected, monocode-ipad

Mac Google sign-in

Mac Companion Google sign-in

Mac dual-route QRs (LAN live while Tailscale joins)

Mac Companion dual-route QRs

iPad pairing camera + Sign in with Google

iPad pairing scan

iPad pairing Sign in with Google

Limits I would not hide

  • tailscale-rs is pre-1.0, unaudited, DERP-only. Treat the embedded Mac path as experimental. Pairing token still gates the socket; there is no extra TLS on that path (WireGuard / DERP covers the tailnet).
  • No MagicDNS from the crate. Pair with the node’s 100.x address.
  • System Tailscale on the Mac is optional (tailscale serve --tcp), not required.
  • iPad tsnet has no VPN interface; it only dials.

Ask

Does this direction make sense for MonoCode? If yes, I can send a small first slice (LAN-only pairing, or Mac settings + protocol, or iPad shell) instead of one large PR. If you are already building something else here, I will stop.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions