Trust is a separate problem from reachability
Approving your own machines
Moonlight pairing is a PIN ceremony: the client generates four digits, the host operator types them into a web UI. That is fine once. It is absurd when both machines belong to you, are signed into the same Tailscale account, and one of them is in another room. Connector keeps the ceremony but automates the human out of it — carefully, and only when it can cryptographically-adjacent-prove that both ends are yours.
The gate is canAutoApprovePairForDevice(). It has four hard conditions, and each failure produces a
specific, quotable reason string that lands in the pairing status line rather than a generic error.
route == "Tailscale". A manual endpoint is never auto-approved: "This host is not using the Connector tailnet route, so Connector cannot auto-authorize it safely."
UserID from status --json.
tailscale whois --json <ip> must return a UserProfile whose ID and LoginName both match the local node's. A mismatch fails loudly: "This host is on Tailscale, but it belongs to a different account."
Only when all four hold does startAutoApprovePairing() begin. It runs on a
QtConcurrent thread, retries up to 60 times with a 500 ms sleep — a thirty-second budget —
and breaks out early if the failure reason mentions a different account, an unverifiable local Tailscale account, or a
missing non-interactive host control path. Those three are permanent failures; retrying them is just noise.
"Same-tailnet clients should auto-trust. PIN approval is only the fallback path."
The approval command
What actually lands on the host is a single subcommand of the staged helper. It validates before it acts:
- PIN must match ^\d{4}$ exactly
- client name required, whitespace-collapsed
- client name capped at 128 characters
- if the runtime is not responding, start it first and record
started_runtime - then try each Sunshine candidate path in turn
For each candidate it ensures an apps.json exists, then runs
sunshine --approve-pair <pin> <name> with a 10-second timeout and parses the last JSON
object in stdout — a small robustness trick against runtimes that log before they answer.
The name written into the host's trust record is localPairingClientName(): the local Tailscale
hostname if known, otherwise QHostInfo::localHostName(), otherwise the literal string
"Connector Client".
Pairing sequence
Two independent conversations run at once. The vendored Moonlight pairing launcher talks TLS to the host's Sunshine
HTTP service and waits for the PIN to be accepted. Meanwhile Connector's own thread repeatedly SSHes in and types the
PIN on the host's behalf. Whichever finishes first, the launcher's success signal is the only thing that
flips the trust state.
Pressing CONNECT on a reachable but untrusted host does not error. connectToDevice() stores
m_pendingConnectDeviceId and m_pendingConnectAppName, calls pairDevice(), and
lets the pairing success handler resume the connection 1.2 seconds later. If pairing fails, the pending connect is
cleared rather than left dangling.
Session lifecycle
On macOS and Linux, shouldUseStreamProcess() returns true unconditionally: the stream always runs in a
separate connectorstream process. The two escape hatches are environment-only —
CONNECTOR_STREAM_CHILD (already inside the helper) and CONNECTOR_EMBEDDED_STREAM=1. The
in-process path still exists and is still compiled; it is simply not the default, because a decoder crash should not
take the cockpit with it.
setState() calls in
connectorsessionmanager.cpp; the cockpit's status line and the session.state RPC both
surface the same value. The parent never parses the child's window — it parses the child's stdout.
Driving the helper directly
Because the helper is an ordinary QCommandLineParser program that speaks JSON on both pipes, it can be
exercised without the cockpit at all. This is the actual option set declared in connectorstream_main.cpp.
$ build/connector-app/connectorstream \ --host 100.64.0.12 \ --device-name workstation \ --app Desktop \ --quality 1080p \ --display-mode windowed \ --width 1920 --height 1080 \ --absolute-mouse 0 \ --performance-overlay 0 \ --microphone-passthrough 1 {"active":false,"connecting":true,"error":"","event":"state","stage":"Finding workstation..."} {"active":false,"connecting":true,"error":"","event":"state","stage":"Preparing Desktop..."} {"active":false,"connecting":true,"error":"","event":"state","stage":"Connecting to workstation..."} {"error":"","event":"started","stage":"Connected to workstation"} # stdin accepts one command object per line {"command":"switch_display","display_index":1} {"command":"toggle_stats"} {"command":"disconnect"} {"error":"","event":"ended","stage":"Session ended"}
Exit codes: 2 when the host argument is missing or the
session refuses to start, 3 from connectorpair on a pairing timeout. Both helpers print a
machine-readable error event before returning.
Mouse routing contract
One of only two behaviours in the codebase extracted into a pure function with its own unit test. It exists because absolute desktop mouse input is wrong on a specific, identifiable configuration.
| Input | Result |
|---|---|
| not a desktop session | pass the preference through unchanged |
| preference is relative | Relative — "direct desktop mouse mode is disabled in settings" |
| Linux host, > 1 remote display | Relative — forced |
| otherwise | Absolute — "direct desktop mouse mode" |
Verbatim from the source: "Connector is using relative mouse mode because Sunshine can misroute absolute desktop mouse input on Linux multi-display hosts." Rather than shipping a confusing setting, Connector detects the condition, overrides the user's preference, and explains itself in the session detail line.
The companion contract, evaluateDefaultMonitorRequest(), is equally blunt about scope:
"Monitor defaults are only configurable on Linux hosts in v1." Both are covered by the
connector_control_contracts ctest suite.