diff --git a/docs/reference/2026-06-27-orca-mobile-manual-network-address-design.md b/docs/reference/2026-06-27-orca-mobile-manual-network-address-design.md new file mode 100644 index 000000000..fdcc7987a --- /dev/null +++ b/docs/reference/2026-06-27-orca-mobile-manual-network-address-design.md @@ -0,0 +1,193 @@ +# Design: Manual network address entry for Orca desktop mobile pairing + +**Date:** 2026-06-27 +**Scope:** Desktop renderer (Settings → Mobile → Network Interface section) +**Status:** Draft, awaiting user review + +## Problem + +`src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx` lets the user pick the network address that gets baked into the mobile-pairing QR code. Today the only options come from `networkInterfaces`, which is the list returned by the main process enumerating OS network interfaces (`en0`, `tailscale0`, etc.). If a user wants a tailnet address that the OS hasn't surfaced yet — a Tailscale MagicDNS hostname, a ZeroTier-assigned address not yet visible to the OS, or a manual LAN IP — they have no way to type one in. The QR ends up pointing at an interface the phone cannot actually reach. + +## Decision summary + +Replace the inner `Select` of `MobileNetworkInterfaceSection` with a `Popover + Command` ("combobox") pattern modeled on the existing `AgentCombobox`. The popover contains a single `CommandInput` that filters the auto-discovered interfaces above and renders a special "Use …" entry at the bottom of the list whenever the input is a valid IPv4 address or Tailscale MagicDNS hostname. Picking that entry selects a custom address; the trigger shows `
(custom)`. Custom addresses are session-scoped (cleared when the settings pane closes). + +## Constraints (from `CONTRIBUTING.md` + `AGENTS.md`) + +- Cross-platform: code paths must not assume a single platform; the manual entry path itself is platform-neutral. +- No `helpers`/`utils`/`misc` file names; use concrete names. +- No `eslint-disable max-lines`; split files instead. +- Prefer `.ts` over `.d.ts`. +- UI work follows `docs/STYLEGUIDE.md` and uses shadcn primitives from `src/renderer/src/components/ui/`. +- The renderer ↔ shared boundary is `src/shared/`; pure logic that may be reused outside the renderer goes there. +- Comments explain *why*, briefly. + +## Files + +| Path | Change | +| --- | --- | +| `src/shared/network/manual-address.ts` | **New.** Pure `parseManualNetworkAddress(input)` returning a discriminated union. | +| `src/shared/network/manual-address.test.ts` | **New.** Vitest cases for IPv4 and MagicDNS hostname validation. | +| `src/renderer/src/components/settings/mobile-network-interface-selection.ts` | Replace `mergeForSelect` with `buildComboboxEntries(interfaces, customAddress)` returning the entry list the UI maps over. | +| `src/renderer/src/components/settings/mobile-network-interface-selection.test.ts` | Replace `mergeForSelect` tests with `buildComboboxEntries` tests. | +| `src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx` | Swap `Select` for `Popover + Command`; add `open`/`query`/`customAddress` state. | +| `src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx` | **New.** Render tests via `@testing-library/react`. | + +No changes to: `mobile/app/pair-scan.tsx`, `MobilePairingQrSection.tsx`, `use-mobile-install-qr.ts`, or any main-process code. The QR generation pipeline already consumes `selectedAddress: string`, which is all the new flow produces. + +## Module 1: `parseManualNetworkAddress` + +```ts +// src/shared/network/manual-address.ts +export type ParseManualAddressResult = + | { ok: true; address: string } + | { ok: false; error: string } + +export function parseManualNetworkAddress(input: string): ParseManualAddressResult +``` + +**Rules** (in order): + +1. `input.trim()` must be non-empty. Otherwise `{ ok: false, error: 'Enter an IPv4 address or Tailscale MagicDNS hostname' }`. +2. Reject any input containing whitespace anywhere; reject any input longer than 253 chars (DNS hostname cap). +3. Accept if it matches the IPv4 grammar (four dotted octets, each 0–255). No leading zeros except for `0` itself. +4. Accept if it matches the Tailscale MagicDNS hostname grammar: + - Regex (case-insensitive): `/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*\.ts\.net$/` +5. Otherwise return the same error as (1). + +Pure function, no React, no I/O. Unit-testable in isolation. + +## Module 2: `buildComboboxEntries` + +```ts +// src/renderer/src/components/settings/mobile-network-interface-selection.ts +export type MobileNetworkInterface = { name: string; address: string } + +export type ComboboxEntry = + | { kind: 'interface'; iface: MobileNetworkInterface } + | { kind: 'use-query'; address: string } // only emitted when current query is valid + +export function buildComboboxEntries( + interfaces: readonly MobileNetworkInterface[], + query: string +): readonly ComboboxEntry[] +``` + +**Behavior:** + +- Trim `query`; if empty, return only `kind: 'interface'` entries from `interfaces` (no `use-query`). +- If `query` is non-empty, behavior branches on `parseManualNetworkAddress(query)`: + - **Valid query:** skip substring filtering and keep every interface visible (so the user can pivot to an existing interface mid-typing). Emit each as `kind: 'interface'`. + - **Invalid query:** filter `interfaces` by case-insensitive substring match on `iface.address` OR `iface.name`. Emit each as `kind: 'interface'`. If the filter yields zero matches, fall back to the full `interfaces` list (so the user always sees the available options, never an empty list mid-typing). +- After the interface entries, if the query parsed as valid AND no emitted interface has an `address` exactly equal to `parsed.address`, append `{ kind: 'use-query', address: parsed.address }`. (Suppression happens regardless of whether filtering ran, because valid queries skip filtering entirely — the check is against the visible interface list, which for valid queries is the full list.) +- Order: interface entries first (stable, in input order — either filtered or the full list per the branch above), then the optional `use-query`. +- The `selectRefreshedNetworkAddress` function is **kept** unchanged — it's still the rule that decides the *initial* `selectedAddress` when no manual entry exists. The UI calls it on mount and on Refresh; afterwards, the combobox owns the selection. + +## Module 3: `MobileNetworkInterfaceSection` UI + +Outer JSX (header text, description, Generate QR button, Refresh button, Tailnet accordion) is untouched. Only the inner selection control is replaced. + +**State:** + +```ts +const [open, setOpen] = useState(false) +const [query, setQuery] = useState('') +const [customAddress, setCustomAddress] = useState+ {translate( + 'auto.components.settings.MobileNetworkInterfaceSection.d536b5e20d', + 'Choose which network address to advertise in the QR code. Use your LAN address for same-network pairing, or an overlay network address (Tailscale, ZeroTier) for cross-network access.' + )} +
++ {ERROR_MESSAGE} +
+ ) : null} + ++ {translate( + 'auto.components.settings.MobileNetworkInterfaceSection.9fc5d203ff', + 'Orca Mobile connects directly to this computer. To use it away from the same local network, put your computer and phone on the same private overlay network, then generate the QR code with that network address selected.' + )} +
+