fix(offline): cold-boot offline from the persisted session + profile #199
@@ -12,6 +12,7 @@ import { GatewayError, type GatewayClient } from './client';
|
|||||||
import * as codec from './codec';
|
import * as codec from './codec';
|
||||||
import { browserOffset } from './profileValidation';
|
import { browserOffset } from './profileValidation';
|
||||||
import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
|
import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
|
||||||
|
import { offlineMode } from './offline.svelte';
|
||||||
import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte';
|
import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte';
|
||||||
import { maintenanceRetryMs } from './maintenance';
|
import { maintenanceRetryMs } from './maintenance';
|
||||||
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
|
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
|
||||||
@@ -19,6 +20,13 @@ import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry'
|
|||||||
const MAX_RETRIES = 6;
|
const MAX_RETRIES = 6;
|
||||||
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
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 {
|
export function createTransport(baseUrl: string): GatewayClient {
|
||||||
const origin = baseUrl || (typeof location !== 'undefined' ? location.origin : '');
|
const origin = baseUrl || (typeof location !== 'undefined' ? location.origin : '');
|
||||||
const transport = createConnectTransport({ baseUrl: origin, useBinaryFormat: true });
|
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).
|
// (it must reject when there is no session, so the watcher keeps waiting rather than reporting up).
|
||||||
const reachabilityProbe = async (): Promise<void> => {
|
const reachabilityProbe = async (): Promise<void> => {
|
||||||
if (!token) throw new Error('no session');
|
if (!token) throw new Error('no session');
|
||||||
|
assertOnline();
|
||||||
await client.execute({ messageType: 'profile.get', payload: codec.empty(), requestId: '' }, { headers: headers() });
|
await client.execute({ messageType: 'profile.get', payload: codec.empty(), requestId: '' }, { headers: headers() });
|
||||||
};
|
};
|
||||||
registerProbe(reachabilityProbe);
|
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
|
// 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.
|
// 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> {
|
async function exec(messageType: string, payload: Uint8Array, signal?: AbortSignal): Promise<Uint8Array> {
|
||||||
|
assertOnline();
|
||||||
for (let attempt = 0; ; attempt++) {
|
for (let attempt = 0; ; attempt++) {
|
||||||
let res;
|
let res;
|
||||||
try {
|
try {
|
||||||
@@ -82,6 +92,7 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
|
|
||||||
async fetchDict(variant, version, opts) {
|
async fetchDict(variant, version, opts) {
|
||||||
if (!token) throw new Error('fetchDict: no session');
|
if (!token) throw new Error('fetchDict: no session');
|
||||||
|
assertOnline();
|
||||||
// Low priority so the browser schedules the (large) dictionary behind the game's own
|
// 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
|
// 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
|
// 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) {
|
async reportLocalEval(counts) {
|
||||||
if (!token) throw new Error('reportLocalEval: no session');
|
if (!token) throw new Error('reportLocalEval: no session');
|
||||||
|
assertOnline();
|
||||||
// keepalive so a batch flushed as the app is backgrounded still reaches the edge.
|
// keepalive so a batch flushed as the app is backgrounded still reaches the edge.
|
||||||
const res = await fetch(`${origin}/metrics/local-eval`, {
|
const res = await fetch(`${origin}/metrics/local-eval`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -319,6 +331,8 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
},
|
},
|
||||||
|
|
||||||
subscribe(onEvent, onError) {
|
subscribe(onEvent, onError) {
|
||||||
|
// No live stream in offline mode (the kill switch); return an inert unsubscribe.
|
||||||
|
if (offlineMode.active) return () => {};
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
import { app } from '../lib/app.svelte';
|
import { app } from '../lib/app.svelte';
|
||||||
import { navigate } from '../lib/router.svelte';
|
import { navigate } from '../lib/router.svelte';
|
||||||
|
import { offlineMode } from '../lib/offline.svelte';
|
||||||
import { aboutContent } from '../lib/aboutContent';
|
import { aboutContent } from '../lib/aboutContent';
|
||||||
import { onExternalLinkClick } from '../lib/links';
|
import { onExternalLinkClick } from '../lib/links';
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@
|
|||||||
<p class="muted">{t('about.version', { v: version })}</p>
|
<p class="muted">{t('about.version', { v: version })}</p>
|
||||||
|
|
||||||
{#if !(app.profile?.isGuest ?? true)}
|
{#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}
|
{t('feedback.title')}{#if app.feedbackReplyUnread}<span class="fbadge">1</span>{/if}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import Friends from './Friends.svelte';
|
import Friends from './Friends.svelte';
|
||||||
import About from './About.svelte';
|
import About from './About.svelte';
|
||||||
import { app } from '../lib/app.svelte';
|
import { app } from '../lib/app.svelte';
|
||||||
|
import { offlineMode } from '../lib/offline.svelte';
|
||||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||||
|
|
||||||
// The Settings hub: a single nav bar + bottom tab bar hosting the Settings / Profile /
|
// The Settings hub: a single nav bar + bottom tab bar hosting the Settings / Profile /
|
||||||
@@ -23,6 +24,12 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (guest && tab === 'friends') tab = 'settings';
|
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> = {
|
const titleKey: Record<SettingsTab, MessageKey> = {
|
||||||
settings: 'settings.title',
|
settings: 'settings.title',
|
||||||
@@ -48,11 +55,11 @@
|
|||||||
<button class="tab" class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>
|
<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>
|
<span class="face"><span class="sq" aria-hidden="true">⚙️</span><span class="lbl">{t('settings.title')}</span></span>
|
||||||
</button>
|
</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>
|
<span class="face"><span class="sq" aria-hidden="true">👤</span><span class="lbl">{t('profile.title')}</span></span>
|
||||||
</button>
|
</button>
|
||||||
{#if !guest}
|
{#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>
|
<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>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user