fix(offline): cold-boot offline from the persisted session + profile #199

Merged
developer merged 5 commits from feature/offline-boot into development 2026-07-06 14:25:11 +00:00
3 changed files with 25 additions and 3 deletions
Showing only changes of commit 1a456c4847 - Show all commits
+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 {
+2 -1
View File
@@ -2,6 +2,7 @@
import { t } from '../lib/i18n/index.svelte';
import { app } from '../lib/app.svelte';
import { navigate } from '../lib/router.svelte';
import { offlineMode } from '../lib/offline.svelte';
import { aboutContent } from '../lib/aboutContent';
import { onExternalLinkClick } from '../lib/links';
@@ -35,7 +36,7 @@
<p class="muted">{t('about.version', { v: version })}</p>
{#if !(app.profile?.isGuest ?? true)}
<button class="feedback" onclick={() => navigate('/feedback')}>
<button class="feedback" disabled={offlineMode.active} onclick={() => navigate('/feedback')}>
{t('feedback.title')}{#if app.feedbackReplyUnread}<span class="fbadge">1</span>{/if}
</button>
{/if}
+9 -2
View File
@@ -6,6 +6,7 @@
import Friends from './Friends.svelte';
import About from './About.svelte';
import { app } from '../lib/app.svelte';
import { offlineMode } from '../lib/offline.svelte';
import { t, type MessageKey } from '../lib/i18n/index.svelte';
// The Settings hub: a single nav bar + bottom tab bar hosting the Settings / Profile /
@@ -23,6 +24,12 @@
$effect(() => {
if (guest && tab === 'friends') tab = 'settings';
});
// Offline mode has no network, so the Profile and Friends surfaces (which read/save over the
// network) are disabled — fall back to Settings if the hub is on one (a deep-link or a live flip).
// Settings stays reachable: it holds the online/offline toggle.
$effect(() => {
if (offlineMode.active && (tab === 'profile' || tab === 'friends')) tab = 'settings';
});
const titleKey: Record<SettingsTab, MessageKey> = {
settings: 'settings.title',
@@ -48,11 +55,11 @@
<button class="tab" class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>
<span class="face"><span class="sq" aria-hidden="true">⚙️</span><span class="lbl">{t('settings.title')}</span></span>
</button>
<button class="tab" class:active={tab === 'profile'} onclick={() => (tab = 'profile')}>
<button class="tab" class:active={tab === 'profile'} disabled={offlineMode.active} onclick={() => (tab = 'profile')}>
<span class="face"><span class="sq" aria-hidden="true">👤</span><span class="lbl">{t('profile.title')}</span></span>
</button>
{#if !guest}
<button class="tab" class:active={tab === 'friends'} onclick={() => (tab = 'friends')}>
<button class="tab" class:active={tab === 'friends'} disabled={offlineMode.active} onclick={() => (tab = 'friends')}>
<span class="face"><span class="sq" aria-hidden="true">🤝{#if app.notifications > 0}<span class="badge">{app.notifications}</span>{/if}</span><span class="lbl">{t('friends.title')}</span></span>
</button>
{/if}