DeviceLink
packages/device-link is the release-enabled, transport-only DeviceLink core
for Tutti Desktop, Android, iOS, and TSH. It owns ICE candidate negotiation,
incremental candidate-exchange coordination, QUIC over the selected packet
path, mutual ephemeral certificate pinning, and product-neutral Relay
byte-stream mechanics. It does not own Agent, Session, Turn, Workspace,
pairing, account, rendezvous, Relay authorization, or Relay product policy.
The initial implementation was upstreamed from TSH's production
core/devicelink package. It is eligible for Tutti's stable package cohort
after the authenticated Android/Desktop lifecycle and reproducible Mobile AAR
consumer build pass. Consumer adapters keep their product policy outside this
module.
The low-level candidate, ICE, TLS, and QUIC types remain provisional primitives.
Product consumers use authenticated.Participant, which composes ICE,
peer-fingerprint-pinned TLS/QUIC, stream creation, cancellation, and close
ordering. The authenticated subpackage is the one narrow aggregation layer
required by Go's dependency direction; it does not own account, pairing,
rendezvous, or Agent behavior. Product bridges must not expose raw
QUICEndpoint.Listen or Dial.
Every direct QUIC session uses a 5-second keepalive period and a 15-second maximum idle timeout. These defaults apply uniformly to Desktop, mobile, and TSH consumers so a peer whose network path disappears is retired promptly without product-specific liveness timers.
Managed connection lifecycle
The linkmanager package owns the reusable lifecycle mechanics that sit above
an authenticated link:
- generation-fenced admission, so a connection completed after invalidation cannot re-enter the pool;
- one in-flight establishment per opaque peer key;
- authenticated link reuse, stream reference counting, idle retirement, and deterministic collision resolution;
- a delayed two-path race that verifies each candidate with a transport-owned stream probe before selecting it and closes every losing late connection;
- an annealed direct-probe cache with bounded peer state and explicit environment invalidation.
The manager does not name or authenticate direct, Relay, room, account, or
rendezvous paths. Product adapters inject opaque keys and metadata, dial
functions, path timing, and policy. In particular, a consumer may let a
preferred-path probe continue after a fallback path wins so it can learn direct
reachability; that detached probe must have its own deadline and close every
connection it does not register. Cache only path reachability failures, never
authorization, control-plane, or product-policy failures. ClaimProbe returns
a generation-bound lease; complete it with RecordFailure, RecordSuccess, or
Close so invalidation can fence every late probe result.
The intended direct-path integration is:
server-authoritative STUN configuration
-> candidateexchange.Start (credentials immediately; candidates may be empty)
-> product publishes or joins its authenticated rendezvous attempt
-> linkmanager admission snapshot
-> candidateexchange.PublishLocal + FeedRemote run beside Participant.Connect
-> linkmanager.Register
-> shared OpenStream / incoming stream handler
One admission snapshot may register at most one link. Network, credential, or
ownership changes invalidate the relevant peer generation before the old
attempt is cancelled. A product-wide availability gate uses SetEnabled(false)
to fence current and future admissions until explicitly re-enabled. Shutdown
first begins manager quiescence, which rejects new admissions and streams, then
waits for accepted stream handlers to finish. Link observations carry a
per-connection sequence; projections must ignore older deliveries and treat
disconnected as terminal for that globally comparable connection ID.
Lifecycle controllers that cannot wait for transport I/O use Retire or
RetireAll. These methods synchronously advance admission generations, revoke
incoming handlers, and remove the exact old link, then return immutable
Retirement handles for physical close outside the controller lock. A delayed
retirement close never looks up a key again and therefore cannot close a newer
link registered for the same opaque key. Invalidate and InvalidateAll
remain synchronous compatibility wrappers.
Tutti's mobileremote Desktop owner is the first production adapter: it keeps
pairing, identity proof, rendezvous, and Agent framing in tuttid, while the
shared manager owns the authenticated link and incoming stream lifecycle.
Relay byte-stream transport
The relaytransport package owns the reusable mechanics for Relay-backed byte
streams. Dial turns binary WebSocket messages into a net.Conn for one
caller stream and uses Ping/Pong liveness so an unresponsive peer closes the
stream for the product's existing reconnect policy. DialLivenessConfig has
20-second ping and 60-second pong defaults. OwnerHost maintains one
WebSocket/yamux owner tunnel while at least one product driver holds a
reference, accepts remote streams only after the product readiness barrier
succeeds, and reconnects with bounded full-jitter backoff plus Retry-After.
OwnerLifecycle.Activate returns an OwnerActivation: its Readiness
context is a continuous product health condition for the complete connection
generation. Cancelling it closes the generation, joins its stream handlers,
reports its cancellation cause through SessionEnded, and starts a new
generation while demand remains.
The owner path has an explicit ownership split:
product demand (zero -> one)
-> product OwnerLifecycle.Prepare
-> relaytransport WebSocket dial + liveness
-> product OwnerLifecycle.Activate continuous readiness
-> relaytransport yamux stream acceptance
-> product StreamHandler
-> final product demand release
-> stop tunnel and handlers
-> exact OwnerLifecycle.Release
The lifecycle factory creates isolated product state for every zero-to-one
demand run. A final release may overlap a new acquire, but the old release can
only clean up its own lifecycle. WebSocket ping/pong owns tunnel liveness, so
yamux keepalive is disabled. Internal yamux logging is discarded; adapters map
sanitized, typed OwnerEvent values into product logs or metrics. Retry events
separate the backoff cap, chosen jitter, server Retry-After, and total delay;
liveness events expose only ping/pong counts and timestamps, never payloads.
networkchange.Monitor is the process-level transport signal for reconnect
self-healing. It starts at generation 1, publishes only later generations,
and hashes interface status, addresses, and—where the platform exposes a
reliable native table—default-route identity. Darwin uses a no-cgo route socket
as a trigger with 500ms debounce and a 30s safety sample; its default-route
summary comes from the native routing information base. Linux samples
/proc/net/route and /proc/net/ipv6_route; Windows samples IP Helper's
GetIpForwardTable2. Android 10 and newer deny ordinary applications access
to /proc/net, so Android retains only the interface/address summary until a
native ConnectivityManager source is provided. Android, iOS, and other
unsupported platforms explicitly do not claim default-route coverage. Windows
and mobile fallback to 2s polling, while Darwin watcher failure also falls back
to 2s polling.
Monitor.Status() is a read-only diagnostic snapshot with stopped,
starting, watching, or polling mode plus a sanitized polling reason and
bounded sample-health counters.
Consumers should derive watcher health from ModeWatching; they must not
invent a separate watcherHealthy field. An
OwnerHost.AdvanceNetworkGeneration call cancels only the current attempt or
retry wait, closes the old Relay transport, and reuses the same owner lifecycle
for an immediate retry. It does not clear credentials; SessionEnded remains
the product-owned credential policy boundary.
Default-route changes are included only after successful parsing of the platform snapshot. A route-table read or parse failure fails that sample and does not advance generation, preserving the monitor's fail-closed rule rather than silently pretending that default-route coverage is complete.
Relay endpoints, headers, query values, credentials, leases, registrations,
room or pairing state, application protocols, and token invalidation remain in
consumer adapters. In particular, a shared transport error is evidence for the
adapter to update its product state, not permission for this package to
interpret HTTP status codes as product policy. DialError makes a bounded
handshake response body available for adapter-owned wire-reason parsing, but
does not include that body in its error string; adapters must not persist the
raw value in ordinary logs or metrics.
OwnerHost.Wake is a product-neutral demand-preserving interrupt for the
current generation or retry wait. It coalesces repeated requests, does not
release or acquire references, and does not reset reconnect backoff.
Stream readiness probe
ProbeStream and ServeStreamProbe form the product-neutral readiness barrier
for a newly opened authenticated stream. The caller writes a fresh nonce and
selects the path only after the peer echoes that nonce. This matters for stale
QUIC sessions: OpenStreamSync can succeed locally after a network transition
even though the peer is no longer reachable. The probe is deliberately
separate from Agent framing and leaves the verified stream open for the
consumer's application protocol. Owners must run ServeStreamProbe before
dispatching the stream to their handler.
Trickle ICE and protocol migration
The candidateexchange package is the consumer-facing Trickle ICE coordinator.
Start returns publishable ICE credentials immediately, even when the initial
candidate list is empty. NextLocalPublication and
AcknowledgeLocalPublication expose the same acknowledgement-bound state
machine used by PublishLocal: candidate notifications are coalesced, a failed
publication is reissued with the same ID and exact snapshot after the shared
retry interval, and the stream stops only after the final local snapshot has
been observed. NotifyRemoteChange and WaitRemoteRefresh expose the same
push-plus-poll scheduler used by FeedRemote; fetched candidates are then
deduplicated into an in-progress Connect. Fetch failures terminate unless the
consumer explicitly classifies them as retryable. The default authoritative
poll fallback is 500ms.
Callback-free consumers use ActionPump. It owns the local-publication and
remote-refresh workers, retry scheduling, stop ordering, and a
one-outstanding-action-per-worker protocol. A consumer may drain the local and
remote actions concurrently, so a slow authoritative read cannot hold up local
candidate publication. It performs only product-authenticated rendezvous I/O
for publish_local and refresh_remote, then resolves each action with success
plus retryability. A local publication is acknowledged only after that
resolution, so a caller can first verify that the server's returned
authoritative snapshot contains the exact published candidates.
Consumers inject only their rendezvous reads/writes and retry classification.
The shared package does not create attempts, sign requests, interpret room or
pairing state, or authorize peers. Lower-level callers may still use
StartLocalDescription, LocalCandidateChanges,
LocalDescriptionSnapshot, LocalGatheringComplete, and
AddRemoteCandidates directly, but product adapters should prefer
candidateexchange so debounce, exact-snapshot retry, completion, and
push-plus-poll behavior do not drift. A Participant represents one connection
attempt and must not be reused after completion or cancellation.
Current integration status:
| Consumer | Candidate-exchange path | Status |
|---|---|---|
| Tutti Desktop owner | Go candidateexchange.Start, PublishLocal, and FeedRemote around the signed paired-device attempt adapter |
Integrated |
| Tutti Android/iOS caller | Go ActionPump workers exposed as next/resolve actions; TypeScript executes and validates signed authoritative attempt I/O only |
Integrated |
| TSH Desktop | Uses the same low-level authenticated.Participant Trickle primitives, but its adapter-local publish/feed loops have not yet cut over |
Migrate after the next stable DeviceLink release; no workspace replace |
| tsh-server paired-device rendezvous | Protocol v2 accepts valid credentials with zero candidates and repeated authoritative participant snapshots | Compatible; no server schema change required |
For the TSH cutover, adapter-local publishTrickledCandidates maps to
Exchange.PublishLocal, feedRemoteCandidates maps to
Exchange.FeedRemote, and the local 200ms debounce maps to the shared default.
TSH retains its room lease checks, attempt TTL context, candidate-summary
diagnostics, configurable poll cadence, and product fallback policy. This keeps
the migration behavioral: it deletes duplicated connection mechanics without
moving room authority or Relay policy into this package.
Network path policy
The default empty NetworkPolicy is system. It keeps the operating system's
network view, but applies a shared LAN-first candidate preference: private
IPv4/ULA host candidates receive the normal host priority, global-unicast host
candidates receive a lower host priority, and server-reflexive candidates
remain available as the public-network fallback.
system includes active TUN interfaces for fallback, while direct additionally
binds sockets to physical interfaces where the platform supports it. Product
consumers should use system unless they expose an explicit advanced routing
setting.
ALPN remains the canonical protocol name. During a rolling migration, a
consumer may add a bounded list of CompatibleProtocols; the canonical value
is always offered first. After connection, Link.NegotiatedProtocol lets the
product adapter select only the matching application framing. Compatibility
entries are a temporary cutover tool, not a promise that old wire protocols
share semantics.
authenticated.ErrorPhase distinguishes connectivity failures from
authenticated transport failures so consumers can keep network probe caches
free of certificate, ALPN, QUIC-handshake, and caller-cancellation failures.
authenticated.FailurePhase additionally reports the layer interrupted by a
caller cancellation. It is reserved for callers that own a dedicated probe
deadline and verify that exact context cause before recording a verdict.
Mobile vertical slice
The mobile package deliberately exposes only a gomobile-safe authenticated
link facade:
- the current application-stream protocol epoch;
- creation from server-authoritative STUN endpoints;
- non-blocking local-description start plus a callback-free Go action pump for acknowledgement-bound publication and authoritative remote refresh;
- JSON next/resolve actions, push notification, incremental remote-candidate
insertion, and Go-owned candidate-worker cancellation while
Connectruns; - caller/owner connection using the peer description;
- authenticated bidirectional stream open/accept/read/write/deadline/close;
OpenStreamWithRelay, which races a direct stream dial and an authorized Relay stream dial concurrently, requires the shared stream probe on both candidates, and closes the losing stream. The returned byte stream has crossed the transport readiness barrier; callers still own their application protocol handshake and request semantics;- the loopback integration probe used by the Android build gate.
The mobile read boundary intentionally fills a caller-owned byte buffer and
returns only a scalar count. Do not replace it with a Go []byte plus error
return while the pinned Go/cgo toolchain is affected by packed-result alignment
failures: gomobile cannot preserve final bytes returned together with io.EOF,
and the generated pointer-bearing result may abort the Android process before
Java receives it.
mobile.DialRelay is the corresponding gomobile-safe byte-stream entry point for
an already-authorized Relay caller. It accepts the endpoint, query values,
headers, and WebSocket subprotocol as JSON map[string][]string values, then
runs the shared stream probe before returning the same Stream abstraction used
by the Agent framing adapter. A successful WebSocket upgrade alone is not proof
that the owner tunnel or Agent handler accepted the stream; the probe confirms
only that the transport owner is responsive. The mobile package does not issue credentials,
select a target, or decide when to fall back; those policies remain in the
Mobile pairing service and native bridge. Link.OpenStreamWithRelay uses the
same stream abstraction to start direct and Relay together; a direct stream may
wait for an in-progress Link.Connect, while Relay starts immediately. Relay
streams must be closed by the caller after each HTTP request or when the live
subscription ends.
Account identity signatures, DeviceLink attempts, pairing scope, Agent HTTP framing, and foreground/background policy remain in the Android and tuttid product adapters. The mobile facade never accepts account cookies or Agent DTOs.
Run the portable checks:
make test
make android-crosscompile
make android-bindings-check
Android
Build the AAR with JDK 17, minSdk 26, compileSdk 36, targetSdk 35, Android Build
Tools 36.0.0, and the pinned NDK r27d (27.3.13750724). Set JAVA_HOME and
ANDROID_HOME:
make android-aar
The AAR is written to dist/tutti-device-link.aar by default. Build artifacts
under dist/ are not source and must not be committed. The build verifies the
Java binding plus armeabi-v7a, arm64-v8a, x86, and x86_64 native
libraries before succeeding.
Build the minimal arm64 Android probe APK without adding a Gradle project:
make android-probe-apk
The probe Activity invokes the same exported gomobile API and writes either
PASS or FAIL under the TuttiDeviceLinkProbe logcat tag. It is a transport
integration fixture, not a product App shell. The generated APK and its stable
local debug keystore remain under ignored dist/ so repeated local installs
keep the same signature.
The Android link step passes -checklinkname=0 because Pion's Android network
enumeration dependency github.com/wlynxg/anet uses the Go standard library's
zone cache through go:linkname. Go 1.23 and newer reject that reference unless
the documented linker compatibility flag is explicit. Keep the flag scoped to
the gomobile build; ordinary host tests and builds do not use it.
iOS
The Mobile app host binds the same DeviceLink facade and Agent-owned live Subscriber into one XCFramework containing iOS device and iOS Simulator slices. Full Xcode and the iOS SDK are required:
pnpm --filter @tutti-os/mobile check:ios-bindings
pnpm --filter @tutti-os/mobile ios:framework
The generated
apps/mobile/ios/Frameworks/TuttiMobileGo.xcframework is ignored build output.
The iOS product adapter owns HTTP/application framing, lifecycle grace, account
identity, pairing, and Agent event emission exactly as the Android adapter does;
the Go mobile package remains transport-only.
Privacy invariants
- Raw candidates, IP addresses, credentials, certificates, and application payloads must never enter ordinary logs or metrics.
- Pion logging remains fully discarded because upstream messages may include candidate addresses.
- Callers expose only categorical path scope and sanitized failure reasons.