feat(offline): transport kill switch + gate the network-requiring UI
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s

Offline mode was 'fiction': the UI only disabled the Stats tab, but Profile was
reachable and a profile save actually hit the server and reported 'saved'.

Two layers now:
- Transport kill switch (transport.ts): in offline mode every network op — each
  unary RPC (exec), the live stream (subscribe), the dict/metrics fetches and
  the reachability probe — is refused before it leaves the device. Offline
  truly means offline regardless of any UI that slips through. The local
  (device-only) game path never uses this transport, so it is unaffected.
- UI gating: the Profile and Friends tabs (SettingsHub) and the Feedback entry
  (About) are disabled offline, and the hub falls back to Settings (which holds
  the online/offline toggle); the lobby Stats tab was already disabled. In-game
  social/export are already hidden for a vs_ai game.

Online unaffected (offlineMode is off): e2e 196. Offline gating is
contour-verified (the mock e2e cannot enter offline).
This commit is contained in:
Ilia Denisov
2026-07-06 15:46:22 +02:00
parent 2a045a5b37
commit 1a456c4847
3 changed files with 25 additions and 3 deletions
+14
View File
@@ -12,6 +12,7 @@ import { GatewayError, type GatewayClient } from './client';
import * as codec from './codec';
import { browserOffset } from './profileValidation';
import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
import { offlineMode } from './offline.svelte';
import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte';
import { maintenanceRetryMs } from './maintenance';
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
@@ -19,6 +20,13 @@ import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry'
const MAX_RETRIES = 6;
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
// The offline kill switch: in deliberate offline mode every network operation is refused before it
// leaves the device, so offline truly means offline regardless of any UI affordance that slips
// through. The local (device-only) game path never uses this transport, so it is unaffected.
function assertOnline(): void {
if (offlineMode.active) throw new GatewayError('offline');
}
export function createTransport(baseUrl: string): GatewayClient {
const origin = baseUrl || (typeof location !== 'undefined' ? location.origin : '');
const transport = createConnectTransport({ baseUrl: origin, useBinaryFormat: true });
@@ -32,6 +40,7 @@ export function createTransport(baseUrl: string): GatewayClient {
// (it must reject when there is no session, so the watcher keeps waiting rather than reporting up).
const reachabilityProbe = async (): Promise<void> => {
if (!token) throw new Error('no session');
assertOnline();
await client.execute({ messageType: 'profile.get', payload: codec.empty(), requestId: '' }, { headers: headers() });
};
registerProbe(reachabilityProbe);
@@ -43,6 +52,7 @@ export function createTransport(baseUrl: string): GatewayClient {
// dropped connection or a rate-limit recovers seamlessly) and driving the global Connecting
// indicator. A successful round-trip marks the gateway reachable; a domain result_code is final.
async function exec(messageType: string, payload: Uint8Array, signal?: AbortSignal): Promise<Uint8Array> {
assertOnline();
for (let attempt = 0; ; attempt++) {
let res;
try {
@@ -82,6 +92,7 @@ export function createTransport(baseUrl: string): GatewayClient {
async fetchDict(variant, version, opts) {
if (!token) throw new Error('fetchDict: no session');
assertOnline();
// Low priority so the browser schedules the (large) dictionary behind the game's own
// requests — the move eval, state and live events — on a slow link (EDGE). Ignored
// where the Priority Hints API is unsupported (iOS WebKit), which is harmless. The
@@ -99,6 +110,7 @@ export function createTransport(baseUrl: string): GatewayClient {
async reportLocalEval(counts) {
if (!token) throw new Error('reportLocalEval: no session');
assertOnline();
// keepalive so a batch flushed as the app is backgrounded still reaches the edge.
const res = await fetch(`${origin}/metrics/local-eval`, {
method: 'POST',
@@ -319,6 +331,8 @@ export function createTransport(baseUrl: string): GatewayClient {
},
subscribe(onEvent, onError) {
// No live stream in offline mode (the kill switch); return an inert unsubscribe.
if (offlineMode.active) return () => {};
const ctrl = new AbortController();
void (async () => {
try {