From 85c3b29d795a202dd76c123ba02dae8ee038a559 Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:22:04 +0000 Subject: [PATCH] announce new versions in the page, for the web app and the desktop app The web app notices a new release on the server by comparing its bundle with the one index.html names now, checked every ten minutes and when the page comes back into view, and shows a card in the corner with a Reload button. Left alone, the next move to another page reloads instead; dismissed, the card goes and that reload still happens. The desktop app downloads an update as soon as it is found and tells the page through a preload bridge, window.personalAgentProxyDesktop, which the web app shows the same card for with a Restart button, instead of a native dialog. Put off, the update is installed when the app next quits, as before. --- packages/desktop/README.md | 4 + packages/desktop/build.ts | 8 +- packages/desktop/src/channels.ts | 6 + packages/desktop/src/main.ts | 15 +- packages/desktop/src/preload.ts | 17 ++ packages/desktop/src/updates.ts | 54 ++++-- packages/desktop/src/window.ts | 2 + .../src/components/release-notice.test.tsx | 165 ++++++++++++++++++ .../web/src/components/release-notice.tsx | 93 ++++++++++ packages/web/src/components/root.tsx | 12 ++ packages/web/src/hooks/use-desktop-update.ts | 33 ++++ packages/web/src/hooks/use-new-release.ts | 52 ++++++ packages/web/src/lib/desktop.ts | 22 +++ packages/web/src/lib/release.test.ts | 77 ++++++++ packages/web/src/lib/release.ts | 75 ++++++++ packages/web/src/router.tsx | 15 +- 16 files changed, 623 insertions(+), 27 deletions(-) create mode 100644 packages/desktop/src/channels.ts create mode 100644 packages/desktop/src/preload.ts create mode 100644 packages/web/src/components/release-notice.test.tsx create mode 100644 packages/web/src/components/release-notice.tsx create mode 100644 packages/web/src/components/root.tsx create mode 100644 packages/web/src/hooks/use-desktop-update.ts create mode 100644 packages/web/src/hooks/use-new-release.ts create mode 100644 packages/web/src/lib/release.test.ts create mode 100644 packages/web/src/lib/release.ts diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 48017697..eb4c4616 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -11,6 +11,10 @@ everything but the app's own origin out of the window: links out of the app open - `src/window.ts` loads the web app and keeps the window to it. Where the web app would show its login page, the window shows a page of its own with a button that opens the browser; with no connection, one that says so. +- Updates (`src/updates.ts`): a new release of the app, checked for on start and every few hours, + is downloaded at once and announced to the page through the window's preload + (`src/preload.ts`, `window.personalAgentProxyDesktop` to the web app), which shows its release + notice with a restart button. Put off, the update is installed when the app next quits. - Signing in (`src/auth.ts`, `packages/api/src/auth/desktop.ts`): the app keeps a PKCE verifier and opens the browser at `/login?callbackUrl=/desktop-auth?challenge=…`. Signed in there, the `/desktop-auth` page mints a one-time code bound to the challenge and hands it back through diff --git a/packages/desktop/build.ts b/packages/desktop/build.ts index 9c81ff74..2063d41e 100644 --- a/packages/desktop/build.ts +++ b/packages/desktop/build.ts @@ -1,6 +1,6 @@ -// Bundles the main process into one file, dist/main.cjs, with every dependency in it; only -// `electron` stays outside, since the runtime is it. The splash pages and their stylesheet are -// bundled as text. +// Bundles the main process into one file, dist/main.cjs, and the window's preload into +// dist/preload.cjs, each with every dependency in it; only `electron` stays outside, since the +// runtime is it. The splash pages and their stylesheet are bundled as text. import {build} from 'esbuild'; import pkg from './package.json'; @@ -8,7 +8,7 @@ import pkg from './package.json'; const production = process.env.NODE_ENV === 'production'; await build({ - entryPoints: ['src/main.ts'], + entryPoints: ['src/main.ts', 'src/preload.ts'], bundle: true, platform: 'node', format: 'cjs', diff --git a/packages/desktop/src/channels.ts b/packages/desktop/src/channels.ts new file mode 100644 index 00000000..1c5d1bec --- /dev/null +++ b/packages/desktop/src/channels.ts @@ -0,0 +1,6 @@ +// The channels between the main process (updates.ts) and the page (preload.ts). + +// Main to page: the version of the update downloaded. Page to main: asks for it. +export const UPDATE_READY_CHANNEL = 'updates:ready'; +// Page to main: restart into the downloaded update. +export const RESTART_CHANNEL = 'updates:restart'; diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index baba8780..41582e0b 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -17,11 +17,20 @@ app.userAgentFallback = `${app.userAgentFallback} PersonalAgentProxyDesktop/${DE let mainWindow: BrowserWindow | null = null; -function window(): BrowserWindow { +function currentWindow(): BrowserWindow | null { if (mainWindow && !mainWindow.isDestroyed()) { return mainWindow; } + return null; +} + +function window(): BrowserWindow { + const open = currentWindow(); + if (open) { + return open; + } + mainWindow = createMainWindow(); return mainWindow; } @@ -95,7 +104,7 @@ function start(): void { } }); app.on('activate', () => { - if (mainWindow && !mainWindow.isDestroyed()) { + if (currentWindow()) { return; } loadApp(window()); @@ -107,7 +116,7 @@ function start(): void { app.dock?.setIcon(path.join(__dirname, '../build/icon.png')); } loadApp(window()); - initUpdates(); + initUpdates(currentWindow); const link = findDeepLink(process.argv); if (link) { void onDeepLink(link); diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts new file mode 100644 index 00000000..a955a6d0 --- /dev/null +++ b/packages/desktop/src/preload.ts @@ -0,0 +1,17 @@ +import {contextBridge, ipcRenderer, type IpcRendererEvent} from 'electron'; + +import {RESTART_CHANNEL, UPDATE_READY_CHANNEL} from './channels'; + +// What the page may ask of the app, as `window.personalAgentProxyDesktop`, typed on the web app's +// side in packages/web/src/lib/desktop.ts: whether an update is downloaded, word when one is, and +// the restart that installs it. The main process answers the app's own pages only (updates.ts). + +contextBridge.exposeInMainWorld('personalAgentProxyDesktop', { + updateReady: (): Promise => ipcRenderer.invoke(UPDATE_READY_CHANNEL), + onUpdateReady: (listener: (version: string) => void): (() => void) => { + const handler = (_event: IpcRendererEvent, version: string) => listener(version); + ipcRenderer.on(UPDATE_READY_CHANNEL, handler); + return () => ipcRenderer.removeListener(UPDATE_READY_CHANNEL, handler); + }, + restart: (): void => ipcRenderer.send(RESTART_CHANNEL), +}); diff --git a/packages/desktop/src/updates.ts b/packages/desktop/src/updates.ts index 85077c55..4e7ef29e 100644 --- a/packages/desktop/src/updates.ts +++ b/packages/desktop/src/updates.ts @@ -1,35 +1,55 @@ -import {app, dialog} from 'electron'; +import { + app, + ipcMain, + type BrowserWindow, + type IpcMainEvent, + type IpcMainInvokeEvent, +} from 'electron'; import {autoUpdater} from 'electron-updater'; -// Updates, from the feed electron-builder.yml names: checked on start and every few hours, and -// installed when the person says so. +import {RESTART_CHANNEL, UPDATE_READY_CHANNEL} from './channels'; +import {APP_ORIGIN} from './env'; + +// Updates, from the feed electron-builder.yml names: checked on start and every few hours. A new +// version is downloaded as soon as it is found and announced to the page, which shows its notice +// with a restart button (packages/web/src/components/release-notice.tsx); put off, it is +// installed when the app next quits, so the next start is the new version either way. const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; -export function initUpdates(): void { +let readyVersion: string | null = null; + +// Only a page of the app's may ask: a bank's sign-in popup has the window's preload too. +function fromApp(event: IpcMainEvent | IpcMainInvokeEvent): boolean { + const url = event.senderFrame?.url; + return url !== undefined && URL.canParse(url) && new URL(url).origin === APP_ORIGIN; +} + +export function initUpdates(currentWindow: () => BrowserWindow | null): void { + // A page loaded after the download, as one opened again from the Dock, asks. + ipcMain.handle(UPDATE_READY_CHANNEL, (event) => (fromApp(event) ? readyVersion : null)); + ipcMain.on(RESTART_CHANNEL, (event) => { + if (!fromApp(event) || !readyVersion) { + return; + } + autoUpdater.quitAndInstall(); + }); + // A development run has no feed, and an unsigned build can't be updated on macOS either way. if (!app.isPackaged) { return; } + autoUpdater.autoDownload = true; + autoUpdater.autoInstallOnAppQuit = true; + autoUpdater.on('error', (error) => { console.warn('[updates] Checking for updates failed:', error.message); }); autoUpdater.on('update-downloaded', (info) => { - void dialog - .showMessageBox({ - type: 'info', - message: `Personal Agent Proxy ${info.version} is ready to install`, - buttons: ['Restart now', 'Later'], - defaultId: 0, - cancelId: 1, - }) - .then(({response}) => { - if (response === 0) { - autoUpdater.quitAndInstall(); - } - }); + readyVersion = info.version; + currentWindow()?.webContents.send(UPDATE_READY_CHANNEL, info.version); }); void autoUpdater.checkForUpdates().catch(() => undefined); diff --git a/packages/desktop/src/window.ts b/packages/desktop/src/window.ts index 936891b6..68eebbdb 100644 --- a/packages/desktop/src/window.ts +++ b/packages/desktop/src/window.ts @@ -113,6 +113,8 @@ export function createMainWindow(): BrowserWindow { // a packaged app has its own, and no build/ beside it. ...(app.isPackaged ? {} : {icon: path.join(__dirname, '../build/icon.png')}), backgroundColor: nativeTheme.shouldUseDarkColors ? '#0a0a0a' : '#ffffff', + // What the page may ask of the app (src/preload.ts), bundled beside the main process. + webPreferences: {preload: path.join(__dirname, 'preload.cjs')}, }); keepWindowBounds(win); diff --git a/packages/web/src/components/release-notice.test.tsx b/packages/web/src/components/release-notice.test.tsx new file mode 100644 index 00000000..169fffe9 --- /dev/null +++ b/packages/web/src/components/release-notice.test.tsx @@ -0,0 +1,165 @@ +/// + +import {afterEach, beforeEach, describe, expect, test} from 'bun:test'; +import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; +import {createMemoryRouter, Link, RouterProvider} from 'react-router'; +import {ReleaseNotice} from '@/components/release-notice'; + +const realFetch = globalThis.fetch; +let serverBundle = '/assets/index-Old.js'; + +function pageWith(bundle: string | null) { + document.head.querySelectorAll('script[type="module"]').forEach((script) => script.remove()); + if (!bundle) { + return; + } + const script = document.createElement('script'); + script.setAttribute('type', 'module'); + script.setAttribute('src', bundle); + document.head.appendChild(script); +} + +function renderNotice(reload: () => void) { + const router = createMemoryRouter( + [ + { + path: '/:page', + element: ( + <> + Second + + + ), + }, + ], + {initialEntries: ['/first']}, + ); + render(); + return router; +} + +// A check runs when the page comes back into view, a minute or more after the last one; the +// test moves the clock rather than waiting. +function comeBackIntoView() { + const now = Date.now; + Date.now = () => now() + 2 * 60 * 1000; + act(() => { + window.dispatchEvent(new Event('focus')); + }); + Date.now = now; +} + +beforeEach(() => { + pageWith('/assets/index-Old.js'); + globalThis.fetch = ((_input: string, _init?: RequestInit) => + Promise.resolve( + new Response(``, { + headers: {'content-type': 'text/html'}, + }), + )) as typeof fetch; +}); + +afterEach(() => { + cleanup(); + globalThis.fetch = realFetch; + pageWith(null); + delete window.personalAgentProxyDesktop; +}); + +// The desktop app's window, with an update downloaded. +function desktopWith(version: string | null, restart: () => void) { + window.personalAgentProxyDesktop = { + updateReady: () => Promise.resolve(version), + onUpdateReady: () => () => {}, + restart, + }; +} + +describe('ReleaseNotice', () => { + test('shows nothing while the server has the same release', async () => { + serverBundle = '/assets/index-Old.js'; + renderNotice(() => {}); + comeBackIntoView(); + await act(async () => {}); + expect(screen.queryByRole('status')).toBeNull(); + }); + + test('offers a reload once the server has a new release', async () => { + serverBundle = '/assets/index-New.js'; + let reloads = 0; + renderNotice(() => reloads++); + comeBackIntoView(); + + await waitFor(() => expect(screen.getByRole('status')).toBeTruthy()); + fireEvent.click(screen.getByRole('button', {name: 'Reload'})); + expect(reloads).toBe(1); + }); + + test('offers a restart in the desktop app with an update downloaded', async () => { + serverBundle = '/assets/index-Old.js'; + let restarts = 0; + desktopWith('0.0.2', () => restarts++); + renderNotice(() => {}); + + await waitFor(() => expect(screen.getByRole('status')).toBeTruthy()); + fireEvent.click(screen.getByRole('button', {name: 'Restart'})); + expect(restarts).toBe(1); + }); + + test('offers the restart over a reload, which the restart includes', async () => { + serverBundle = '/assets/index-New.js'; + desktopWith('0.0.2', () => {}); + renderNotice(() => {}); + comeBackIntoView(); + + await waitFor(() => expect(screen.getByRole('button', {name: 'Restart'})).toBeTruthy()); + expect(screen.queryByRole('button', {name: 'Reload'})).toBeNull(); + }); + + test('shows nothing in the desktop app without an update', async () => { + serverBundle = '/assets/index-Old.js'; + desktopWith(null, () => {}); + renderNotice(() => {}); + await act(async () => {}); + expect(screen.queryByRole('status')).toBeNull(); + }); + + test('dismissed, goes, and still reloads at the next page', async () => { + serverBundle = '/assets/index-New.js'; + let reloads = 0; + renderNotice(() => reloads++); + comeBackIntoView(); + await waitFor(() => expect(screen.getByRole('status')).toBeTruthy()); + + fireEvent.click(screen.getByRole('button', {name: 'Dismiss'})); + expect(screen.queryByRole('status')).toBeNull(); + expect(reloads).toBe(0); + + fireEvent.click(screen.getByRole('link', {name: 'Second'})); + await waitFor(() => expect(reloads).toBe(1)); + }); + + test('dismissed in the desktop app, goes without restarting', async () => { + serverBundle = '/assets/index-Old.js'; + let restarts = 0; + desktopWith('0.0.2', () => restarts++); + renderNotice(() => {}); + await waitFor(() => expect(screen.getByRole('status')).toBeTruthy()); + + fireEvent.click(screen.getByRole('button', {name: 'Dismiss'})); + expect(screen.queryByRole('status')).toBeNull(); + expect(restarts).toBe(0); + }); + + test('reloads at the next page instead, when left alone', async () => { + serverBundle = '/assets/index-New.js'; + let reloads = 0; + renderNotice(() => reloads++); + comeBackIntoView(); + await waitFor(() => expect(screen.getByRole('status')).toBeTruthy()); + expect(reloads).toBe(0); + + fireEvent.click(screen.getByRole('link', {name: 'Second'})); + await waitFor(() => expect(reloads).toBe(1)); + }); +}); diff --git a/packages/web/src/components/release-notice.tsx b/packages/web/src/components/release-notice.tsx new file mode 100644 index 00000000..51cb6157 --- /dev/null +++ b/packages/web/src/components/release-notice.tsx @@ -0,0 +1,93 @@ +import {XIcon} from 'lucide-react'; +import {useEffect, useRef, useState} from 'react'; +import {useLocation} from 'react-router'; +import {Button} from '@proxy/ui/components/button'; +import {useDesktopUpdate} from '@/hooks/use-desktop-update'; +import {useNewRelease} from '@/hooks/use-new-release'; +import {desktopBridge} from '@/lib/desktop'; + +function reloadPage(): void { + window.location.reload(); +} + +function restartDesktop(): void { + desktopBridge()?.restart(); +} + +// What the button does: restart the desktop app, or reload the page. +type Action = 'restart' | 'reload'; + +function Notice({ + action, + onAction, + onDismiss, +}: { + action: string; + onAction: () => void; + onDismiss: () => void; +}) { + return ( +
+ A new version of Personal Agent Proxy is ready. + + +
+ ); +} + +// A new version, once there is one: a notice in the corner with a button. In the desktop app +// with an update downloaded, the button restarts the app into it. Otherwise, with a new release +// of the web app on the server, the button reloads the page, and, left alone, the next move to +// another page reloads instead, which the new release then shows, so nothing in the middle of +// being typed is lost to it. Dismissed, the notice goes and nothing else changes: the page still +// reloads at the next move, and the app still updates when it next quits. +export function ReleaseNotice({reload = reloadPage}: {reload?: () => void}) { + const update = useDesktopUpdate(); + const newRelease = useNewRelease(); + const {pathname} = useLocation(); + const pathAtNotice = useRef(null); + const [dismissed, setDismissed] = useState(null); + + useEffect(() => { + if (!newRelease) { + return; + } + if (pathAtNotice.current === null) { + pathAtNotice.current = pathname; + return; + } + if (pathname !== pathAtNotice.current) { + reload(); + } + }, [newRelease, pathname, reload]); + + const action: Action | null = update ? 'restart' : newRelease ? 'reload' : null; + if (!action || dismissed === action) { + return null; + } + + if (action === 'restart') { + return ( + setDismissed('restart')} + /> + ); + } + + return setDismissed('reload')} />; +} diff --git a/packages/web/src/components/root.tsx b/packages/web/src/components/root.tsx new file mode 100644 index 00000000..1272abbe --- /dev/null +++ b/packages/web/src/components/root.tsx @@ -0,0 +1,12 @@ +import {Outlet} from 'react-router'; +import {ReleaseNotice} from '@/components/release-notice'; + +// Around every page, the human side's and the agents' alike: the page, and word of a new release. +export function Root() { + return ( + <> + + + + ); +} diff --git a/packages/web/src/hooks/use-desktop-update.ts b/packages/web/src/hooks/use-desktop-update.ts new file mode 100644 index 00000000..f8b5523a --- /dev/null +++ b/packages/web/src/hooks/use-desktop-update.ts @@ -0,0 +1,33 @@ +import {useEffect, useState} from 'react'; +import {desktopBridge} from '@/lib/desktop'; + +// The version of the desktop app's downloaded update, once it has one; null in a browser, and in +// the app until then. Asked on mount, for a page loaded after the download, and told after. +export function useDesktopUpdate(): string | null { + const [version, setVersion] = useState(null); + + useEffect(() => { + const bridge = desktopBridge(); + if (!bridge) { + return; + } + + let cancelled = false; + void bridge.updateReady().then( + (ready) => { + if (!cancelled && ready) { + setVersion(ready); + } + }, + () => undefined, + ); + const stop = bridge.onUpdateReady(setVersion); + + return () => { + cancelled = true; + stop(); + }; + }, []); + + return version; +} diff --git a/packages/web/src/hooks/use-new-release.ts b/packages/web/src/hooks/use-new-release.ts new file mode 100644 index 00000000..4c5b1b32 --- /dev/null +++ b/packages/web/src/hooks/use-new-release.ts @@ -0,0 +1,52 @@ +import {useEffect, useState} from 'react'; +import {hasNewRelease} from '@/lib/release'; + +// Whether the server has a newer release of the web app than the page: asked every so often, +// and when the page comes back into view, if that has not just been asked. Once true, stays so. + +const CHECK_INTERVAL_MS = 10 * 60 * 1000; +const VISIBLE_CHECK_GAP_MS = 60 * 1000; + +export function useNewRelease(): boolean { + const [newRelease, setNewRelease] = useState(false); + + useEffect(() => { + if (newRelease) { + return; + } + + let cancelled = false; + let lastCheck = Date.now(); + + const check = async () => { + lastCheck = Date.now(); + const found = await hasNewRelease(document); + if (!cancelled && found) { + setNewRelease(true); + } + }; + + const onVisible = () => { + if (document.visibilityState !== 'visible') { + return; + } + if (Date.now() - lastCheck < VISIBLE_CHECK_GAP_MS) { + return; + } + void check(); + }; + + const timer = setInterval(() => void check(), CHECK_INTERVAL_MS); + document.addEventListener('visibilitychange', onVisible); + window.addEventListener('focus', onVisible); + + return () => { + cancelled = true; + clearInterval(timer); + document.removeEventListener('visibilitychange', onVisible); + window.removeEventListener('focus', onVisible); + }; + }, [newRelease]); + + return newRelease; +} diff --git a/packages/web/src/lib/desktop.ts b/packages/web/src/lib/desktop.ts index 570cb342..a556f6b6 100644 --- a/packages/web/src/lib/desktop.ts +++ b/packages/web/src/lib/desktop.ts @@ -15,6 +15,28 @@ export function isDesktopApp(): boolean { return navigator.userAgent.includes(DESKTOP_USER_AGENT); } +/** + * What the desktop app's window gives its pages (`packages/desktop/src/preload.ts`): the version + * of an update it has downloaded, if any, word when it downloads one, and the restart that installs + * it. + */ +export type DesktopBridge = { + updateReady(): Promise; + onUpdateReady(listener: (version: string) => void): () => void; + restart(): void; +}; + +declare global { + interface Window { + personalAgentProxyDesktop?: DesktopBridge; + } +} + +/** The desktop app's bridge; null in a browser. */ +export function desktopBridge(): DesktopBridge | null { + return window.personalAgentProxyDesktop ?? null; +} + /** The link that hands the desktop app the one-time code it trades for a session. */ export function desktopAuthLink(code: string): string { return `${DESKTOP_SCHEME}://auth-callback?code=${encodeURIComponent(code)}`; diff --git a/packages/web/src/lib/release.test.ts b/packages/web/src/lib/release.test.ts new file mode 100644 index 00000000..4cc968c2 --- /dev/null +++ b/packages/web/src/lib/release.test.ts @@ -0,0 +1,77 @@ +/// + +import {describe, expect, test} from 'bun:test'; +import {bundleOf, currentBundle, hasNewRelease, type FetchPage} from '@/lib/release'; + +const BUILT = ` + + + +
`; + +function page(html: string, options: {status?: number; type?: string} = {}): FetchPage { + return () => + Promise.resolve( + new Response(html, { + status: options.status ?? 200, + headers: {'content-type': options.type ?? 'text/html; charset=utf-8'}, + }), + ); +} + +function documentWith(bundle: string | null): Document { + const doc = document.implementation.createHTMLDocument(); + if (bundle) { + const script = doc.createElement('script'); + script.setAttribute('type', 'module'); + script.setAttribute('src', bundle); + doc.head.appendChild(script); + } + return doc; +} + +describe('bundleOf', () => { + test('names the module script of a build, past the inline one', () => { + expect(bundleOf(BUILT)).toBe('/assets/index-C3kN2xYz.js'); + }); + + test('names the development entry', () => { + expect(bundleOf('')).toBe('/src/main.tsx'); + }); + + test('is null for a page without a module script', () => { + expect(bundleOf('Service unavailable')).toBeNull(); + expect(bundleOf('')).toBeNull(); + }); +}); + +describe('currentBundle', () => { + test('reads the page its own module script', () => { + expect(currentBundle(documentWith('/assets/index-A.js'))).toBe('/assets/index-A.js'); + expect(currentBundle(documentWith(null))).toBeNull(); + }); +}); + +describe('hasNewRelease', () => { + test('is true when the server names another bundle', async () => { + expect(await hasNewRelease(documentWith('/assets/index-Old.js'), page(BUILT))).toBe(true); + }); + + test('is false when the server names the same bundle', async () => { + expect(await hasNewRelease(documentWith('/assets/index-C3kN2xYz.js'), page(BUILT))).toBe(false); + }); + + test('is false on an error page, a non-page or a failed request', async () => { + const doc = documentWith('/assets/index-Old.js'); + expect(await hasNewRelease(doc, page('Bad gateway', {status: 502}))).toBe(false); + expect(await hasNewRelease(doc, page('{"ok":true}', {type: 'application/json'}))).toBe(false); + expect(await hasNewRelease(doc, page('Maintenance'))).toBe(false); + expect(await hasNewRelease(doc, () => Promise.reject(new TypeError('Failed to fetch')))).toBe( + false, + ); + }); + + test('is false for a page without a bundle of its own', async () => { + expect(await hasNewRelease(documentWith(null), page(BUILT))).toBe(false); + }); +}); diff --git a/packages/web/src/lib/release.ts b/packages/web/src/lib/release.ts new file mode 100644 index 00000000..10b38ea6 --- /dev/null +++ b/packages/web/src/lib/release.ts @@ -0,0 +1,75 @@ +import {Err, Ok, type Result} from 'ts-results-es'; + +// Which release of the web app a page is: the module script index.html loads. A build's is +// named by its content by Vite (/assets/index-.js), so a page whose script is not the one +// the server's index.html names now is an older release. In development it is /src/main.tsx, +// which never changes. + +const SCRIPT_TAG = /]*>/gi; +const TYPE_MODULE = /\btype\s*=\s*["']?module["']?/i; +const SRC = /\bsrc\s*=\s*["']([^"']+)["']/i; + +// The module script's `src` in a page's HTML; null without one. +export function bundleOf(html: string): string | null { + for (const [tag] of html.matchAll(SCRIPT_TAG)) { + if (!TYPE_MODULE.test(tag)) { + continue; + } + + const src = SRC.exec(tag)?.[1]; + if (src) { + return src; + } + } + + return null; +} + +// The bundle the page in front of the person was loaded with. +export function currentBundle(doc: Document): string | null { + return doc.querySelector('script[type="module"][src]')?.getAttribute('src') ?? null; +} + +// A `fetch`: the browser's, or a test's. +export type FetchPage = (input: string, init?: RequestInit) => Promise; + +// The bundle the server names now, from its index.html, never from a cache. Anything but a page +// of the app, as a proxy's error page would be, is an error and no verdict. +export async function fetchServerBundle( + fetchFn: FetchPage = fetch, +): Promise> { + const response = await fetchFn('/', { + cache: 'no-store', + headers: {Accept: 'text/html'}, + }).catch((error: unknown) => (error instanceof Error ? error : new Error(String(error)))); + if (response instanceof Error) { + return Err(response); + } + + if (!response.ok || !response.headers.get('content-type')?.includes('text/html')) { + return Err(new Error(`Unexpected response for index.html: ${response.status}`)); + } + + const bundle = bundleOf(await response.text()); + if (!bundle) { + return Err(new Error('No module script in index.html')); + } + + return Ok(bundle); +} + +// Whether the server has a newer release than the page: an error, or a page without a bundle of +// its own, is no. +export async function hasNewRelease(doc: Document, fetchFn: FetchPage = fetch): Promise { + const current = currentBundle(doc); + if (!current) { + return false; + } + + const server = await fetchServerBundle(fetchFn); + if (server.isErr()) { + return false; + } + + return server.value !== current; +} diff --git a/packages/web/src/router.tsx b/packages/web/src/router.tsx index 1b05d4af..7cc8b5f2 100644 --- a/packages/web/src/router.tsx +++ b/packages/web/src/router.tsx @@ -1,6 +1,12 @@ import type {SignInMethods} from '@proxy/contracts/auth'; import type {Org} from '@proxy/contracts/orgs'; -import {Outlet, createBrowserRouter, redirect, redirectDocument} from 'react-router'; +import { + Outlet, + createBrowserRouter, + redirect, + redirectDocument, + type RouteObject, +} from 'react-router'; import { agentCollectionLoader, agentRecordLoader, @@ -14,6 +20,7 @@ import {getMe} from '@/client/me-client'; import {isNotFoundError} from '@/client/request'; import {getOrg, listOrgs} from '@/client/orgs-client'; import {HumanSide} from '@/components/human-side'; +import {Root} from '@/components/root'; import {RouteError} from '@/components/route-error'; import {sanitizeCallbackUrl} from '@/lib/callback-url'; import {unwrapLoaderResult} from '@/lib/loader-utils'; @@ -185,7 +192,7 @@ async function loginLoader({request}: {request: Request}): Promise}, {path: '/login', loader: loginLoader, element: }, {path: '/desktop-auth', loader: desktopAuthLoader, element: }, @@ -339,4 +346,6 @@ export const router = createBrowserRouter([ ], }, {path: '*', loader: unknownLoader, element: null, errorElement: }, -]); +]; + +export const router = createBrowserRouter([{element: , children: routes}]);