diff --git a/src/app/state/sessions.ts b/src/app/state/sessions.ts index a9c34ce9..19f2212d 100644 --- a/src/app/state/sessions.ts +++ b/src/app/state/sessions.ts @@ -40,17 +40,17 @@ export function setFallbackSession( localStorage.setItem('cinny_user_id', userId); localStorage.setItem('cinny_hs_base_url', baseUrl); } -export const removeFallbackSession = () => { - localStorage.removeItem('cinny_hs_base_url'); - localStorage.removeItem('cinny_user_id'); - localStorage.removeItem('cinny_device_id'); - localStorage.removeItem('cinny_access_token'); +export const removeFallbackSession = (storage: Storage = localStorage) => { + storage.removeItem('cinny_hs_base_url'); + storage.removeItem('cinny_user_id'); + storage.removeItem('cinny_device_id'); + storage.removeItem('cinny_access_token'); }; -export const getFallbackSession = (): Session | undefined => { - const baseUrl = localStorage.getItem('cinny_hs_base_url'); - const userId = localStorage.getItem('cinny_user_id'); - const deviceId = localStorage.getItem('cinny_device_id'); - const accessToken = localStorage.getItem('cinny_access_token'); +export const getFallbackSession = (storage: Storage = localStorage): Session | undefined => { + const baseUrl = storage.getItem('cinny_hs_base_url'); + const userId = storage.getItem('cinny_user_id'); + const deviceId = storage.getItem('cinny_device_id'); + const accessToken = storage.getItem('cinny_access_token'); if (baseUrl && userId && deviceId && accessToken) { const session: Session = { diff --git a/src/index.tsx b/src/index.tsx index 6c7a3bc2..51e9760f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -30,29 +30,32 @@ import { getFallbackSession } from './app/state/sessions'; document.body.classList.add(configClass, varsClass); -// Register Service Worker -if ('serviceWorker' in navigator) { - const swUrl = - import.meta.env.MODE === 'production' - ? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js` - : `/dev-sw.js?dev-sw`; +// Register Service Worker. After the preview demo has settled the stored session: the worker is +// handed that session, and the demo may drop one it cannot serve. +const registerServiceWorker = () => { + if ('serviceWorker' in navigator) { + const swUrl = + import.meta.env.MODE === 'production' + ? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js` + : `/dev-sw.js?dev-sw`; - const sendSessionToSW = () => { - const session = getFallbackSession(); - pushSessionToSW(session?.baseUrl, session?.accessToken); - }; + const sendSessionToSW = () => { + const session = getFallbackSession(); + pushSessionToSW(session?.baseUrl, session?.accessToken); + }; - navigator.serviceWorker.register(swUrl).then(sendSessionToSW); - navigator.serviceWorker.ready.then(sendSessionToSW); + navigator.serviceWorker.register(swUrl).then(sendSessionToSW); + navigator.serviceWorker.ready.then(sendSessionToSW); - navigator.serviceWorker.addEventListener('message', (ev) => { - const { type } = ev.data ?? {}; + navigator.serviceWorker.addEventListener('message', (ev) => { + const { type } = ev.data ?? {}; - if (type === 'requestSession') { - sendSessionToSW(); - } - }); -} + if (type === 'requestSession') { + sendSessionToSW(); + } + }); + } +}; // The pretend homeserver of the dev preview: built in only with VITE_PREVIEW_DEMO=1 (the dev // workflow) and switched on by `previewDemo: true` in config.json. Any other build drops it. @@ -76,4 +79,7 @@ const mountApp = () => { root.render(); }; -installPreviewDemo().finally(mountApp); +installPreviewDemo().finally(() => { + registerServiceWorker(); + mountApp(); +}); diff --git a/src/preview-demo/install.ts b/src/preview-demo/install.ts index a7b66de9..10b9a3ee 100644 --- a/src/preview-demo/install.ts +++ b/src/preview-demo/install.ts @@ -4,6 +4,7 @@ // only by a build with VITE_PREVIEW_DEMO=1 whose config.json says previewDemo: true (src/index.tsx). // Own-origin requests (the app's static files) pass; the demo origin is answered in-page; any // other origin is refused, so no real homeserver can be contacted while this is on. +import { removeFallbackSession } from '../app/state/sessions'; import { DEMO_ORIGIN } from './fixtures'; import { createDemoServer, DemoServer } from './server'; @@ -27,8 +28,6 @@ const refusal = (what: string, url: string) => new Error(`preview: pretend server only; ${what} to ${url} refused`); const BANNER_TEXT = 'preview: pretend server. Any password works.'; -const FOREIGN_SESSION_TEXT = - 'preview: pretend server. A login for another server is stored here and cannot be used.'; // In flow, above the app: body becomes a column and the app root takes the rest, so the banner // never covers the room header or the connection strip. @@ -78,7 +77,16 @@ export const guardedFetch = }); }; +// Every preview of this site shares one origin, so one localStorage: a login another preview left +// (a real homeserver) would make the app connect to that server, which the guard refuses. The +// demo has one server, so a stored login for any other is dropped before the app reads it. +export const dropForeignSession = (storage: Storage) => { + const base = storage.getItem('cinny_hs_base_url'); + if (base !== null && base !== DEMO_ORIGIN) removeFallbackSession(storage); +}; + export const installDemo = (win: Window & typeof globalThis = window) => { + dropForeignSession(win.localStorage); const server = createDemoServer(); const { origin } = win.location; const realFetch = win.fetch.bind(win); @@ -111,7 +119,6 @@ export const installDemo = (win: Window & typeof globalThis = window) => { routeOf(String(url), origin) === 'own' ? realBeacon(url, data) : false; } - const stored = win.localStorage.getItem('cinny_hs_base_url'); - showBanner(stored !== null && stored !== DEMO_ORIGIN ? FOREIGN_SESSION_TEXT : BANNER_TEXT); + showBanner(BANNER_TEXT); return server; }; diff --git a/src/preview-demo/server.test.ts b/src/preview-demo/server.test.ts index d277b97b..ec9031c1 100644 --- a/src/preview-demo/server.test.ts +++ b/src/preview-demo/server.test.ts @@ -2,9 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ClientEvent, MatrixClient, SyncState, createClient } from 'matrix-js-sdk'; import { DEMO_ORIGIN, ME, ROOMS, GENERAL, COMMONS } from './fixtures'; -import { guardedFetch, installDemo, routeOf } from './install'; +import { dropForeignSession, guardedFetch, installDemo, routeOf } from './install'; import { createDemoServer, DemoServer } from './server'; import { seedDemoSettings } from './seed'; +import { getFallbackSession } from '../app/state/sessions'; import { mindOfSender, POSE_KEY, resolvePose } from '../app/components/message/pose/poses'; import { parseRoomNotice, roomTrainingLine } from '../app/utils/trainingNotice'; @@ -233,6 +234,7 @@ describe('the guard', () => { localStorage: { getItem: (k: string) => store.get(k) ?? null, setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k), }, } as unknown as Window & typeof globalThis; vi.stubGlobal('document', { @@ -254,6 +256,59 @@ describe('the guard', () => { vi.unstubAllGlobals(); }); + it('drops a stored login for a real homeserver before the app reads it, keeps the demo one', () => { + const SESSION = { + cinny_access_token: 'tok', + cinny_device_id: 'DEV', + cinny_user_id: '@a:b', + cinny_hs_base_url: 'https://chat.gamecult.org', + }; + const store = new Map(Object.entries({ ...SESSION, settings: '{}' })); + const storage = { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k), + } as Storage; + dropForeignSession(storage); + expect(getFallbackSession(storage)).toBeUndefined(); + expect([...store.keys()]).toEqual(['settings']); + + Object.entries({ ...SESSION, cinny_hs_base_url: DEMO_ORIGIN }).forEach(([k, v]) => + store.set(k, v) + ); + dropForeignSession(storage); + expect(getFallbackSession(storage)?.baseUrl).toBe(DEMO_ORIGIN); + }); + + it('installDemo clears a foreign login so the app starts at the login screen', () => { + const store = new Map([ + ['cinny_hs_base_url', 'https://chat.gamecult.org'], + ['cinny_access_token', 'tok'], + ['cinny_device_id', 'DEV'], + ['cinny_user_id', '@a:b'], + ]); + const win = { + location: { origin: PAGE }, + fetch: never, + WebSocket: class {}, + XMLHttpRequest: { prototype: { open: vi.fn() } }, + navigator: {}, + localStorage: { + getItem: (k: string) => store.get(k) ?? null, + removeItem: (k: string) => void store.delete(k), + }, + } as unknown as Window & typeof globalThis; + vi.stubGlobal('document', { + createElement: () => ({ style: {}, setAttribute: vi.fn() }), + getElementById: () => null, + body: { style: {}, insertBefore: vi.fn() }, + }); + const server = installDemo(win); + live.push({ server, mx: clientFor(server) }); + expect(store.size).toBe(0); + vi.unstubAllGlobals(); + }); + it('seeds pose portraits on for a first visit and keeps what a visitor already chose', () => { const fresh = new Map(); seedDemoSettings({