Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions packages/desktop/build.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
// 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';

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',
Expand Down
6 changes: 6 additions & 0 deletions packages/desktop/src/channels.ts
Original file line number Diff line number Diff line change
@@ -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';
15 changes: 12 additions & 3 deletions packages/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -95,7 +104,7 @@ function start(): void {
}
});
app.on('activate', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
if (currentWindow()) {
return;
}
loadApp(window());
Expand All @@ -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);
Expand Down
17 changes: 17 additions & 0 deletions packages/desktop/src/preload.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> => 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),
});
54 changes: 37 additions & 17 deletions packages/desktop/src/updates.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
2 changes: 2 additions & 0 deletions packages/desktop/src/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
165 changes: 165 additions & 0 deletions packages/web/src/components/release-notice.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/// <reference types="bun" />

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: (
<>
<Link to="/second">Second</Link>
<ReleaseNotice reload={reload} />
</>
),
},
],
{initialEntries: ['/first']},
);
render(<RouterProvider router={router} />);
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(`<script type="module" src="${serverBundle}"></script>`, {
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));
});
});
Loading
Loading