From 9c5277040623489ec70ef85cf2e9828972418e8a Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Fri, 9 Oct 2026 20:00:05 +0200 Subject: [PATCH 01/79] feat(desktop): a frame may load the preview's origin, the window not --- apps/desktop/src/main/window.test.ts | 44 ++++++++++++++++ apps/desktop/src/main/window.ts | 78 ++++++++++++++++++++++++++-- 2 files changed, 118 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/window.test.ts b/apps/desktop/src/main/window.test.ts index b45c5fad7..42c6aaac3 100644 --- a/apps/desktop/src/main/window.test.ts +++ b/apps/desktop/src/main/window.test.ts @@ -21,10 +21,12 @@ import { WEB_PREFERENCES, clampWindowState, crashRenderScript, + decideFrameNavigation, decideNavigation, isPermissionAllowed, jsonForScript, originOf, + previewOriginOf, parseCrashAction, type DisplayArea, type WindowState, @@ -202,6 +204,48 @@ describe('decideNavigation', () => { }); }); +describe('decideFrameNavigation (a frame that is not the main one)', () => { + const PREVIEW = previewOriginOf(`${APP_ORIGIN}/design`); + + it('names the preview by the app\'s own port under localhost', () => { + expect(PREVIEW).toBe(`http://localhost:${new URL(APP_ORIGIN).port}`); + expect(previewOriginOf('https://127.0.0.1:4700/')).toBeNull(); + expect(previewOriginOf('http://192.168.1.4:4700/')).toBeNull(); + expect(previewOriginOf('http://127.0.0.1/')).toBeNull(); + expect(previewOriginOf('not a url')).toBeNull(); + }); + + it.each([ + ['the preview\'s sign-in', (p: string) => `${p}/designer-preview/enter?ticket=abc`, 'allow'], + ['the page the sign-in redirects to', (p: string) => `${p}/a/repairs/`, 'allow'], + ['a hosted app framed on the app\'s own origin', () => `${APP_ORIGIN}/apps/repairs/staff/`, 'allow'], + ['another port on localhost', () => 'http://localhost:1/', 'deny'], + ['localhost over https', (p: string) => p.replace('http:', 'https:') + '/', 'deny'], + ['a look-alike host', (p: string) => p.replace('localhost', 'localhost.evil.com') + '/', 'deny'], + ['the web', () => 'https://example.com/', 'deny'], + ['a blob minted on the preview', (p: string) => `blob:${p}/abc-123`, 'deny'], + ['a data: document', () => 'data:text/html,x', 'deny'], + ['javascript:', () => 'javascript:alert(1)', 'deny'], + ['an unparseable target', () => 'http://', 'deny'], + ])('%s', (_label, make, expected) => { + expect(decideFrameNavigation(make(PREVIEW as string), APP_ORIGIN, PREVIEW)).toBe(expected); + }); + + it('knows no preview outside a project being built', () => { + expect(decideFrameNavigation(`${PREVIEW as string}/a/repairs/`, APP_ORIGIN, null)).toBe('deny'); + expect(decideFrameNavigation(`${APP_ORIGIN}/apps/repairs/staff/`, APP_ORIGIN, null)).toBe('allow'); + }); + + it('allows nothing before the handshake', () => { + expect(decideFrameNavigation(`${APP_ORIGIN}/`, null, null)).toBe('deny'); + }); + + it('leaves the main frame\'s rule alone: the preview\'s name is not the app', () => { + // A frame may be on localhost; the window may not (the preload would load there). + expect(decideNavigation(`${PREVIEW as string}/takeover`, APP_ORIGIN).action).toBe('deny'); + }); +}); + describe('originOf', () => { it('reduces the boot URL to the origin nav is locked to', () => { // The URL index.ts builds carries the boot token; the origin diff --git a/apps/desktop/src/main/window.ts b/apps/desktop/src/main/window.ts index 3a95d077f..e7a57ba99 100644 --- a/apps/desktop/src/main/window.ts +++ b/apps/desktop/src/main/window.ts @@ -163,6 +163,53 @@ export function decideNavigation(target: string, appOrigin: string | null): Navi return { action: 'deny', reason: `blocked ${url.protocol} navigation to ${url.host || target}` }; } +/** + * The rule for a frame that is NOT the main one (`will-frame-navigate`, and + * `will-redirect` on a subframe): it may load the app's own origin (a hosted + * app's screens are framed there) or the Designer's preview origin, and nothing + * else. Nothing is handed to the system browser from here: a frame that wants + * to leave simply does not. + * + * WHY A SECOND RULE AND NOT A WIDER FIRST ONE. The Designer's preview is a frame + * on `http://localhost:` that signs in with a 303 on that name. Tried on a + * packed build (66-T12, B5): `will-redirect` fires for subframes too, so the + * main frame's rule cancelled that redirect and the preview stayed blank. + * Adding `localhost` to the main frame's rule instead would let a page written + * by a model take the whole window to `localhost` (with a click's activation + * Chromium lets a frame set `top.location`), where the preload loads and the + * bridge is live. So the main frame keeps {@link decideNavigation} unchanged, + * and only a subframe may be on the preview's name. + * + * @param previewOrigin `http://localhost:` while a project is being + * built, else `null`. + */ +export function decideFrameNavigation( + target: string, + appOrigin: string | null, + previewOrigin: string | null, +): 'allow' | 'deny' { + const url = safeParse(target); + if (url === null) return 'deny'; + for (const allowed of [appOrigin, previewOrigin]) { + const origin = allowed === null ? null : safeParse(allowed); + // The scheme is matched for the reason given on decideNavigation: `blob:` + // inherits the origin of the URL inside it. + if (origin !== null && url.protocol === origin.protocol && url.origin === origin.origin) return 'allow'; + } + return 'deny'; +} + +/** + * The preview's origin for an app URL: the same port under the name + * `localhost`, which is what design mode serves the preview on. `null` for + * anything that is not `http://127.0.0.1:`. + */ +export function previewOriginOf(appUrl: string): string | null { + const parsed = safeParse(appUrl); + if (parsed === null || parsed.protocol !== 'http:' || parsed.hostname !== '127.0.0.1' || parsed.port === '') return null; + return `http://localhost:${parsed.port}`; +} + /** `new URL`, as a value rather than an exception. */ function safeParse(url: string): URL | null { try { @@ -422,8 +469,12 @@ export function crashRenderScript(info: CrashScreenInfo): string { export interface DesktopWindows { /** The bundled splash. Creates the window if it does not exist. */ showBoot(): Promise; - /** Navigate to the loopback app URL. */ - loadApp(url: string): Promise; + /** + * Navigate to the loopback app URL. `preview: true` while a project is being + * built: a subframe may then also load the Designer's preview origin + * ({@link decideFrameNavigation}). + */ + loadApp(url: string, opts?: { readonly preview?: boolean }): Promise; /** The bundled crash page. */ showCrash(info: CrashScreenInfo): Promise; /** A second launch focuses the existing window. */ @@ -504,6 +555,9 @@ export function createWindowManager(opts: CreateWindowManagerOptions): DesktopWi let win: BrowserWindow | null = null; /** The loopback origin nav is locked to; `null` whenever no server page is up. */ let appOrigin: string | null = null; + /** The Designer's preview origin a subframe may load; `null` outside a project being built. */ + let previewOrigin: string | null = null; + let lastAppPreview = false; /** * Whether the crash page is the document currently loaded — the gate on * treating a `?action=` navigation as a button press. @@ -619,8 +673,19 @@ export function createWindowManager(opts: CreateWindowManagerOptions): DesktopWi // A redirect can cross origins after `will-navigate` already allowed the // request, so the decision is re-made on the redirect itself. + // It fires for subframes as well, which have their own rule. created.webContents.on('will-redirect', (event, target) => { - if (decideNavigation(target, appOrigin).action !== 'allow') event.preventDefault(); + const allowed = event.isMainFrame + ? decideNavigation(target, appOrigin).action === 'allow' + : decideFrameNavigation(target, appOrigin, previewOrigin) === 'allow'; + if (!allowed) event.preventDefault(); + }); + + // `will-navigate` is the main frame's only. This one fires for every frame, + // a frame's first load included; the main frame is left to the handler above. + created.webContents.on('will-frame-navigate', (event) => { + if (event.isMainFrame) return; + if (decideFrameNavigation(event.url, appOrigin, previewOrigin) !== 'allow') event.preventDefault(); }); // ── permission handlers ── @@ -689,13 +754,16 @@ export function createWindowManager(opts: CreateWindowManagerOptions): DesktopWi // handshake lands. Clearing it means a server that died and is restarting // cannot leave a dead origin allowed. appOrigin = null; + previewOrigin = null; showingCrash = false; await target.loadFile(bootPage); }, - async loadApp(url: string): Promise { + async loadApp(url: string, opts?: { readonly preview?: boolean }): Promise { const target = await create(); appOrigin = originOf(url); + lastAppPreview = opts?.preview === true; + previewOrigin = lastAppPreview ? previewOriginOf(url) : null; showingCrash = false; lastAppUrl = url; await target.loadURL(url); @@ -704,6 +772,7 @@ export function createWindowManager(opts: CreateWindowManagerOptions): DesktopWi async showCrash(info: CrashScreenInfo): Promise { const target = await create(); appOrigin = null; + previewOrigin = null; showingCrash = true; await target.loadFile(crashPage); await target.webContents.executeJavaScript(crashRenderScript(info)); @@ -722,6 +791,7 @@ export function createWindowManager(opts: CreateWindowManagerOptions): DesktopWi return; } appOrigin = originOf(lastAppUrl); + previewOrigin = lastAppPreview ? previewOriginOf(lastAppUrl) : null; await target.loadURL(lastAppUrl); }, From ce553b1f9c4791ac4d6e23b9cea6a15b7f5a5ac6 Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Fri, 9 Oct 2026 20:04:45 +0200 Subject: [PATCH 02/79] feat(server): startProject, a start that hands back close and busy --- apps/server/src/cli/commands/design.ts | 33 ++---- apps/server/src/cli/commands/start.ts | 153 +++++++++++++++++++----- apps/server/src/cli/relocation-host.ts | 9 ++ apps/server/src/cli/start-project.ts | 65 +++++++++++ apps/server/src/compose.ts | 2 + apps/server/src/index.ts | 3 + apps/server/src/project/running.ts | 100 ++++++++++++++++ apps/server/test/start-project.test.ts | 156 +++++++++++++++++++++++++ 8 files changed, 469 insertions(+), 52 deletions(-) create mode 100644 apps/server/src/cli/start-project.ts create mode 100644 apps/server/src/project/running.ts create mode 100644 apps/server/test/start-project.test.ts diff --git a/apps/server/src/cli/commands/design.ts b/apps/server/src/cli/commands/design.ts index 7c3308e9a..90be126db 100644 --- a/apps/server/src/cli/commands/design.ts +++ b/apps/server/src/cli/commands/design.ts @@ -21,7 +21,6 @@ import { existsSync, mkdirSync } from 'node:fs'; import { createServer } from 'node:net'; import { dirname, resolve } from 'node:path'; -import { createLocalOwner, isBootstrapRequired, settingsRepo, usersRepo } from '@adminium/meta'; import Database from 'better-sqlite3'; import { runChild } from '../../designer/child.js'; @@ -34,7 +33,7 @@ import { boolFlag, numberFlag, parseFlags, stringFlag } from '../args.js'; import type { Command } from '../command.js'; import { CliUsageError, EXIT_OK } from '../exit.js'; import { newCommand } from './new.js'; -import { runStart } from './start.js'; +import { localOwnerStart, runStart } from './start.js'; /** * What an app's own screens are built with, added to a project the Designer @@ -123,27 +122,15 @@ export const designCommand: Command = { return runStart( { io, deps: { ...deps, cwd }, argv: ['--port', String(port), '--log-level', logLevel] }, - { - async prepare(meta) { - if (await isBootstrapRequired(meta)) { - await createLocalOwner(meta); - io.out('Made you the owner of this project, with no password yet (`adminium owner set` gives you one).'); - } - const ownerId = await settingsRepo(meta).get('designer.localOwnerId'); - const owner = ownerId === null ? null : await usersRepo(meta).findById(ownerId); - // Only the owner `design` made, and only while they have no password, is signed in by the link. - return { token: owner !== null && owner.passwordHash === null && owner.status === 'active' ? token : null }; - }, - async started(_url, listening, minted) { - const page = `http://127.0.0.1:${String(listening)}/design`; - const link = minted === null ? page : `${page}#designToken=${minted}`; - io.out(`Adminium Designer is running at ${page}`); - if (minted === null) io.out('Sign in with your account.'); - const opened = boolFlag(values['no-open']) ? false : await deps.openBrowser(link); - if (!opened) io.out(minted === null ? `Open ${page}` : `Open this link once to sign in: ${link}`); - io.out('Ctrl-C stops it.'); - }, - }, + localOwnerStart(io, token, async (_url, listening, minted) => { + const page = `http://127.0.0.1:${String(listening)}/design`; + const link = minted === null ? page : `${page}#designToken=${minted}`; + io.out(`Adminium Designer is running at ${page}`); + if (minted === null) io.out('Sign in with your account.'); + const opened = boolFlag(values['no-open']) ? false : await deps.openBrowser(link); + if (!opened) io.out(minted === null ? `Open ${page}` : `Open this link once to sign in: ${link}`); + io.out('Ctrl-C stops it.'); + }), ); }, }; diff --git a/apps/server/src/cli/commands/start.ts b/apps/server/src/cli/commands/start.ts index df7d7e1a8..13b5d5dd9 100644 --- a/apps/server/src/cli/commands/start.ts +++ b/apps/server/src/cli/commands/start.ts @@ -32,7 +32,7 @@ * no way to boot the old image at all (not even to export before restoring). */ -import { firstRun, settingsRepo, usersRepo } from '@adminium/meta'; +import { createLocalOwner, firstRun, isBootstrapRequired, settingsRepo, usersRepo } from '@adminium/meta'; import { describePreMigration, @@ -45,6 +45,8 @@ import { seedSourceConnection } from '../../connections/seed.js'; import { storageCryptoFromSecret } from '../../files/crypto.js'; import { embeddedMetaWarning } from '../../meta/store.js'; import { prepareProject } from '../../project/boot.js'; +import { findProject } from '../../project/locate.js'; +import { markRunning, refuseIfRunning } from '../../project/running.js'; import { withDefaults } from '../../project/config.js'; import { syncProjectDatabases } from '../../project/databases.js'; import { diskFileStore } from '../../project/file-store.js'; @@ -53,6 +55,7 @@ import { createProjectService, type ProjectServerOptions } from '../../project/s import { APP_VERSION } from '../../version.js'; import { numberFlag, parseFlags, stringFlag } from '../args.js'; import type { Command, CommandContext } from '../command.js'; +import type { CliIo } from '../io.js'; import { CliError, EXIT_CONFIG, EXIT_OK, type ExitCode } from '../exit.js'; import { createRelocationHost } from '../relocation-host.js'; import { loadCliEnv } from '../runtime.js'; @@ -133,18 +136,108 @@ export interface DesignStart { * copy (as `adminium dev` does), and serves Adminium Designer. */ export async function runStart({ io, deps, argv }: CommandContext, design?: DesignStart): Promise { + const { values } = parseFlags(argv, startCommand.flags, startCommand.name); + const started = await startUp( + { io, deps }, + { + port: numberFlag(values.port, 'port', startCommand.name), + host: stringFlag(values.host), + dataDir: stringFlag(values['data-dir']), + metaUrl: stringFlag(values['meta-url']), + logLevel: stringFlag(values['log-level']), + staticRoot: stringFlag(values['static-root']), + skipMigrate: values['skip-migrate'] === true, + }, + design, + ); + if (design !== undefined) { + await design.started(started.url, started.port, started.token); + return EXIT_OK; + } + io.out(`Adminium is running at ${started.url}`); + + // The local bridge's consent token (`routes/bridge`). Printed HERE as well + // as in the wizard because the published Docker image's CMD is `start`, not + // `init` — a container started with ADMINIUM_BRIDGE_ORIGINS set would + // otherwise have a pairing code nothing on earth could tell you. + if (started.bridgePairingCode !== null) { + io.out(''); + io.out(`Pairing code: ${started.bridgePairingCode}`); + io.out('Enter it on the site to hand this instance a connection string.'); + } + + // The process now lives until a signal; `start` never "finishes". The exit + // code is only reached in tests, where startServer is a fake. + return EXIT_OK; +} + +/** What `adminium start`'s flags say, parsed. */ +export interface StartFlags { + port?: number | undefined; + host?: string | undefined; + dataDir?: string | undefined; + metaUrl?: string | undefined; + logLevel?: string | undefined; + staticRoot?: string | undefined; + skipMigrate?: boolean | undefined; +} + +/** + * Design mode's owner, as `adminium design` makes it: the first time, the + * project's owner with no password. `token` signs that owner in once, and only + * while they have no password; `null` mints no link at all. + */ +export function localOwnerStart(io: CliIo, token: string | null, started: DesignStart['started'] = async () => undefined): DesignStart { + return { + async prepare(meta) { + if (await isBootstrapRequired(meta)) { + await createLocalOwner(meta); + io.out('Made you the owner of this project, with no password yet (`adminium owner set` gives you one).'); + } + const ownerId = await settingsRepo(meta).get('designer.localOwnerId'); + const owner = ownerId === null ? null : await usersRepo(meta).findById(ownerId); + // Only the owner `design` made, and only while they have no password, is signed in by the link. + return { token: owner !== null && owner.passwordHash === null && owner.status === 'active' ? token : null }; + }, + started, + }; +} + +/** A server that was started: where it answers, and the ways to stop it and to ask what it is doing. */ +export interface StartedUp { + url: string; + port: number; + /** Design mode's one-use token, when one was minted. */ + token: string | null; + bridgePairingCode: string | null; + /** Ends the server, then the stores, and resolves when both are gone. */ + close(): Promise; + /** What has the project's folder right now (a turn, a save, a restore…), or `null`. */ + busy(): StartBusy | null; +} + +/** What {@link StartedUp.busy} answers: the Designer's own word for it. */ +export interface StartBusy { + kind: string; + sessionId: string | null; +} + +/** + * Everything `adminium start` does between reading its flags and waiting for a + * signal. `runStart` is its caller on a terminal; the desktop app's server + * child is its other caller (`startProject`), which needs the handle this + * returns: a terminal's process ends by a signal, the app's child is told to + * stop and must be able to say when it has. + */ +export async function startUp({ io, deps }: Pick, flags: StartFlags, design?: DesignStart): Promise { { - const { values } = parseFlags(argv, startCommand.flags, startCommand.name); - const port = numberFlag(values.port, 'port', startCommand.name); + const port = flags.port; // Design mode is on this machine only, whatever the environment or .env says. - const host = design !== undefined ? '127.0.0.1' : stringFlag(values.host); - if (design !== undefined && (stringFlag(values.host) ?? deps.env.HOST ?? '') !== '' && (stringFlag(values.host) ?? deps.env.HOST) !== '127.0.0.1') { + const host = design !== undefined ? '127.0.0.1' : flags.host; + if (design !== undefined && (flags.host ?? deps.env.HOST ?? '') !== '' && (flags.host ?? deps.env.HOST) !== '127.0.0.1') { io.err('Adminium Designer listens on this machine only (127.0.0.1): the HOST you set is not used.'); } - const dataDir = stringFlag(values['data-dir']); - const metaUrl = stringFlag(values['meta-url']); - const logLevel = stringFlag(values['log-level']); - const staticRoot = stringFlag(values['static-root']); + const { dataDir, metaUrl, logLevel, staticRoot } = flags; // Inside a project: `.env` and adminium.config.ts fill in whatever the // environment leaves unset, and the config's databases are connected below. @@ -156,6 +249,9 @@ export async function runStart({ io, deps, argv }: CommandContext, design?: Desi */ const projectMode: 'dev' | 'server' = design !== undefined || deps.env.ADMINIUM_PROJECT_MODE === 'dev' ? 'dev' : 'server'; // The apps' screens are built one way for a developer and another for a server; a build of the other kind is redone. + // Before the folder's code is built or run: a second server on one folder would write the same database. + const located = findProject(deps.cwd, deps.env); + if (located !== null) refuseIfRunning(located.root); const project = await prepareProject({ cwd: deps.cwd, env: deps.env, version: APP_VERSION, dev: projectMode === 'dev' }); if (project !== null) { const built = project.from === 'new-build' ? ' (built it first)' : project.appsRebuilt === true ? ` (built its apps' screens again, for ${projectMode === 'dev' ? 'development' : 'a server'})` : ''; @@ -193,7 +289,7 @@ export async function runStart({ io, deps, argv }: CommandContext, design?: Desi io.err(embeddedMetaWarning(runtime.metaStore.url)); } - if (values['skip-migrate'] !== true) { + if (flags.skipMigrate !== true) { const guard = await guardPreMigration({ meta: runtime.metaStore.meta, engine: runtime.metaStore.engine, @@ -355,24 +451,23 @@ export async function runStart({ io, deps, argv }: CommandContext, design?: Desi }, }); const server = await relocationHost.start(runtime); - if (design !== undefined && prepared !== null) { - await design.started(server.url, env.PORT, prepared.token); - return EXIT_OK; - } - io.out(`Adminium is running at ${server.url}`); - - // The local bridge's consent token (`routes/bridge`). Printed HERE as well - // as in the wizard because the published Docker image's CMD is `start`, not - // `init` — a container started with ADMINIUM_BRIDGE_ORIGINS set would - // otherwise have a pairing code nothing on earth could tell you. - if (server.bridgePairingCode !== null) { - io.out(''); - io.out(`Pairing code: ${server.bridgePairingCode}`); - io.out('Enter it on the site to hand this instance a connection string.'); - } - - // The process now lives until a signal; `start` never "finishes". The exit - // code is only reached in tests, where startServer is a fake. - return EXIT_OK; + const forget = project === null ? () => undefined : markRunning(project.project.root, { port: env.PORT, mode: design !== undefined ? 'design' : projectMode === 'dev' ? 'dev' : 'start', by: deps.env.ADMINIUM_RUNTIME === 'desktop' ? 'desktop' : 'cli' }); + return { + url: server.url, + port: env.PORT, + token: prepared?.token ?? null, + bridgePairingCode: server.bridgePairingCode, + async close() { + try { + await relocationHost.close(); + } finally { + forget(); + } + }, + busy() { + const live = relocationHost.current()?.app as unknown as { designerBusy?: () => StartBusy | null } | undefined; + return live?.designerBusy?.() ?? null; + }, + }; } } diff --git a/apps/server/src/cli/relocation-host.ts b/apps/server/src/cli/relocation-host.ts index d531afe5f..5a22b0075 100644 --- a/apps/server/src/cli/relocation-host.ts +++ b/apps/server/src/cli/relocation-host.ts @@ -48,6 +48,8 @@ export interface RelocationHost { onMetaRelocated: OnMetaRelocated; /** The live server, for callers that print its URL. */ current(): StartedServer | null; + /** End the live server, then its stores (after any restart that is under way). */ + close(): Promise; } export interface CreateRelocationHostOptions { @@ -149,5 +151,12 @@ export function createRelocationHost(opts: CreateRelocationHostOptions): Relocat start: boot, onMetaRelocated, current: () => server, + async close() { + await chain.catch(() => undefined); + await server?.close().catch(() => undefined); + await runtime?.close().catch(() => undefined); + server = null; + runtime = null; + }, }; } diff --git a/apps/server/src/cli/start-project.ts b/apps/server/src/cli/start-project.ts new file mode 100644 index 000000000..b4fa186f0 --- /dev/null +++ b/apps/server/src/cli/start-project.ts @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** + * `startProject` — serve one project folder from a host that is not a terminal. + * + * The desktop app's server child calls this. It runs the same path `adminium + * start` and `adminium design` run (`startUp`), not a copy of it, and hands + * back what a terminal never needed: a way to stop the server and know when it + * has stopped, and a way to ask what it is in the middle of. + */ +import { realpathSync } from 'node:fs'; + +import { findProject } from '../project/locate.js'; +import { localOwnerStart, type DesignStart, type StartedUp, startUp } from './commands/start.js'; +import { CliError, EXIT_CONFIG } from './exit.js'; +import type { CliIo } from './io.js'; +import { defaultCliDeps, type CliDeps } from './runtime.js'; + +export interface StartProjectOptions { + /** The project's folder. It must hold the project itself: a parent's project is never used. */ + root: string; + port: number; + /** + * `design`: Adminium Designer on this machine only, the folder as the master + * copy. `serve`: the project as `adminium start` serves it, no Designer. + */ + mode: 'design' | 'serve'; + /** `serve` only (design mode is 127.0.0.1 whatever is given). Default 127.0.0.1. */ + host?: string; + io: CliIo; + /** The environment the project's `.env` fills in. Default: this process's. */ + env?: Record; + logLevel?: string; + /** Design mode's owner and link. Default: make the project's owner when it has none; no link. */ + design?: DesignStart; + /** Test seam. */ + deps?: Partial; +} + +export type StartedProject = StartedUp; + +function real(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } +} + +export async function startProject(opts: StartProjectOptions): Promise { + const env = { ...(opts.env ?? process.env) }; + // The folder that was picked, never one named by the environment or found above it. + delete env.ADMINIUM_PROJECT_DIR; + // The mode is what this call says, never what was inherited. + delete env.ADMINIUM_PROJECT_MODE; + const located = findProject(opts.root, env); + if (located === null || real(located.root) !== real(opts.root)) { + throw new CliError(`${opts.root} is not a project folder (it has no adminium.config.ts).`, { code: EXIT_CONFIG }); + } + const deps: CliDeps = { ...defaultCliDeps(), ...opts.deps, env, cwd: located.root }; + return startUp( + { io: opts.io, deps }, + { port: opts.port, host: opts.mode === 'serve' ? (opts.host ?? '127.0.0.1') : undefined, logLevel: opts.logLevel ?? 'warn' }, + opts.mode === 'design' ? (opts.design ?? localOwnerStart(opts.io, null)) : undefined, + ); +} diff --git a/apps/server/src/compose.ts b/apps/server/src/compose.ts index cda6e1c81..406df0c17 100644 --- a/apps/server/src/compose.ts +++ b/apps/server/src/compose.ts @@ -2904,6 +2904,8 @@ export async function composeServer(opts: ComposeServerOptions): Promise { await publicStats.flush(); }); + // What has the project's folder, for whoever is about to stop this server (the desktop app asks before it does). + if (!app.hasDecorator('designerBusy')) app.decorate('designerBusy', () => designer?.runner.busy() ?? null); // A Designer turn that runs is stopped, and its end written, before the store closes. app.addHook('onClose', async () => { await designer?.shutdown(); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 7105ad8b7..6cefd6890 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -254,6 +254,9 @@ export { // The `adminium` CLI. `runCli` returns an exit code and never touches // `process` — `src/cli/index.ts` is the only module that exits. export { COMMANDS, findCommand, runCli, type RunCliOptions } from './cli/run.js'; +export { startProject, type StartProjectOptions, type StartedProject } from './cli/start-project.js'; +export type { StartBusy } from './cli/commands/start.js'; +export { readRunning, runningMessage, type RunningMark } from './project/running.js'; export { defaultCliDeps, displayUrl, diff --git a/apps/server/src/project/running.ts b/apps/server/src/project/running.ts new file mode 100644 index 000000000..ab4a1f8fb --- /dev/null +++ b/apps/server/src/project/running.ts @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** + * `.adminium/running.json` — who is serving this project right now. + * + * One folder, one server: two of them would write the same database files and + * the same `apps/` folder. The terminal's commands and the desktop app both + * read this file before they start and write it once they listen, so the + * second one says where the first is instead of starting. + * + * A file whose process is gone is simply replaced: a server that was killed + * never got to remove it. + */ +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { CliError, EXIT_CONFIG } from '../cli/exit.js'; + +export interface RunningMark { + pid: number; + port: number; + mode: 'design' | 'dev' | 'start'; + startedAt: string; + by: 'desktop' | 'cli'; +} + +export function runningFile(root: string): string { + return join(root, '.adminium', 'running.json'); +} + +/** Whether a process with that id exists. `EPERM` means it does, under another user. */ +function alive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +/** The mark of a server that is still there, or `null`. Never this process's own. */ +export function readRunning(root: string, isAlive: (pid: number) => boolean = alive): RunningMark | null { + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(runningFile(root), 'utf8')); + } catch { + return null; + } + if (typeof parsed !== 'object' || parsed === null) return null; + const mark = parsed as Partial; + if (typeof mark.pid !== 'number' || !Number.isInteger(mark.pid) || mark.pid <= 0 || typeof mark.port !== 'number') return null; + if (mark.pid === process.pid || !isAlive(mark.pid)) return null; + return { + pid: mark.pid, + port: mark.port, + mode: mark.mode === 'design' || mark.mode === 'dev' ? mark.mode : 'start', + startedAt: typeof mark.startedAt === 'string' ? mark.startedAt : '', + by: mark.by === 'desktop' ? 'desktop' : 'cli', + }; +} + +/** The words for a project that is already being served. */ +export function runningMessage(mark: RunningMark): string { + return `This project is already running (${mark.by === 'desktop' ? 'in the Adminium app' : 'in a terminal'}), on port ${String(mark.port)}.`; +} + +export function refuseIfRunning(root: string, isAlive?: (pid: number) => boolean): void { + const mark = readRunning(root, isAlive); + if (mark !== null) throw new CliError(runningMessage(mark), { code: EXIT_CONFIG, hint: 'Stop it there first, or use it where it is.' }); +} + +/** + * Write the mark for this process; the returned function removes it (only + * while it is still this process's). It is also removed when the process + * exits on its own, which is how a terminal's server ends. + */ +export function markRunning(root: string, what: Pick, now: () => Date = () => new Date()): () => void { + const file = runningFile(root); + const mark: RunningMark = { pid: process.pid, port: what.port, mode: what.mode, startedAt: now().toISOString(), by: what.by }; + try { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, `${JSON.stringify(mark, null, 2)}\n`); + } catch { + // A folder that cannot be written to still serves; the mark is a courtesy to a second start. + return () => undefined; + } + let done = false; + const forget = (): void => { + if (done) return; + done = true; + process.removeListener('exit', forget); + try { + const held = JSON.parse(readFileSync(file, 'utf8')) as Partial; + if (held.pid === process.pid) rmSync(file, { force: true }); + } catch { + // Already gone. + } + }; + process.once('exit', forget); + return forget; +} diff --git a/apps/server/test/start-project.test.ts b/apps/server/test/start-project.test.ts new file mode 100644 index 000000000..a21ac0217 --- /dev/null +++ b/apps/server/test/start-project.test.ts @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** + * `startProject`: the terminal's start path with a handle, as the desktop + * app's server child calls it. A real server on a real port each time: what + * is proved here is that `close()` leaves nothing behind and a second start + * on the same folder works. + */ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { request } from 'node:http'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { startProject, type StartedProject } from '../src/cli/start-project.js'; +import { markRunning, readRunning, refuseIfRunning, runningFile, runningMessage } from '../src/project/running.js'; +import { fakeIo, TEST_SECRET } from './cli-helpers.js'; + +const anyPort = async (): Promise => + new Promise((done) => { + const probe = createServer(); + probe.listen(0, '127.0.0.1', () => { + const { port } = probe.address() as { port: number }; + probe.close(() => done(port)); + }); + }); + +const listening = async (port: number): Promise => + new Promise((done) => { + const probe = createServer(); + probe.once('error', () => done(true)); + probe.listen(port, '127.0.0.1', () => probe.close(() => done(false))); + }); + +/** `fetch` will not send a Host of the caller's choosing; a plain request does. */ +const statusAs = async (port: number, host: string): Promise => + new Promise((done, fail) => { + request({ host: '127.0.0.1', port, path: '/api/v1/healthz', headers: { host } }, (res) => { + res.resume(); + done(res.statusCode ?? 0); + }) + .once('error', fail) + .end(); + }); + +let root: string; +let live: StartedProject | null = null; +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'adminium-start-project-'))); + writeFileSync(join(root, 'adminium.config.mjs'), 'export default {};\n'); + writeFileSync(join(root, 'package.json'), '{"name":"p","private":true,"type":"module"}\n'); + writeFileSync(join(root, '.env'), `ADMINIUM_SECRET=${TEST_SECRET}\n`); + mkdirSync(join(root, 'data')); +}); +afterEach(async () => { + await live?.close(); + live = null; + rmSync(root, { recursive: true, force: true }); +}); + +const env = (): Record => ({ PATH: process.env.PATH, HOME: process.env.HOME }); + +describe('startProject', () => { + it('serves the folder, says where, and close() leaves nothing: then it starts again', { timeout: 120_000 }, async () => { + const port = await anyPort(); + live = await startProject({ root, port, mode: 'serve', io: fakeIo(), env: env() }); + expect(live.url).toBe(`http://127.0.0.1:${String(port)}`); + expect(live.port).toBe(port); + expect((await fetch(`${live.url}/api/v1/healthz`)).status).toBe(200); + expect(live.busy()).toBeNull(); + // The data is the project's own, not the home folder's. + expect(existsSync(join(root, 'data'))).toBe(true); + + const mark = JSON.parse(readFileSync(runningFile(root), 'utf8')) as { pid: number; port: number; mode: string; by: string }; + expect(mark).toMatchObject({ pid: process.pid, port, mode: 'start', by: 'cli' }); + + await live.close(); + live = null; + expect(await listening(port)).toBe(false); + expect(existsSync(runningFile(root))).toBe(false); + + const again = await anyPort(); + live = await startProject({ root, port: again, mode: 'serve', io: fakeIo(), env: { ...env(), ADMINIUM_RUNTIME: 'desktop' } }); + expect((await fetch(`${live.url}/api/v1/healthz`)).status).toBe(200); + expect(JSON.parse(readFileSync(runningFile(root), 'utf8'))).toMatchObject({ by: 'desktop', port: again }); + }); + + it('design mode: this machine only, the owner made, the Designer answering, nothing busy', { timeout: 120_000 }, async () => { + const port = await anyPort(); + const io = fakeIo(); + live = await startProject({ root, port, mode: 'design', io, env: { ...env(), HOST: '0.0.0.0' } }); + expect(live.url).toBe(`http://127.0.0.1:${String(port)}`); + expect(live.token).toBeNull(); + expect(io.stdout()).toContain('Made you the owner of this project'); + expect(JSON.parse(readFileSync(runningFile(root), 'utf8'))).toMatchObject({ mode: 'design' }); + // The other name is the preview's; any third name is refused. + expect(await statusAs(port, `127.0.0.1:${String(port)}`)).toBe(200); + expect(await statusAs(port, `localhost:${String(port)}`)).toBe(200); + expect(await statusAs(port, `evil.test:${String(port)}`)).toBe(421); + expect(live.busy()).toBeNull(); + }); + + it('refuses a folder that is not the project itself, even inside one', async () => { + const inner = join(root, 'apps'); + mkdirSync(inner); + await expect(startProject({ root: inner, port: 1, mode: 'serve', io: fakeIo(), env: env() })).rejects.toThrow(/is not a project folder/); + // Nor a project named by the environment. + const elsewhere = mkdtempSync(join(tmpdir(), 'adminium-not-a-project-')); + try { + await expect(startProject({ root: elsewhere, port: 1, mode: 'serve', io: fakeIo(), env: { ...env(), ADMINIUM_PROJECT_DIR: root } })).rejects.toThrow(/is not a project folder/); + } finally { + rmSync(elsewhere, { recursive: true, force: true }); + } + }); + + it('does not start a project another process is serving', async () => { + mkdirSync(join(root, '.adminium'), { recursive: true }); + // This test runner's parent is certainly alive and is not this process. + writeFileSync(runningFile(root), JSON.stringify({ pid: process.ppid, port: 4711, mode: 'design', startedAt: 'x', by: 'desktop' })); + await expect(startProject({ root, port: await anyPort(), mode: 'serve', io: fakeIo(), env: env() })).rejects.toThrow('This project is already running (in the Adminium app), on port 4711.'); + }); +}); + +describe('.adminium/running.json', () => { + it('is written, honoured while its process lives, and replaced when it does not', () => { + const forget = markRunning(root, { port: 4700, mode: 'design', by: 'cli' }); + // This process's own mark never refuses this process. + expect(readRunning(root)).toBeNull(); + const mine = JSON.parse(readFileSync(runningFile(root), 'utf8')) as { pid: number }; + writeFileSync(runningFile(root), JSON.stringify({ ...mine, pid: 999_999 })); + + expect(readRunning(root, () => true)).toMatchObject({ pid: 999_999, port: 4700, mode: 'design', by: 'cli' }); + expect(() => refuseIfRunning(root, () => true)).toThrow('This project is already running (in a terminal), on port 4700.'); + expect(runningMessage({ pid: 1, port: 9, mode: 'start', startedAt: '', by: 'desktop' })).toContain('in the Adminium app'); + + // Its process is gone: the file means nothing, and the next start replaces it. + expect(readRunning(root, () => false)).toBeNull(); + expect(() => refuseIfRunning(root, () => false)).not.toThrow(); + + // Forgetting removes only this process's own mark. + forget(); + expect(existsSync(runningFile(root))).toBe(true); + const mineAgain = markRunning(root, { port: 4701, mode: 'start', by: 'desktop' }); + mineAgain(); + expect(existsSync(runningFile(root))).toBe(false); + }); + + it('reads nothing from a file that is not a mark', () => { + mkdirSync(join(root, '.adminium'), { recursive: true }); + for (const text of ['', 'not json', 'null', '{"pid":"1","port":2}', '{"pid":-4,"port":2}', '{"pid":12}']) { + writeFileSync(runningFile(root), text); + expect(readRunning(root, () => true)).toBeNull(); + } + }); +}); From 5d32fd50269e018cc5f210eebc15ec180bf2dd6e Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Fri, 9 Oct 2026 20:08:28 +0200 Subject: [PATCH 03/79] feat(server): a project the desktop app opens is served as on a terminal --- apps/server/src/compose.ts | 20 ++++++++++++++----- .../server/test/project-client-server.test.ts | 16 ++++++--------- apps/server/test/project-code-server.test.ts | 20 +++++++------------ apps/server/test/start-project.test.ts | 5 +++++ 4 files changed, 33 insertions(+), 28 deletions(-) diff --git a/apps/server/src/compose.ts b/apps/server/src/compose.ts index 406df0c17..462698aa0 100644 --- a/apps/server/src/compose.ts +++ b/apps/server/src/compose.ts @@ -574,7 +574,8 @@ export async function composeServer(opts: ComposeServerOptions): Promise { }); }); +// Plan 66 (P12): a project the desktop app opened is served as a terminal serves it. describe('on the desktop app', () => { - it('serves no project code, and adds no pages', async () => { + it('serves a project it opened: its pages and their code', async () => { const served = await serve({ env: { ADMINIUM_RUNTIME: 'desktop' } }); - expect(await pagesRepo(served.install.meta).findById(projectPageId('revenue'))).toBeNull(); + expect(await pagesRepo(served.install.meta).findById(projectPageId('revenue'))).not.toBeNull(); const { project } = await bootstrap(served); - expect(project).toEqual({ databases: { main: served.install.mainId }, client: null }); - const res = await served.app.inject({ - method: 'GET', - url: '/api/v1/project/client/pages/revenue-A1.js', - headers: { cookie: served.cookie }, - }); - expect(res.statusCode).toBe(404); + expect(project?.client).not.toBeNull(); + expect(project?.client).toBeDefined(); }); }); diff --git a/apps/server/test/project-code-server.test.ts b/apps/server/test/project-code-server.test.ts index 6bdeff3bf..9233a7ff5 100644 --- a/apps/server/test/project-code-server.test.ts +++ b/apps/server/test/project-code-server.test.ts @@ -3,7 +3,7 @@ * A project's hooks and actions inside a composed server: a hook's rejection * on a single edit, a bulk edit, a delete, the public API and a CSV import; * after hooks writing through `db`; actions with their permissions and audit; - * the Studio overview; a reload in dev; and no project code on the desktop. + * the Studio overview; a reload in dev; and the same for a project the desktop app opened. * * The bundles are written by hand, as `adminium build` would leave them, so * this needs no esbuild. @@ -616,18 +616,12 @@ describe('where project code runs', () => { expect(listed.json().data).toEqual([]); }); - it('never loads it on the desktop app', async () => { - const { install: one, app, cookie } = await serve({ env: { ADMINIUM_RUNTIME: 'desktop' } }); + // Plan 66 (P12): the desktop app opens a project folder and serves it as a terminal does. Its classic + // workspace passes no project at all, which is what keeps project code out of it. + it('loads it for a project the desktop app opened', async () => { + const { app, cookie } = await serve({ env: { ADMINIUM_RUNTIME: 'desktop' } }); const overview = await app.inject({ method: 'GET', url: '/api/v1/project/overview', headers: { cookie } }); - expect(overview.json().data).toMatchObject({ codeEnabled: false, hooks: [], actions: [], problems: [] }); - const listed = await app.inject({ method: 'GET', url: '/api/v1/project/actions', headers: { cookie } }); - expect(listed.json().data).toEqual([]); - const res = await app.inject({ - method: 'PATCH', - url: `/api/v1/data/${one.mainId}/main.orders/3`, - headers: { cookie }, - payload: { values: { status: 'paid' } }, - }); - expect(res.statusCode).toBe(200); + expect(overview.json().data).toMatchObject({ codeEnabled: true }); + expect((overview.json().data as { hooks: unknown[] }).hooks.length).toBeGreaterThan(0); }); }); diff --git a/apps/server/test/start-project.test.ts b/apps/server/test/start-project.test.ts index a21ac0217..a208c567f 100644 --- a/apps/server/test/start-project.test.ts +++ b/apps/server/test/start-project.test.ts @@ -84,6 +84,11 @@ describe('startProject', () => { live = await startProject({ root, port: again, mode: 'serve', io: fakeIo(), env: { ...env(), ADMINIUM_RUNTIME: 'desktop' } }); expect((await fetch(`${live.url}/api/v1/healthz`)).status).toBe(200); expect(JSON.parse(readFileSync(runningFile(root), 'utf8'))).toMatchObject({ by: 'desktop', port: again }); + // A project in the desktop app has none of the classic workspace's own routes: they act on another instance. + for (const path of ['/api/v1/desktop/lan-share', '/api/v1/desktop/local-database', '/api/v1/desktop/backup']) { + const res = await fetch(`${live.url}${path}`, { method: path.endsWith('lan-share') ? 'GET' : 'POST' }); + expect(res.status, path).toBe(404); + } }); it('design mode: this machine only, the owner made, the Designer answering, nothing busy', { timeout: 120_000 }, async () => { From 45812b3c5c974df7b085fab938fb2080df7fae99 Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Fri, 9 Oct 2026 20:09:41 +0200 Subject: [PATCH 04/79] feat(desktop): only the app's own server origin may call the bridge --- apps/desktop/src/main/index.ts | 1 + apps/desktop/src/main/ipc.test.ts | 32 ++++++++++++++++++++++++++++ apps/desktop/src/main/ipc.ts | 35 +++++++++++++++++++++++++++++-- 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index d8b1b36b8..14ca50490 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1938,6 +1938,7 @@ export function electronBootDeps(): DesktopBootDeps { registerBridge: (context) => { const handlers = registerIpcHandlers({ ipc: ipcMain, + pinSender: app.isPackaged, // 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 diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index 839e39623..0e64d1114 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -24,6 +24,7 @@ import { createDefaultConfig, type DesktopConfig } from './config.js'; import { isLoopbackHostname, loopbackSenderPolicy, + pinnedSenderPolicy, registerIpcHandlers, toErrorPayload, type DesktopDialogs, @@ -339,6 +340,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); diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 53a276ad1..e670b3669 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -460,8 +460,10 @@ export interface RegisterIpcHandlersOptions { showLogs: () => Promise; /** Relaunch the app (e.g. after a data-dir change). */ relaunch: () => void; - /** An override. Defaults to {@link loopbackSenderPolicy}. */ + /** 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 +515,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. @@ -593,7 +623,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 => {}); From 27826eabc7a1eddcd0959b5510740ca7632956a5 Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Fri, 9 Oct 2026 20:52:20 +0200 Subject: [PATCH 05/79] feat(server): a host may refuse names from a project's .env and config --- apps/server/src/cli/commands/start.ts | 10 ++++++- apps/server/src/cli/start-project.ts | 39 +++++++++++++++++++++++++- apps/server/src/index.ts | 2 +- apps/server/src/project/boot.ts | 23 +++++++++++++-- apps/server/src/project/dotenv.ts | 2 +- apps/server/test/start-project.test.ts | 26 ++++++++++++++++- 6 files changed, 94 insertions(+), 8 deletions(-) diff --git a/apps/server/src/cli/commands/start.ts b/apps/server/src/cli/commands/start.ts index 13b5d5dd9..d4f2aac21 100644 --- a/apps/server/src/cli/commands/start.ts +++ b/apps/server/src/cli/commands/start.ts @@ -180,6 +180,8 @@ export interface StartFlags { logLevel?: string | undefined; staticRoot?: string | undefined; skipMigrate?: boolean | undefined; + /** Names the project's `.env` and config may not set (`prepareProject`'s `refuse`). */ + refuse?: readonly string[] | undefined; } /** @@ -210,6 +212,8 @@ export interface StartedUp { /** Design mode's one-use token, when one was minted. */ token: string | null; bridgePairingCode: string | null; + /** Names from the project's `.env` or config that were ignored (`StartFlags.refuse`). */ + refused: string[]; /** Ends the server, then the stores, and resolves when both are gone. */ close(): Promise; /** What has the project's folder right now (a turn, a save, a restore…), or `null`. */ @@ -252,7 +256,10 @@ export async function startUp({ io, deps }: Pick, // Before the folder's code is built or run: a second server on one folder would write the same database. const located = findProject(deps.cwd, deps.env); if (located !== null) refuseIfRunning(located.root); - const project = await prepareProject({ cwd: deps.cwd, env: deps.env, version: APP_VERSION, dev: projectMode === 'dev' }); + const project = await prepareProject({ cwd: deps.cwd, env: deps.env, version: APP_VERSION, dev: projectMode === 'dev', ...(flags.refuse === undefined ? {} : { refuse: flags.refuse }) }); + if (project !== null && project.refused.length > 0) { + io.err(`Ignored from this project's .env and config (the app decides these itself): ${project.refused.join(', ')}`); + } if (project !== null) { const built = project.from === 'new-build' ? ' (built it first)' : project.appsRebuilt === true ? ` (built its apps' screens again, for ${projectMode === 'dev' ? 'development' : 'a server'})` : ''; io.out(`Project: ${project.project.root}${built}`); @@ -457,6 +464,7 @@ export async function startUp({ io, deps }: Pick, port: env.PORT, token: prepared?.token ?? null, bridgePairingCode: server.bridgePairingCode, + refused: project?.refused ?? [], async close() { try { await relocationHost.close(); diff --git a/apps/server/src/cli/start-project.ts b/apps/server/src/cli/start-project.ts index b4fa186f0..08c5bf485 100644 --- a/apps/server/src/cli/start-project.ts +++ b/apps/server/src/cli/start-project.ts @@ -32,12 +32,49 @@ export interface StartProjectOptions { logLevel?: string; /** Design mode's owner and link. Default: make the project's owner when it has none; no link. */ design?: DesignStart; + /** + * Names the folder's `.env` and config may not set. A host that opens other + * people's folders passes {@link HOST_DECIDED_ENV}; the default is none. + */ + refuse?: readonly string[]; /** Test seam. */ deps?: Partial; } export type StartedProject = StartedUp; +/** + * What a host that opens folders it did not make must decide itself: where the + * server listens, what it trusts, what it runs and where its own files are. A + * folder's `.env` that names one of these is not obeyed (and is told so). + * + * `NODE_OPTIONS`, `PATH` and `ELECTRON_RUN_AS_NODE` are here because the + * environment the file fills is handed on to every program the server starts. + */ +export const HOST_DECIDED_ENV: readonly string[] = [ + 'HOST', + 'PORT', + 'ADMINIUM_HOST', + 'ADMINIUM_PORT', + 'ADMINIUM_RUNTIME', + 'ADMINIUM_BOOT_TOKEN', + 'ADMINIUM_STATIC_ROOT', + 'ADMINIUM_BUNDLED_ADD_ONS', + 'ADMINIUM_BUNDLED_APPS', + 'ADMINIUM_DEMO_SEED_SCRIPT', + 'ADMINIUM_TRUST_PROXY', + 'ADMINIUM_TRUSTED_PROXIES', + 'ADMINIUM_DESIGNER', + 'ADMINIUM_PROJECT_MODE', + 'ADMINIUM_PROJECT_DIR', + 'ADMINIUM_DESKTOP_PROJECT', + 'ADMINIUM_DESKTOP_PROGRAMS', + 'ADMINIUM_DESKTOP_SINGLE_USER', + 'NODE_OPTIONS', + 'PATH', + 'ELECTRON_RUN_AS_NODE', +]; + function real(path: string): string { try { return realpathSync(path); @@ -59,7 +96,7 @@ export async function startProject(opts: StartProjectOptions): Promise; + /** Names the folder's `.env` or config set that the host does not let a folder decide; each was ignored. */ + refused: string[]; } /** @@ -61,23 +63,38 @@ export async function prepareProject(opts: { loadBundler?: LoadBundler; /** Who the build is for (`loadProjectConfig`); left out by a command that only reads the project. */ dev?: boolean; + /** + * Names a folder may not decide, whatever its `.env` or its config says: the + * host (the desktop app) states them itself, or leaves them unset on purpose. + * A terminal passes none: there the folder and the person are one. + */ + refuse?: readonly string[]; }): Promise { const project = findProject(opts.cwd, opts.env); if (project === null) return null; - const fromDotEnv = loadDotEnv(project.root, opts.env); + const refuse = opts.refuse ?? []; + const inFile = refuse.length === 0 ? null : readDotEnv(project.root); + const refused = new Set(inFile === null ? [] : refuse.filter((name) => name in inFile)); + const fromDotEnv = refuse.length === 0 ? loadDotEnv(project.root, opts.env) : loadDotEnv(project.root, opts.env, [...KEPT_OUT_OF_THE_ENVIRONMENT, ...refuse]); const loaded = await loadProjectConfig(project, { version: opts.version, ...(opts.loadBundler === undefined ? {} : { loadBundler: opts.loadBundler }), ...(opts.dev === undefined ? {} : { dev: opts.dev }), }); + const fromConfig = projectEnvironment(loaded.config, project.root); + for (const name of refuse) { + if (name in fromConfig && (opts.env[name] ?? '') === '') refused.add(name); + Reflect.deleteProperty(fromConfig, name); + } return { project, config: loaded.config, from: loaded.from, ...(loaded.appsRebuilt === true ? { appsRebuilt: true as const } : {}), - env: withDefaults(opts.env, projectEnvironment(loaded.config, project.root)), + env: withDefaults(opts.env, fromConfig), databases: configuredDatabases(loaded.config), fromDotEnv, sources: variableSources(project, loaded.config, opts.env, fromDotEnv), + refused: [...refused].sort(), }; } diff --git a/apps/server/src/project/dotenv.ts b/apps/server/src/project/dotenv.ts index 3c03b448f..71efbf3ce 100644 --- a/apps/server/src/project/dotenv.ts +++ b/apps/server/src/project/dotenv.ts @@ -34,7 +34,7 @@ export const AI_ENV_NAMES = [ ] as const; export type AiEnvName = (typeof AI_ENV_NAMES)[number]; -const KEPT_OUT_OF_THE_ENVIRONMENT: readonly string[] = AI_ENV_NAMES; +export const KEPT_OUT_OF_THE_ENVIRONMENT: readonly string[] = AI_ENV_NAMES; /** The variables in `/.env`, or null when there is no such file. */ export function readDotEnv(root: string): Record | null { diff --git a/apps/server/test/start-project.test.ts b/apps/server/test/start-project.test.ts index a208c567f..5e440ec45 100644 --- a/apps/server/test/start-project.test.ts +++ b/apps/server/test/start-project.test.ts @@ -13,7 +13,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { startProject, type StartedProject } from '../src/cli/start-project.js'; +import { HOST_DECIDED_ENV, startProject, type StartedProject } from '../src/cli/start-project.js'; import { markRunning, readRunning, refuseIfRunning, runningFile, runningMessage } from '../src/project/running.js'; import { fakeIo, TEST_SECRET } from './cli-helpers.js'; @@ -106,6 +106,30 @@ describe('startProject', () => { expect(live.busy()).toBeNull(); }); + it('does not obey a folder that names what the host decides', { timeout: 120_000 }, async () => { + const port = await anyPort(); + const elsewhere = await anyPort(); + writeFileSync( + join(root, '.env'), + [`ADMINIUM_SECRET=${TEST_SECRET}`, 'HOST=0.0.0.0', `PORT=${String(elsewhere)}`, 'ADMINIUM_TRUST_PROXY=true', 'ADMINIUM_DESIGNER=live', 'ADMINIUM_STATIC_ROOT=/tmp/nowhere', 'NODE_OPTIONS=--require ./evil.cjs', 'ADMINIUM_LOG_LEVEL=error', ''].join('\n'), + ); + const io = fakeIo(); + // The host leaves the trust and Designer names unset on purpose: the file must not fill them. + live = await startProject({ root, port, mode: 'serve', io, env: { ...env(), ADMINIUM_RUNTIME: 'desktop' }, refuse: HOST_DECIDED_ENV }); + expect(live.port).toBe(port); + expect(live.url).toBe(`http://127.0.0.1:${String(port)}`); + expect(await listening(elsewhere)).toBe(false); + expect(live.refused).toEqual(['ADMINIUM_DESIGNER', 'ADMINIUM_STATIC_ROOT', 'ADMINIUM_TRUST_PROXY', 'HOST', 'NODE_OPTIONS', 'PORT']); + expect(io.stderr()).toContain("Ignored from this project's .env and config"); + }); + + it('obeys the same file on a terminal, where the folder and the person are one', { timeout: 120_000 }, async () => { + const port = await anyPort(); + writeFileSync(join(root, '.env'), `ADMINIUM_SECRET=${TEST_SECRET}\nADMINIUM_TRUST_PROXY=true\n`); + live = await startProject({ root, port, mode: 'serve', io: fakeIo(), env: env() }); + expect(live.refused).toEqual([]); + }); + it('refuses a folder that is not the project itself, even inside one', async () => { const inner = join(root, 'apps'); mkdirSync(inner); From 2dd5937d46ae9d072cb14f7865dec7412bbbaf97 Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Fri, 9 Oct 2026 20:55:30 +0200 Subject: [PATCH 06/79] feat(desktop): the server child serves a project folder through startProject --- apps/desktop/src/main/server-manager.ts | 2 + apps/desktop/src/server/env.ts | 115 ++++++++++++ apps/desktop/src/server/index.ts | 119 ++++++++++++- apps/desktop/src/server/project.test.ts | 224 ++++++++++++++++++++++++ apps/desktop/src/server/protocol.ts | 19 +- apps/server/src/cli/start-project.ts | 16 +- 6 files changed, 489 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/server/project.test.ts diff --git a/apps/desktop/src/main/server-manager.ts b/apps/desktop/src/main/server-manager.ts index 6a2e7536b..6ed2b30a3 100644 --- a/apps/desktop/src/main/server-manager.ts +++ b/apps/desktop/src/main/server-manager.ts @@ -676,6 +676,8 @@ class ServerManagerImpl implements ServerManager { fail(`The Adminium server failed to start (${stage}): ${message}`, null); return; } + // An answer to `busy?` (asked by `busy()`), never a start's outcome. + if (parsed.message.type === 'busy') return; const ready = parsed.message; finish(() => { resolve(ready); diff --git a/apps/desktop/src/server/env.ts b/apps/desktop/src/server/env.ts index 85151dd8b..92693de58 100644 --- a/apps/desktop/src/server/env.ts +++ b/apps/desktop/src/server/env.ts @@ -210,6 +210,17 @@ export const STRIPPED_INHERITED_ENV_KEYS: readonly string[] = [ // worse than meaningless inherited: the server refuses to boot on a list // while the flag is off, so a stray export would stop the app from starting. 'ADMINIUM_TRUSTED_PROXIES', + // Project mode (plan 66). Which folder this child serves, and how, is main's + // to say after the person answered the trust question: an inherited value + // would have the app build and run a folder nobody picked. + 'ADMINIUM_DESKTOP_PROJECT', + 'ADMINIUM_DESKTOP_PROJECT_MODE', + 'ADMINIUM_DESKTOP_PROGRAMS', + 'ADMINIUM_PROJECT_DIR', + 'ADMINIUM_PROJECT_MODE', + // `live` is never the desktop's, and the bundled apps' folder is the app's own. + 'ADMINIUM_DESIGNER', + 'ADMINIUM_BUNDLED_APPS', ]; /** @@ -424,3 +435,107 @@ export function toServerEnvRecord( } return record; } + +// ─── Project mode ──────────────────────────────────────────────────────────── + +/** How a project is served: built on this machine only, or shared as `adminium start` serves it. */ +export type DesktopProjectMode = 'design' | 'serve'; + +export interface BuildProjectServerEnvInput { + /** The project's folder, by its real path. */ + root: string; + mode: DesktopProjectMode; + /** Picked by main before every fork: design mode must know it before it listens. */ + port: number; + /** `serve` only; design mode is this machine only whatever is given. */ + host?: string | undefined; + /** Signs the project's owner in once (design mode's one-use link). */ + bootToken: string; + logLevel?: DesktopLogLevel | undefined; + staticRoot?: string | undefined; + bundledAddOnsDir?: string | undefined; + bundledAppsDir?: string | undefined; + inherit?: NodeJS.ProcessEnv | undefined; +} + +/** + * The environment of a child that serves a project folder. + * + * What it does NOT set is the point: `ADMINIUM_SECRET`, `ADMINIUM_DATA_DIR` + * and `ADMINIUM_META_URL` are the project's own (its `.env`, its `data/`), + * never the classic workspace's. Every name that decides where the server + * listens, what it trusts and where the app's own files are is stated here or + * left out on purpose, and the child refuses those names from the folder's + * `.env` (`HOST_DECIDED_ENV` in the server package). + */ +export function buildProjectServerEnv(input: BuildProjectServerEnvInput): Record { + if (!isAbsolute(input.root)) throw new Error(`the project folder must be an absolute path, got "${input.root}"`); + if (input.bootToken.length !== BOOT_TOKEN_HEX_LENGTH) { + throw new Error(`bootToken must be ${String(BOOT_TOKEN_HEX_LENGTH)} hex characters, got ${String(input.bootToken.length)}`); + } + if (!Number.isInteger(input.port) || input.port < 1 || input.port > 65535) { + throw new Error(`a project's port is picked before the fork, got ${String(input.port)}`); + } + const env: Record = {}; + for (const [key, value] of Object.entries(input.inherit ?? {})) { + if (value === undefined) continue; + if (STRIPPED_INHERITED_ENV_KEYS.includes(key)) continue; + env[key] = value; + } + const host = input.mode === 'serve' ? (input.host ?? LOOPBACK_HOST) : LOOPBACK_HOST; + env.ADMINIUM_RUNTIME = 'desktop'; + env.ADMINIUM_DESKTOP_PROJECT = input.root; + env.ADMINIUM_DESKTOP_PROJECT_MODE = input.mode; + env.ADMINIUM_HOST = host; + env.HOST = host; + env.ADMINIUM_PORT = String(input.port); + env.PORT = String(input.port); + env.ADMINIUM_BOOT_TOKEN = input.bootToken; + if (input.logLevel !== undefined) env.ADMINIUM_LOG_LEVEL = input.logLevel; + if (input.staticRoot !== undefined) env.ADMINIUM_STATIC_ROOT = input.staticRoot; + if (input.bundledAddOnsDir !== undefined) env.ADMINIUM_BUNDLED_ADD_ONS = input.bundledAddOnsDir; + // Absolute, always: the server's default is `./apps-bundle`, which in this child would be looked for inside the opened folder. + if (input.bundledAppsDir !== undefined) env.ADMINIUM_BUNDLED_APPS = input.bundledAppsDir; + return env; +} + +export interface DesktopProjectEnv { + root: string; + mode: DesktopProjectMode; + host: string; + port: number; + bootToken: string; + logLevel: DesktopLogLevel | undefined; +} + +/** Whether main started this child for a project folder. */ +export function isProjectEnv(env: NodeJS.ProcessEnv): boolean { + return (env.ADMINIUM_DESKTOP_PROJECT ?? '') !== ''; +} + +/** Reads back what {@link buildProjectServerEnv} wrote. Anything missing or odd is a refusal, not a default. */ +export function parseDesktopProjectEnv(env: NodeJS.ProcessEnv): DesktopProjectEnv { + const problems: string[] = []; + const root = env.ADMINIUM_DESKTOP_PROJECT ?? ''; + if (root === '' || !isAbsolute(root)) problems.push('ADMINIUM_DESKTOP_PROJECT must be an absolute path'); + const mode = env.ADMINIUM_DESKTOP_PROJECT_MODE; + if (mode !== 'design' && mode !== 'serve') problems.push('ADMINIUM_DESKTOP_PROJECT_MODE must be design or serve'); + const port = Number(env.ADMINIUM_PORT); + if (!Number.isInteger(port) || port < 1 || port > 65535) problems.push('ADMINIUM_PORT must be a port main picked'); + const host = env.ADMINIUM_HOST ?? ''; + if (host === '') problems.push('ADMINIUM_HOST must be set'); + if (mode === 'design' && host !== LOOPBACK_HOST) problems.push(`ADMINIUM_HOST must be ${LOOPBACK_HOST} while a project is built`); + const bootToken = env.ADMINIUM_BOOT_TOKEN ?? ''; + if (!/^[0-9a-f]+$/.test(bootToken) || bootToken.length !== BOOT_TOKEN_HEX_LENGTH) problems.push('ADMINIUM_BOOT_TOKEN must be the token main minted'); + if (env.ADMINIUM_RUNTIME !== 'desktop') problems.push('ADMINIUM_RUNTIME must be desktop'); + const level = env.ADMINIUM_LOG_LEVEL; + if (problems.length > 0) throw new DesktopServerEnvError(problems); + return { + root, + mode: mode as DesktopProjectMode, + host, + port, + bootToken, + logLevel: (DESKTOP_LOG_LEVELS as readonly string[]).includes(level ?? '') ? (level as DesktopLogLevel) : undefined, + }; +} diff --git a/apps/desktop/src/server/index.ts b/apps/desktop/src/server/index.ts index 88a795378..f69c43b13 100644 --- a/apps/desktop/src/server/index.ts +++ b/apps/desktop/src/server/index.ts @@ -53,6 +53,7 @@ // matrix gives this app two inputs — "`@adminium/server` (spawned)" and // "dashboard build output (static files)" — and no others. import { + HOST_DECIDED_ENV, LATEST_META_MIGRATION, composeServer, firstRun, @@ -60,11 +61,15 @@ import { metaEngineFromUrl, openRuntime, resolveStaticRoot, + startProject, type AdminiumServer, type CliRuntime, + type StartedProject, } from '@adminium/server'; import { + isProjectEnv, + parseDesktopProjectEnv, parseDesktopServerEnv, toServerEnvRecord, type DesktopServerEnv, @@ -292,6 +297,11 @@ export async function runServerEntry(opts: RunServerEntryOptions): Promise exit(0), () => exit(1), @@ -322,6 +332,113 @@ export async function runServerEntry(opts: RunServerEntryOptions): Promise void; +} + +/** + * The child, serving a project folder (plan 66, spec 05). + * + * It runs the terminal's start path, not a copy of it: `startProject` is what + * `adminium start` and `adminium design` run, and it hands back the two things + * a terminal never needed, a `close()` that says when everything is gone and a + * `busy()` main asks before it ends this process. + * + * NOTHING HERE DECIDES WHETHER THE FOLDER MAY RUN. The trust question, the + * folder's state, a new project's scaffold and the install of its packages are + * main's, before the fork; by the time this child exists the folder is one the + * person said yes to and that is ready to start. This function builds and runs + * the folder's code, which is exactly why main must not fork it sooner. + * + * The working folder becomes the project's, as on a terminal, so `./data` is + * the project's own. The data folder and the secret are the project's `.env`'s; + * main sets neither. The names main does decide are refused from that file + * (`HOST_DECIDED_ENV`). + */ +export async function runProjectEntry(opts: RunProjectEntryOptions): Promise { + const port = opts.parentPort; + const log = opts.onLog ?? ((line: string) => console.error(line)); + const exit = opts.exit ?? ((code: number) => process.exit(code)); + const start = opts.start ?? startProject; + const chdir = opts.chdir ?? ((directory: string) => process.chdir(directory)); + const post = (message: ServerMessage): void => { + if (port === null) { + log(`[server] no parentPort; ${JSON.stringify(message)}`); + return; + } + port.postMessage(message); + }; + + let host: string; + let started: StartedProject; + try { + const processEnv = opts.env ?? process.env; + const project = await stage('env', () => parseDesktopProjectEnv(processEnv)); + host = project.host; + started = await stage('project', async () => { + chdir(project.root); + const env: NodeJS.ProcessEnv = { ...processEnv }; + // While a project is built the token is design mode's one-use link, and no other door takes it. + if (project.mode === 'design') delete env.ADMINIUM_BOOT_TOKEN; + return start({ + root: project.root, + port: project.port, + mode: project.mode, + host: project.host, + env, + refuse: HOST_DECIDED_ENV, + logLevel: project.logLevel ?? 'warn', + ...(project.mode === 'design' ? { token: project.bootToken } : {}), + }); + }); + } catch (error) { + const stageName: ServerBootStage = error instanceof BootStageError ? error.stage : 'env'; + const message = error instanceof Error ? error.message : String(error); + const detail = error instanceof BootStageError ? error.detail : undefined; + post({ type: 'error', stage: stageName, message, ...(detail === undefined ? {} : { detail }) }); + log(`[server] project start failed at stage "${stageName}": ${message}`); + exit(1); + return null; + } + + if (port !== null) { + let closing = false; + port.on('message', (event) => { + const parsed = parseParentMessage(event.data); + if (!parsed.ok) { + log(`[server] ignoring unrecognized parent message: ${parsed.error}`); + return; + } + if (parsed.message.type === 'busy?') { + post({ type: 'busy', busy: started.busy() }); + return; + } + // A second `shutdown` while the first is still closing changes nothing: the first one's exit is the answer. + if (closing) return; + closing = true; + void started.close().then( + () => exit(0), + () => exit(1), + ); + }); + port.start?.(); + } + + post({ + type: 'ready', + port: started.port, + host, + // `startProject` migrates inside its own path and does not count; the build's newest migration is still true. + migrations: { applied: 0, version: LATEST_META_MIGRATION }, + }); + return started; +} + if (process.env.ADMINIUM_RUNTIME === 'desktop') { - void runServerEntry({ parentPort: resolveParentPort() }); + if (isProjectEnv(process.env)) void runProjectEntry({ parentPort: resolveParentPort() }); + else void runServerEntry({ parentPort: resolveParentPort() }); } diff --git a/apps/desktop/src/server/project.test.ts b/apps/desktop/src/server/project.test.ts new file mode 100644 index 000000000..7a95035f3 --- /dev/null +++ b/apps/desktop/src/server/project.test.ts @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** + * Project mode in the server child (plan 66, spec 05): the environment main + * gives it, the messages, and the entry itself with the server package's + * `startProject` stood in for. The real start is the server package's own test + * (`apps/server/test/start-project.test.ts`). + */ +import { HOST_DECIDED_ENV } from '@adminium/server'; +import { describe, expect, it, vi } from 'vitest'; + +import { + buildProjectServerEnv, + buildServerEnv, + isProjectEnv, + parseDesktopProjectEnv, + STRIPPED_INHERITED_ENV_KEYS, +} from './env.js'; +import { runProjectEntry, type ParentPortLike } from './index.js'; +import { parseParentMessage, parseServerMessage } from './protocol.js'; + +const TOKEN = 'ab'.repeat(32); +const ROOT = '/Users/someone/Adminium/juniper'; + +const block = (over: Partial[0]> = {}) => + buildProjectServerEnv({ root: ROOT, mode: 'design', port: 4700, bootToken: TOKEN, ...over }); + +describe('buildProjectServerEnv', () => { + it('states where the server listens and which folder it serves, in both spellings', () => { + expect(block()).toEqual({ + ADMINIUM_RUNTIME: 'desktop', + ADMINIUM_DESKTOP_PROJECT: ROOT, + ADMINIUM_DESKTOP_PROJECT_MODE: 'design', + ADMINIUM_HOST: '127.0.0.1', + HOST: '127.0.0.1', + ADMINIUM_PORT: '4700', + PORT: '4700', + ADMINIUM_BOOT_TOKEN: TOKEN, + }); + }); + + it('never sets the secret, the data folder or the store: they are the project’s own', () => { + const env = block({ inherit: { ADMINIUM_SECRET: 'classic', ADMINIUM_DATA_DIR: '/classic', ADMINIUM_META_URL: 'sqlite:/classic/meta.db', ADMINIUM_META_DSN: 'x' } }); + for (const name of ['ADMINIUM_SECRET', 'ADMINIUM_DATA_DIR', 'ADMINIUM_META_URL', 'ADMINIUM_META_DSN', 'ADMINIUM_DESKTOP_SINGLE_USER', 'ADMINIUM_TRUST_PROXY', 'ADMINIUM_DESIGNER']) { + expect(env, name).not.toHaveProperty(name); + } + }); + + it('is this machine only while a project is built, whatever host is asked for', () => { + expect(block({ host: '0.0.0.0' }).ADMINIUM_HOST).toBe('127.0.0.1'); + const shared = block({ mode: 'serve', host: '0.0.0.0' }); + expect(shared.ADMINIUM_HOST).toBe('0.0.0.0'); + expect(shared.HOST).toBe('0.0.0.0'); + expect(block({ mode: 'serve' }).ADMINIUM_HOST).toBe('127.0.0.1'); + }); + + it('lets nothing inherited choose the folder, the mode or where the app’s own files are', () => { + const hostile = Object.fromEntries(STRIPPED_INHERITED_ENV_KEYS.map((name) => [name, 'inherited'])); + const env = block({ inherit: { ...hostile, NODE_OPTIONS: '--require /x.cjs', LANG: 'en_GB.UTF-8' } }); + expect(Object.values(env)).not.toContain('inherited'); + expect(env.ADMINIUM_DESKTOP_PROJECT).toBe(ROOT); + // Passed through as it is: the copied-app read guard rides on it (spec 06). + expect(env.NODE_OPTIONS).toBe('--require /x.cjs'); + expect(env.LANG).toBe('en_GB.UTF-8'); + }); + + it('gives the app’s own folders as they are, so none is looked for inside the opened folder', () => { + const env = block({ staticRoot: '/app/out/dashboard', bundledAddOnsDir: '/app/add-ons', bundledAppsDir: '/app/apps-bundle', logLevel: 'info' }); + expect(env).toMatchObject({ ADMINIUM_STATIC_ROOT: '/app/out/dashboard', ADMINIUM_BUNDLED_ADD_ONS: '/app/add-ons', ADMINIUM_BUNDLED_APPS: '/app/apps-bundle', ADMINIUM_LOG_LEVEL: 'info' }); + }); + + it('refuses a relative folder, a port that was not picked and a token of the wrong size', () => { + expect(() => block({ root: 'juniper' })).toThrow(/absolute path/); + expect(() => block({ port: 0 })).toThrow(/picked before the fork/); + expect(() => block({ bootToken: 'abc' })).toThrow(/64 hex/); + }); + + it('every name it states is one a folder’s .env may not set', () => { + for (const name of Object.keys(block({ staticRoot: '/s', bundledAddOnsDir: '/a', bundledAppsDir: '/b' }))) { + if (name === 'ADMINIUM_LOG_LEVEL' || name === 'ADMINIUM_DESKTOP_PROJECT_MODE') continue; + expect(HOST_DECIDED_ENV, name).toContain(name); + } + // And the two it leaves unset on purpose. + expect(HOST_DECIDED_ENV).toEqual(expect.arrayContaining(['ADMINIUM_TRUST_PROXY', 'ADMINIUM_DESIGNER', 'NODE_OPTIONS', 'PATH'])); + }); + + it('the classic block names no project, so the classic child stays the classic child', () => { + const classic = buildServerEnv({ dataDir: '/classic', secret: 's'.repeat(64), bootToken: TOKEN, singleUser: true, inherit: { ADMINIUM_DESKTOP_PROJECT: ROOT, ADMINIUM_DESKTOP_PROJECT_MODE: 'design' } }); + expect(isProjectEnv(classic)).toBe(false); + expect(isProjectEnv(block())).toBe(true); + }); +}); + +describe('parseDesktopProjectEnv', () => { + it('reads back what main wrote', () => { + expect(parseDesktopProjectEnv(block({ logLevel: 'debug' }))).toEqual({ root: ROOT, mode: 'design', host: '127.0.0.1', port: 4700, bootToken: TOKEN, logLevel: 'debug' }); + }); + + it.each([ + ['a relative folder', { ADMINIUM_DESKTOP_PROJECT: 'juniper' }, /absolute path/], + ['no mode', { ADMINIUM_DESKTOP_PROJECT_MODE: '' }, /design or serve/], + ['no port', { ADMINIUM_PORT: '0' }, /a port main picked/], + ['a network host while building', { ADMINIUM_HOST: '0.0.0.0' }, /127\.0\.0\.1 while a project is built/], + ['a token that is not the minted one', { ADMINIUM_BOOT_TOKEN: 'zz'.repeat(32) }, /the token main minted/], + ['another runtime', { ADMINIUM_RUNTIME: 'self-host' }, /must be desktop/], + ])('refuses %s', (_label, over, message) => { + expect(() => parseDesktopProjectEnv({ ...block(), ...over })).toThrow(message); + }); +}); + +describe('the messages', () => { + it('main may ask what the server is in the middle of', () => { + expect(parseParentMessage({ type: 'busy?' })).toEqual({ ok: true, message: { type: 'busy?' } }); + expect(parseParentMessage({ type: 'shutdown' }).ok).toBe(true); + expect(parseParentMessage({ type: 'eval', code: 'x' }).ok).toBe(false); + }); + + it('and the server answers with the Designer’s word, or nothing', () => { + expect(parseServerMessage({ type: 'busy', busy: null }).ok).toBe(true); + expect(parseServerMessage({ type: 'busy', busy: { kind: 'turn', sessionId: 'ds_1' } }).ok).toBe(true); + expect(parseServerMessage({ type: 'busy', busy: { kind: '' } }).ok).toBe(false); + expect(parseServerMessage({ type: 'error', stage: 'project', message: 'x' }).ok).toBe(true); + }); +}); + +function fakePort() { + const posted: unknown[] = []; + let listener: ((event: { data: unknown }) => void) | null = null; + const port: ParentPortLike = { + postMessage: (message) => void posted.push(message), + on: (_event, fn) => { + listener = fn; + }, + }; + return { port, posted, send: (data: unknown) => listener?.({ data }) }; +} + +const started = (over: Record = {}) => ({ + url: 'http://127.0.0.1:4700', + port: 4700, + token: TOKEN, + bridgePairingCode: null, + refused: [], + close: vi.fn(async () => undefined), + busy: vi.fn(() => null as { kind: string; sessionId: string | null } | null), + ...over, +}); + +describe('runProjectEntry', () => { + it('goes to the folder, starts it as a terminal would, and says ready', async () => { + const { port, posted } = fakePort(); + const handle = started(); + const start = vi.fn(async () => handle); + const chdir = vi.fn(); + const env = block({ inherit: { LANG: 'C' } }); + await runProjectEntry({ parentPort: port, env, start, chdir, exit: vi.fn(), onLog: vi.fn() }); + + expect(chdir).toHaveBeenCalledWith(ROOT); + expect(chdir.mock.invocationCallOrder[0]).toBeLessThan(start.mock.invocationCallOrder[0] ?? 0); + const given = (start.mock.calls[0] as unknown as [Record])[0]; + expect(given).toMatchObject({ root: ROOT, port: 4700, mode: 'design', host: '127.0.0.1', token: TOKEN, refuse: HOST_DECIDED_ENV }); + // While a project is built the token opens design mode's one door and no other. + expect(given.env).not.toHaveProperty('ADMINIUM_BOOT_TOKEN'); + expect(given.env).toMatchObject({ ADMINIUM_RUNTIME: 'desktop', LANG: 'C' }); + expect(posted).toEqual([{ type: 'ready', port: 4700, host: '127.0.0.1', migrations: { applied: 0, version: expect.any(String) as string } }]); + }); + + it('shared: no design link, the host main chose, the boot token kept for its own door', async () => { + const { port } = fakePort(); + const start = vi.fn(async () => started({ token: null })); + await runProjectEntry({ parentPort: port, env: block({ mode: 'serve', host: '0.0.0.0' }), start, chdir: vi.fn(), exit: vi.fn(), onLog: vi.fn() }); + const given = (start.mock.calls[0] as unknown as [Record])[0]; + expect(given).toMatchObject({ mode: 'serve', host: '0.0.0.0' }); + expect(given).not.toHaveProperty('token'); + expect(given.env).toHaveProperty('ADMINIUM_BOOT_TOKEN', TOKEN); + }); + + it('answers busy? with the Designer’s word, and stops once, saying so by its exit', async () => { + const { port, posted, send } = fakePort(); + let release: () => void = () => undefined; + const handle = started({ + busy: vi.fn(() => ({ kind: 'turn', sessionId: 'ds_1' })), + close: vi.fn(() => new Promise((done) => (release = done))), + }); + const exit = vi.fn(); + await runProjectEntry({ parentPort: port, env: block(), start: vi.fn(async () => handle), chdir: vi.fn(), exit, onLog: vi.fn() }); + + send({ type: 'busy?' }); + expect(posted.at(-1)).toEqual({ type: 'busy', busy: { kind: 'turn', sessionId: 'ds_1' } }); + + send({ type: 'shutdown' }); + send({ type: 'shutdown' }); + expect(handle.close).toHaveBeenCalledTimes(1); + // Not gone until close() says everything is. + expect(exit).not.toHaveBeenCalled(); + release(); + await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(0)); + }); + + it('a start that fails says at which step, with the server’s own words, and exits', async () => { + const { port, posted } = fakePort(); + const exit = vi.fn(); + const result = await runProjectEntry({ + parentPort: port, + env: block(), + start: vi.fn(async () => Promise.reject(new Error('This project is already running (in a terminal), on port 4711.'))), + chdir: vi.fn(), + exit, + onLog: vi.fn(), + }); + expect(result).toBeNull(); + expect(posted).toEqual([expect.objectContaining({ type: 'error', stage: 'project', message: 'This project is already running (in a terminal), on port 4711.' })]); + expect(exit).toHaveBeenCalledWith(1); + }); + + it('an environment main did not write never reaches the folder', async () => { + const { port, posted } = fakePort(); + const start = vi.fn(); + const chdir = vi.fn(); + await runProjectEntry({ parentPort: port, env: { ...block(), ADMINIUM_DESKTOP_PROJECT_MODE: 'live' }, start, chdir, exit: vi.fn(), onLog: vi.fn() }); + expect(start).not.toHaveBeenCalled(); + expect(chdir).not.toHaveBeenCalled(); + expect(posted).toEqual([expect.objectContaining({ type: 'error', stage: 'env' })]); + }); +}); diff --git a/apps/desktop/src/server/protocol.ts b/apps/desktop/src/server/protocol.ts index 2eec3fef2..26ebad0eb 100644 --- a/apps/desktop/src/server/protocol.ts +++ b/apps/desktop/src/server/protocol.ts @@ -35,6 +35,8 @@ export const SERVER_BOOT_STAGES = [ 'migrate', 'compose', 'listen', + // Project mode: the terminal's start path, as one stage (it prints its own lines). + 'project', ] as const; export type ServerBootStage = (typeof SERVER_BOOT_STAGES)[number]; @@ -87,13 +89,25 @@ export const serverErrorMessageSchema = z.object({ }); /** Child → parent. */ +/** + * A project's server answering `busy?`: what has the folder right now (a turn, + * a save, a restore…), or `null`. Main asks before anything that ends the + * child, so that it can ask the person first. + */ +export const serverBusyMessageSchema = z.object({ + type: z.literal('busy'), + busy: z.object({ kind: z.string().min(1), sessionId: z.string().nullable() }).nullable(), +}); + export const serverMessageSchema = z.discriminatedUnion('type', [ serverReadyMessageSchema, serverErrorMessageSchema, + serverBusyMessageSchema, ]); export type ServerReadyMessage = z.infer; export type ServerErrorMessage = z.infer; +export type ServerBusyMessage = z.infer; export type ServerMessage = z.infer; /** @@ -102,7 +116,10 @@ export type ServerMessage = z.infer; * `wal_checkpoint(TRUNCATE)` when the meta store wires it) and exits 0. The * parent only reaches for `kill()` when this is ignored. */ -export const parentMessageSchema = z.object({ type: z.literal('shutdown') }); +export const parentMessageSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('shutdown') }), + z.object({ type: z.literal('busy?') }), +]); export type ParentMessage = z.infer; diff --git a/apps/server/src/cli/start-project.ts b/apps/server/src/cli/start-project.ts index 08c5bf485..59daef12c 100644 --- a/apps/server/src/cli/start-project.ts +++ b/apps/server/src/cli/start-project.ts @@ -12,7 +12,7 @@ import { realpathSync } from 'node:fs'; import { findProject } from '../project/locate.js'; import { localOwnerStart, type DesignStart, type StartedUp, startUp } from './commands/start.js'; import { CliError, EXIT_CONFIG } from './exit.js'; -import type { CliIo } from './io.js'; +import { nodeIo, type CliIo } from './io.js'; import { defaultCliDeps, type CliDeps } from './runtime.js'; export interface StartProjectOptions { @@ -26,7 +26,14 @@ export interface StartProjectOptions { mode: 'design' | 'serve'; /** `serve` only (design mode is 127.0.0.1 whatever is given). Default 127.0.0.1. */ host?: string; - io: CliIo; + /** Where its lines go. Default: this process's own output. */ + io?: CliIo; + /** + * Design mode: 64 hex characters that sign the project's owner in once, at + * `/design#designToken=…`, while that owner has no password. The host mints + * it and opens that address itself. Left out: no such link. + */ + token?: string; /** The environment the project's `.env` fills in. Default: this process's. */ env?: Record; logLevel?: string; @@ -94,9 +101,10 @@ export async function startProject(opts: StartProjectOptions): Promise Date: Fri, 9 Oct 2026 20:58:01 +0200 Subject: [PATCH 07/79] feat(desktop): the server manager serves a project: port per fork, busy --- apps/desktop/src/main/index.test.ts | 2 + apps/desktop/src/main/server-manager.test.ts | 185 +++++++++++++++++++ apps/desktop/src/main/server-manager.ts | 148 +++++++++++++-- 3 files changed, 323 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/main/index.test.ts b/apps/desktop/src/main/index.test.ts index 3414f8a3d..71eb13c67 100644 --- a/apps/desktop/src/main/index.test.ts +++ b/apps/desktop/src/main/index.test.ts @@ -308,6 +308,8 @@ function harness( get bootToken() { return liveBootToken; }, + project: null, + busy: () => Promise.resolve(null), start: () => fork( overrides.start ?? diff --git a/apps/desktop/src/main/server-manager.test.ts b/apps/desktop/src/main/server-manager.test.ts index 0a60e2b19..a3d817a7f 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,183 @@ 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', + 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_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('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('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 6ed2b30a3..f81457989 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 } from '../server/protocol.js'; import { createDesktopLogging, createMemoryLogSink, @@ -194,6 +196,40 @@ 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; +} /** Lines of log attached to a failure. Enough for a stack, short enough to read. */ export const CRASH_EXCERPT_LINES = 20; @@ -220,6 +256,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 +316,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 +351,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 +403,14 @@ 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 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,7 +428,7 @@ 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(); @@ -411,6 +463,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 +479,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 +543,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 +594,68 @@ 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 }; + } + + 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; + 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 }), + ...(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 +682,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 +713,11 @@ class ServerManagerImpl implements ServerManager { } const ready = this.#awaitReady(child); + child.on('message', (raw) => { + const parsed = parseServerMessage(raw); + if (!parsed.ok || parsed.message.type !== 'busy') return; + for (const waiter of [...this.#busyWaiters]) waiter(parsed.message.busy); + }); child.on('exit', (code) => { this.#onExit(child, code); }); @@ -634,7 +754,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; @@ -698,6 +818,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(); @@ -781,8 +902,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 => { From 6e6df8ebcd8e6da89c43895315c84396ff2c2ff6 Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Fri, 9 Oct 2026 21:02:27 +0200 Subject: [PATCH 08/79] feat(desktop): main opens a project folder: its own boot, quit asks first --- apps/desktop/src/main/index.test.ts | 183 ++++++++++++++++++++ apps/desktop/src/main/index.ts | 231 +++++++++++++++++++++++--- apps/desktop/src/main/project.test.ts | 84 ++++++++++ apps/desktop/src/main/project.ts | 103 ++++++++++++ apps/desktop/src/main/window.ts | 28 +++- 5 files changed, 604 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/src/main/project.test.ts create mode 100644 apps/desktop/src/main/project.ts diff --git a/apps/desktop/src/main/index.test.ts b/apps/desktop/src/main/index.test.ts index 71eb13c67..8d635a856 100644 --- a/apps/desktop/src/main/index.test.ts +++ b/apps/desktop/src/main/index.test.ts @@ -16,6 +16,8 @@ import { 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, @@ -1395,3 +1397,184 @@ describe('isElectronMain', () => { expect(isElectronMain()).toBe(false); }); }); + +// ─── 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 } = {}) { + const h = harness(); + const opts: CreateServerManagerOptions[] = []; + const shown: Array<{ url: string; preview: boolean }> = []; + const sessions: Array = []; + const confirmed: string[] = []; + let stops = 0; + let stateListener: (s: ServerState) => void = () => undefined; + let exitListener: (e: ServerExit) => void = () => undefined; + let mode: 'design' | 'serve' = 'design'; + const manager: ServerManager = { + state: { status: 'ready', ...PROJECT_READY }, + bootToken: 'c'.repeat(64), + get project() { + return { root: ROOT, mode }; + }, + 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: (changes) => { + if (changes?.mode !== undefined) mode = changes.mode; + const next = { ...PROJECT_READY, port: 4701, url: 'http://127.0.0.1:4701' }; + stateListener({ status: 'ready', ...next }); + return Promise.resolve(next); + }, + onExit: (l) => { + exitListener = l; + return () => undefined; + }, + subscribe: (l) => { + stateListener = l; + l({ status: 'ready', ...PROJECT_READY }); + 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), + }, + createServerManager: (o) => { + opts.push(o); + return manager; + }, + openProject: { root: ROOT }, + pickProjectPort: () => Promise.resolve(4700), + bundledAppsDir: '/app/apps-bundle', + confirmStopBusy: (busy) => { + confirmed.push(busy.kind); + return Promise.resolve(over.confirm ?? true); + }, + }; + return { h, deps, opts, shown, sessions, confirmed, stops: () => stops, manager, fireExit: (e: ServerExit) => exitListener(e) }; + } + 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' }, staticRoot: '/app/out/dashboard' }); + for (const name of ['dataDir', 'secret', 'singleUser', 'host', 'port']) expect(p.opts[0]).not.toHaveProperty(name); + // 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('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 again the next time, not remembered as a no. + p.h.fireBeforeQuit(); + await settle(); + expect(p.confirmed).toEqual(['turn', 'turn']); + }); + + 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'); + }); + + 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 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: 'ab' })).toBe('http://127.0.0.1:4712/'); + }); +}); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 14ca50490..618750b97 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -84,10 +84,12 @@ import { buildAppMenu, menuTranslator, type MenuHandlers, type MenuTranslate } f import { EPHEMERAL_PORT, generateBootToken, LOOPBACK_HOST } from '../server/env.js'; import { LAN_PORT_IN_USE, registerIpcHandlers, type DesktopRuntimeSnapshot } from './ipc.js'; import { createDesktopLogging } from './logging.js'; +import { firstFreePort, seamProject, sessionCookieNames, stopBusyWords } from './project.js'; import { createServerManager, type CreateServerManagerOptions, type ServerExit, + type ServerBusy, type ServerManager, type ServerReadyInfo, type ServerState, @@ -105,6 +107,7 @@ import { type CrashAction, type CrashScreenInfo, type DesktopWindows, + projectPartition, } from './window.js'; // ─── Pure policy ───────────────────────────────────────────────────────────── @@ -169,6 +172,23 @@ 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. Shared: the dashboard's front door. + */ +export function projectUrl(opts: { port: number; mode: 'design' | 'serve'; token: string | null }): string { + const origin = `http://${LOOPBACK_HOST}:${String(opts.port)}`; + if (opts.mode === 'serve') return `${origin}/`; + return opts.token === null ? `${origin}/design` : `${origin}/design#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)}`; @@ -514,6 +534,30 @@ 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; + /** 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; + /** + * 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') => Promise) | undefined; } /** What {@link DesktopBootDeps.createBackup} needs from the boot sequence. */ @@ -780,6 +824,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); @@ -805,6 +854,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.'); } @@ -895,28 +947,53 @@ 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').catch(() => true)); + asking = false; + if (!goOn) return; + stopping = true; + finish(); + await target.stop().catch(() => undefined); + host.quit(); + })(); + return; + } + finish(); + stopping = true; void target.stop().finally(() => { host.quit(); }); @@ -932,6 +1009,79 @@ 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. + /* + * 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 }): Promise => { + 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); + 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 }), + project: { + root: project.root, + mode: 'design', + pickPort, + ...(deps.bundledAppsDir === undefined ? {} : { bundledAppsDir: deps.bundledAppsDir }), + }, + }); + manager = projectManager; + updateManager = deps.createUpdateManager({ mode: loadedConfig.updates.mode }); + menuHandlers = { + showLogs: () => void deps.showLogs(), + ...(updateManager === null ? {} : { checkForUpdates: (): void => void updateManager?.checkForUpdates() }), + }; + rebuildMenu(); + + const show = (ready: { host: string; port: number }): Promise => { + const mode = projectManager.project?.mode ?? 'design'; + return windows.loadApp(projectUrl({ port: ready.port, mode, token: projectManager.bootToken }), { preview: mode === 'design' }); + }; + const started = projectManager.start(); + await windows.showBoot(); + let ready: ServerReadyInfo; + try { + ready = await started; + } catch (error) { + await windows.showCrash(crashFromStartError(error)); + return; + } + runtime = { ...runtime, serverPort: ready.port }; + await show(ready); + + // 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; + if (runtime !== null && state.port === runtime.serverPort) return; + runtime = runtime === null ? runtime : { ...runtime, serverPort: state.port }; + void show(state); + }); + }; + let firstRun: boolean; let secret: string; try { @@ -940,6 +1090,10 @@ export function createDesktopApp(deps: DesktopBootDeps): DesktopApp { config = loaded.config; firstRun = loaded.firstRun; // Step 3 — safeStorage, or the flagged plaintext fallback. + if (deps.openProject !== undefined) { + await bootProject(loaded.config, deps.openProject); + return; + } const resolved = await deps.config.resolveSecret(loaded.config); secret = resolved.secret; runtime = { @@ -1551,15 +1705,17 @@ const backupReplySchema = z.object({ */ 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; @@ -1709,6 +1865,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'); @@ -1935,6 +2093,30 @@ 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, + pickProjectPort: () => firstFreePort(), + // 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) => { + const words = stopBusyWords(busy); + 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, @@ -1990,12 +2172,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/project.test.ts b/apps/desktop/src/main/project.test.ts new file mode 100644 index 000000000..bb7f5bfce --- /dev/null +++ b/apps/desktop/src/main/project.test.ts @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { createServer, type Server } from 'node:net'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { E2E_PROJECT_ENV, firstFreePort, 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('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('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.'); + expect(stopBusyWords({ kind: 'something-new', sessionId: null }).title).toBe('This project is being changed.'); + }); +}); + +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..54e2dc56b --- /dev/null +++ b/apps/desktop/src/main/project.ts @@ -0,0 +1,103 @@ +// 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 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 native question's words. English here; the app's own pages bring the other languages (66-T15). */ +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): StopBusyWords { + const what: Record = { + 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.', + }; + return { + title: what[busy.kind] ?? 'This project is being changed.', + detail: 'If you quit now it is stopped where it is. What was already written stays.', + goOn: 'Quit anyway', + stay: 'Keep working', + }; +} + +/** 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/window.ts b/apps/desktop/src/main/window.ts index e7a57ba99..ec9e31b4d 100644 --- a/apps/desktop/src/main/window.ts +++ b/apps/desktop/src/main/window.ts @@ -21,6 +21,7 @@ * against a table of hostile URLs. */ +import { createHash } from 'node:crypto'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -199,6 +200,15 @@ export function decideFrameNavigation( return 'deny'; } +/** + * The session partition of a project: persistent, and named by a hash of the + * folder's path so that two projects never share cookies and the name says + * nothing about where the folder is. + */ +export function projectPartition(root: string): string { + return `persist:project-${createHash('sha256').update(root).digest('hex').slice(0, 24)}`; +} + /** * The preview's origin for an app URL: the same port under the name * `localhost`, which is what design mode serves the preview on. `null` for @@ -475,6 +485,12 @@ export interface DesktopWindows { * ({@link decideFrameNavigation}). */ loadApp(url: string, opts?: { readonly preview?: boolean }): Promise; + /** + * Give the window a cookie jar of this project's own (a session partition + * named by the folder's path), or `null` for the default one. A window that + * exists on another jar is closed; the next page makes a new one. + */ + useProjectSession?(root: string | null): void; /** The bundled crash page. */ showCrash(info: CrashScreenInfo): Promise; /** A second launch focuses the existing window. */ @@ -558,6 +574,8 @@ export function createWindowManager(opts: CreateWindowManagerOptions): DesktopWi /** The Designer's preview origin a subframe may load; `null` outside a project being built. */ let previewOrigin: string | null = null; let lastAppPreview = false; + /** The session partition new windows are made on; `undefined` is Electron's default session. */ + let partition: string | undefined; /** * Whether the crash page is the document currently loaded — the gate on * treating a `?action=` navigation as a button press. @@ -622,7 +640,7 @@ export function createWindowManager(opts: CreateWindowManagerOptions): DesktopWi show: false, // "standard OS chrome in v1 (no custom titlebar)". title: 'Adminium', - webPreferences: { ...WEB_PREFERENCES, preload: preloadEntry() }, + webPreferences: { ...WEB_PREFERENCES, preload: preloadEntry(), ...(partition === undefined ? {} : { partition }) }, }); win = created; @@ -799,6 +817,14 @@ export function createWindowManager(opts: CreateWindowManagerOptions): DesktopWi return win !== null && !win.isDestroyed(); }, + useProjectSession(root: string | null): void { + const next = root === null ? undefined : projectPartition(root); + if (next === partition) return; + partition = next; + if (win !== null && !win.isDestroyed()) win.destroy(); + win = null; + }, + handleFileArgument(path: string): void { // Held, not delivered — see `pendingFileArgument`. The focus IS step // 1's other half ("a second launch focuses the existing window") and works From 49d3f4c5a575d066bac0951d4503f0d37f016a4a Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Fri, 9 Oct 2026 21:06:28 +0200 Subject: [PATCH 09/79] fix(server): startProject fills the environment in place, as a terminal --- apps/desktop/src/main/index.ts | 7 +++++-- apps/desktop/src/main/project.test.ts | 14 +++++++++++++- apps/desktop/src/main/project.ts | 11 +++++++++++ apps/desktop/src/server/index.ts | 3 ++- apps/desktop/src/server/project.test.ts | 2 ++ apps/server/src/cli/start-project.ts | 13 +++++++++++-- apps/server/test/start-project.test.ts | 12 ++++++++++++ 7 files changed, 56 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 618750b97..55839a0f2 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -84,7 +84,7 @@ import { buildAppMenu, menuTranslator, type MenuHandlers, type MenuTranslate } f import { EPHEMERAL_PORT, generateBootToken, LOOPBACK_HOST } from '../server/env.js'; import { LAN_PORT_IN_USE, registerIpcHandlers, type DesktopRuntimeSnapshot } from './ipc.js'; import { createDesktopLogging } from './logging.js'; -import { firstFreePort, seamProject, sessionCookieNames, stopBusyWords } from './project.js'; +import { firstFreePort, projectPortRange, seamProject, sessionCookieNames, stopBusyWords } from './project.js'; import { createServerManager, type CreateServerManagerOptions, @@ -2096,7 +2096,10 @@ export function electronBootDeps(): DesktopBootDeps { // 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, - pickProjectPort: () => firstFreePort(), + 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'), diff --git a/apps/desktop/src/main/project.test.ts b/apps/desktop/src/main/project.test.ts index bb7f5bfce..b2baa6cae 100644 --- a/apps/desktop/src/main/project.test.ts +++ b/apps/desktop/src/main/project.test.ts @@ -3,7 +3,7 @@ import { createServer, type Server } from 'node:net'; import { afterEach, describe, expect, it } from 'vitest'; -import { E2E_PROJECT_ENV, firstFreePort, seamProject, sessionCookieNames, stopBusyWords } from './project.js'; +import { E2E_PORT_ENV, E2E_PROJECT_ENV, PROJECT_PORTS, firstFreePort, projectPortRange, seamProject, sessionCookieNames, stopBusyWords } from './project.js'; const open: Server[] = []; afterEach(async () => { @@ -64,6 +64,18 @@ describe('seamProject (the test seam)', () => { }); }); +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({ diff --git a/apps/desktop/src/main/project.ts b/apps/desktop/src/main/project.ts index 54e2dc56b..3f615faa3 100644 --- a/apps/desktop/src/main/project.ts +++ b/apps/desktop/src/main/project.ts @@ -72,6 +72,17 @@ export function seamProject( return CONFIG_FILES.some((file) => fs.exists(join(root, file))) ? { root } : undefined; } +/** 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 }; +} + /** A native question's words. English here; the app's own pages bring the other languages (66-T15). */ export interface StopBusyWords { title: string; diff --git a/apps/desktop/src/server/index.ts b/apps/desktop/src/server/index.ts index f69c43b13..2486f3c33 100644 --- a/apps/desktop/src/server/index.ts +++ b/apps/desktop/src/server/index.ts @@ -381,7 +381,8 @@ export async function runProjectEntry(opts: RunProjectEntryOptions): Promise { chdir(project.root); - const env: NodeJS.ProcessEnv = { ...processEnv }; + // The environment itself, not a copy: the project's `.env` fills it in place, and its config reads it from there. + const env = processEnv; // While a project is built the token is design mode's one-use link, and no other door takes it. if (project.mode === 'design') delete env.ADMINIUM_BOOT_TOKEN; return start({ diff --git a/apps/desktop/src/server/project.test.ts b/apps/desktop/src/server/project.test.ts index 7a95035f3..8dda95fc1 100644 --- a/apps/desktop/src/server/project.test.ts +++ b/apps/desktop/src/server/project.test.ts @@ -161,6 +161,8 @@ describe('runProjectEntry', () => { // While a project is built the token opens design mode's one door and no other. expect(given.env).not.toHaveProperty('ADMINIUM_BOOT_TOKEN'); expect(given.env).toMatchObject({ ADMINIUM_RUNTIME: 'desktop', LANG: 'C' }); + // The same object the child was started with: the project's .env fills it, and its config reads it from there. + expect(given.env).toBe(env); expect(posted).toEqual([{ type: 'ready', port: 4700, host: '127.0.0.1', migrations: { applied: 0, version: expect.any(String) as string } }]); }); diff --git a/apps/server/src/cli/start-project.ts b/apps/server/src/cli/start-project.ts index 59daef12c..847e2843f 100644 --- a/apps/server/src/cli/start-project.ts +++ b/apps/server/src/cli/start-project.ts @@ -34,7 +34,16 @@ export interface StartProjectOptions { * it and opens that address itself. Left out: no such link. */ token?: string; - /** The environment the project's `.env` fills in. Default: this process's. */ + /** + * The environment the project's `.env` fills in. Default: this process's. + * + * It is filled IN PLACE, as a terminal's is. Pass `process.env` itself (the + * default) for a project that is really served: `adminium.config.ts` reads + * its values with `env('DATABASE_URL')`, which is this process's + * environment, so a copy would start a project whose config never saw its + * own `.env`. The two names that say which folder and which mode are + * removed from it: this call decides both. + */ env?: Record; logLevel?: string; /** Design mode's owner and link. Default: make the project's owner when it has none; no link. */ @@ -91,7 +100,7 @@ function real(path: string): string { } export async function startProject(opts: StartProjectOptions): Promise { - const env = { ...(opts.env ?? process.env) }; + const env = opts.env ?? process.env; // The folder that was picked, never one named by the environment or found above it. delete env.ADMINIUM_PROJECT_DIR; // The mode is what this call says, never what was inherited. diff --git a/apps/server/test/start-project.test.ts b/apps/server/test/start-project.test.ts index 5e440ec45..e89d57987 100644 --- a/apps/server/test/start-project.test.ts +++ b/apps/server/test/start-project.test.ts @@ -130,6 +130,18 @@ describe('startProject', () => { expect(live.refused).toEqual([]); }); + it('fills the environment it is given in place, so the project’s config reads its own .env', { timeout: 120_000 }, async () => { + const port = await anyPort(); + writeFileSync(join(root, '.env'), `ADMINIUM_SECRET=${TEST_SECRET}\nP66_T12_FROM_DOTENV=yes\n`); + const given: Record = { ...env(), ADMINIUM_PROJECT_DIR: '/elsewhere', ADMINIUM_PROJECT_MODE: 'dev' }; + live = await startProject({ root, port, mode: 'serve', io: fakeIo(), env: given }); + expect(given.P66_T12_FROM_DOTENV).toBe('yes'); + // Which folder and which mode are this call's to say. + expect(given).not.toHaveProperty('ADMINIUM_PROJECT_DIR'); + expect(given).not.toHaveProperty('ADMINIUM_PROJECT_MODE'); + expect(JSON.parse(readFileSync(runningFile(root), 'utf8'))).toMatchObject({ mode: 'start' }); + }); + it('refuses a folder that is not the project itself, even inside one', async () => { const inner = join(root, 'apps'); mkdirSync(inner); From 6b5f1c7fc1d7d1d68340c8d0fc451379c82ba430 Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Fri, 9 Oct 2026 21:11:38 +0200 Subject: [PATCH 10/79] test(e2e): a project folder in the desktop app, start to quit --- apps/desktop/src/main/index.test.ts | 3 + apps/desktop/src/main/index.ts | 10 + .../e2e/tests-desktop/desktop-project.spec.ts | 178 ++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 apps/e2e/tests-desktop/desktop-project.spec.ts diff --git a/apps/desktop/src/main/index.test.ts b/apps/desktop/src/main/index.test.ts index 8d635a856..ebf78bfd4 100644 --- a/apps/desktop/src/main/index.test.ts +++ b/apps/desktop/src/main/index.test.ts @@ -1460,6 +1460,7 @@ describe('createDesktopApp opening a project folder', () => { openProject: { root: ROOT }, pickProjectPort: () => Promise.resolve(4700), bundledAppsDir: '/app/apps-bundle', + projectEnv: { PATH: '/usr/bin:/bin', ADMINIUM_SECRET: 'from the shell' }, confirmStopBusy: (busy) => { confirmed.push(busy.kind); return Promise.resolve(over.confirm ?? true); @@ -1478,6 +1479,8 @@ describe('createDesktopApp opening a project folder', () => { expect(p.opts).toHaveLength(1); expect(p.opts[0]).toMatchObject({ project: { root: ROOT, mode: 'design', bundledAppsDir: '/app/apps-bundle' }, 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'); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 55839a0f2..b8360b991 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -552,6 +552,14 @@ export interface DesktopBootDeps { pickProjectPort?: ((mode: 'design' | 'serve') => Promise) | undefined; /** The app's own bundled apps, as an absolute path. */ bundledAppsDir?: string | 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 @@ -1035,6 +1043,7 @@ export function createDesktopApp(deps: DesktopBootDeps): DesktopApp { 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', @@ -2096,6 +2105,7 @@ export function electronBootDeps(): DesktopBootDeps { // 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, + projectEnv: process.env, pickProjectPort: () => { const range = projectPortRange(process.env, app.isPackaged); return firstFreePort(range.first, range.last); diff --git a/apps/e2e/tests-desktop/desktop-project.spec.ts b/apps/e2e/tests-desktop/desktop-project.spec.ts new file mode 100644 index 000000000..e7a572caf --- /dev/null +++ b/apps/e2e/tests-desktop/desktop-project.spec.ts @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** + * A project folder in the desktop app (plan 66, 66-T12): the app's own server + * child serves the folder as a terminal would, and the window opens Adminium + * Designer on it, signed in. + * + * The folder is opened through the test seam (`ADMINIUM_DESKTOP_E2E_PROJECT`), + * which only an unpackaged build reads: the app has no screen to pick a folder + * on yet, and the question that comes before a folder's code may run has none + * either. This spec is the folder AFTER that question: prepared, and trusted. + * + * The model is the scripted one the browser's Designer spec uses, on this + * process. + */ +import { appendFileSync, existsSync, readFileSync } from 'node:fs'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { join } from 'node:path'; + +import { expect, test, type ElectronApplication, type Page } from '@playwright/test'; + +import { createDesignerModelServer } from '../scripts/fake-llm.mjs'; +import { ProjectHarness } from '../tests/projectHarness.js'; +import { closeDesktop, launchDesktop } from './helpers/launch.js'; + +const APP_KEY = 'repair-desk'; +/** Inside the run's own ports when it was given any; else in the product's range. */ +const FIRST_PORT = process.env['E2E_PORT'] === undefined || process.env['E2E_PORT'] === '' ? 4780 : Number(process.env['E2E_PORT']) + 80; + +let project: ProjectHarness; +let model: Server; +let app: ElectronApplication; +let page: Page; +let userDataDir: string; + +test.describe.configure({ mode: 'serial' }); + +// eslint-disable-next-line no-empty-pattern -- Playwright reads fixtures from this pattern, and the hook needs none. +test.beforeAll(async ({}, testInfo) => { + testInfo.setTimeout(300_000); + project = await ProjectHarness.create({}, ['react', 'react-dom']); + // What the model writes into the bare app: the same three files the browser's Designer spec has it write. + const files = { + 'manifest/tables/jobs.json': { + ref: 'jobs', + label: { 'en-US': 'Job' }, + labelPlural: { 'en-US': 'Jobs' }, + keyField: 'title', + columns: [ + { ref: 'id', type: 'int', role: 'pk' }, + { ref: 'title', type: 'text', maxLength: 120, default: 'Untitled', label: { 'en-US': 'Title' } }, + { ref: 'status', type: 'enum', enum: ['open', 'done'], default: 'open', label: { 'en-US': 'Status' } }, + ], + }, + [`manifest/pages/${APP_KEY}-jobs.json`]: { + ref: `${APP_KEY}-jobs`, + template: 'page-crud', + title: { key: `${APP_KEY}.jobs`, fallback: 'Jobs' }, + nav: { group: 'main', icon: 'list-checks', order: 1 }, + bindings: { rows: 'jobs' }, + }, + 'manifest/roles.json': [ + { key: 'staff', name: 'Repair desk staff', permissions: ['table:@jobs:read', 'table:@jobs:create', 'table:@jobs:update', `page:@${APP_KEY}-jobs:view`] }, + ], + }; + model = createDesignerModelServer({ appKey: APP_KEY, appName: 'Repair desk', files: Object.fromEntries(Object.entries(files).map(([file, value]) => [file, JSON.stringify(value, null, 2)])) }); + await new Promise((resolve) => model.listen(0, '127.0.0.1', resolve)); + const modelUrl = `http://localhost:${String((model.address() as AddressInfo).port)}`; + + // The model, where the Designer's own model screen writes it today: the project's `.env` (66-T17 moves keys to the + // app's own store). Not the environment: the app's server child inherits none of the app's. + appendFileSync(join(project.root, '.env'), `\nADMINIUM_AI_OLLAMA_BASE_URL=${modelUrl}\nADMINIUM_AI_MODEL=ollama/fake\n`); + + ({ app, userDataDir } = await launchDesktop({ + env: { + ADMINIUM_DESKTOP_E2E_PROJECT: project.root, + ADMINIUM_DESKTOP_E2E_PORT: String(FIRST_PORT), + ADMINIUM_DISABLE_UPDATES: '1', + }, + })); + page = await app.firstWindow(); +}); + +test.afterAll(async () => { + if (app !== undefined) await closeDesktop(app, userDataDir); + await new Promise((resolve) => (model === undefined ? resolve() : model.close(() => resolve()))); + await project?.close(); +}); + +const mark = (): { pid: number; port: number; mode: string; by: string } => + JSON.parse(readFileSync(join(project.root, '.adminium', 'running.json'), 'utf8')) as { pid: number; port: number; mode: string; by: string }; + +test('opens the folder in the Designer, signed in, on this machine only', async () => { + await expect(page.getByRole('heading', { name: 'What do you want to build?' })).toBeVisible({ timeout: 180_000 }); + // The one-use token signed the project's owner in and left the address. + const url = new URL(page.url()); + expect(url.hash).toBe(''); + expect(url.hostname).toBe('127.0.0.1'); + expect(Number(url.port)).toBeGreaterThanOrEqual(FIRST_PORT); + expect(Number(url.port)).toBeLessThan(FIRST_PORT + 20); + await expect(page.getByRole('button', { name: 'Model: fake' })).toBeVisible(); + expect(await page.evaluate(() => typeof (window as unknown as { adminiumDesktop?: unknown }).adminiumDesktop)).toBe('object'); + + // The folder is served, and says who serves it. + expect(mark()).toMatchObject({ port: Number(url.port), mode: 'design', by: 'desktop' }); + // Its data is its own: the store is in the folder, not in the app's data folder. + expect(existsSync(join(project.root, 'data', 'meta.db'))).toBe(true); + expect(existsSync(join(userDataDir, 'data', 'meta.db'))).toBe(false); + // The classic workspace's own routes are not this server's. + const statuses = await page.evaluate(async () => Promise.all(['/api/v1/desktop/lan-share', '/api/v1/system/info'].map(async (path) => (await fetch(path)).status))); + expect(statuses).toEqual([404, 200]); +}); + +test('a second server on the same folder is refused, and told where the first is', async () => { + const second = project.cli(['start', '--port', String(FIRST_PORT + 19), '--host', '127.0.0.1']); + expect(second.status).not.toBe(0); + expect(`${second.stdout}${second.stderr}`).toContain(`This project is already running (in the Adminium app), on port ${String(mark().port)}.`); +}); + +test('a request builds an app, saves a version and shows it', async () => { + test.setTimeout(240_000); + await page.getByRole('textbox', { name: 'Describe your app' }).fill('A repair desk: jobs and parts.'); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(/\/design\/ds_[0-9a-z]{24}$/); + await expect(page.getByText('The app has a jobs table now. It is applied and saved.')).toBeVisible({ timeout: 120_000 }); + await expect(page.getByText(/^Saved as v1$/)).toBeVisible(); + await expect(page.getByRole('button', { name: 'Version v1: v1' })).toBeVisible(); + + // The preview is a frame, and the window's rule lets a frame be on the app's own origin: the owner's dashboard is in it. + const frame = page.frameLocator('iframe[title="Dashboard"]'); + await expect(frame.getByRole('button', { name: 'Set your password' })).toBeVisible({ timeout: 60_000 }); + // No frame has the app's bridge. + expect(await frame.locator('body').evaluate(() => typeof (window as unknown as { adminiumDesktop?: unknown }).adminiumDesktop)).toBe('undefined'); +}); + +test('quitting in the middle of a turn asks first, and “keep working” keeps the app', async () => { + test.setTimeout(240_000); + // The native question, answered "Keep working" and written down. + await app.evaluate(({ dialog }) => { + const asked: string[] = []; + (globalThis as unknown as { askedOnQuit: string[] }).askedOnQuit = asked; + dialog.showMessageBox = ((...args: unknown[]) => { + const options = (args.length === 2 ? args[1] : args[0]) as { message: string; buttons: string[] }; + asked.push(`${options.message} [${options.buttons.join(' | ')}]`); + return Promise.resolve({ response: 1, checkboxChecked: false }); + }) as typeof dialog.showMessageBox; + }); + + await page.getByRole('textbox', { name: 'Message to Adminium Designer' }).fill('Write them again, exactly as they are.'); + await page.keyboard.press('Enter'); + await expect(page.getByRole('button', { name: 'Stop' })).toBeVisible({ timeout: 30_000 }); + await app.evaluate(({ app: electronApp }) => { + electronApp.quit(); + }); + + await expect + .poll(() => app.evaluate(() => (globalThis as unknown as { askedOnQuit: string[] }).askedOnQuit), { timeout: 15_000 }) + .toEqual(['The Designer is in the middle of a turn. [Quit anyway | Keep working]']); + // Still here, and the turn it was in the middle of ends as it would have. + await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0, { timeout: 120_000 }); + // (The same files written again change nothing, so no second version: the answer itself is what shows the turn ended.) + await expect(page.getByText('The app has a jobs table now. It is applied and saved.')).toHaveCount(2, { timeout: 30_000 }); + await expect(page.getByRole('textbox', { name: 'Message to Adminium Designer' })).toBeEditable(); + expect(mark().by).toBe('desktop'); +}); + +test('with nothing running it quits without a question, and the folder is free again', async () => { + const before = await app.evaluate(() => (globalThis as unknown as { askedOnQuit: string[] }).askedOnQuit.length); + const pid = mark().pid; + const gone = new Promise((resolve) => app.process().once('exit', () => resolve())); + await app.evaluate(({ app: electronApp }) => { + electronApp.quit(); + }); + await Promise.race([gone, new Promise((_, reject) => setTimeout(() => reject(new Error('the app did not quit within 30 s')), 30_000))]); + expect(before).toBe(1); + expect(existsSync(join(project.root, '.adminium', 'running.json'))).toBe(false); + expect(() => process.kill(pid, 0)).toThrow(); +}); From f54638889475dc019ef55f5b1591c68a040b70d3 Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Fri, 9 Oct 2026 21:13:05 +0200 Subject: [PATCH 11/79] docs(desktop): say what the code does without naming plan tasks --- apps/desktop/src/main/project.ts | 2 +- apps/desktop/src/main/window.ts | 2 +- apps/e2e/tests-desktop/desktop-project.spec.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/project.ts b/apps/desktop/src/main/project.ts index 3f615faa3..9f74e7ec1 100644 --- a/apps/desktop/src/main/project.ts +++ b/apps/desktop/src/main/project.ts @@ -83,7 +83,7 @@ export function projectPortRange(env: NodeJS.ProcessEnv, isPackaged: boolean): { return { first, last: first + 19 }; } -/** A native question's words. English here; the app's own pages bring the other languages (66-T15). */ +/** A native question's words. English here, as the menu's and the backup dialogs' words are in main. */ export interface StopBusyWords { title: string; detail: string; diff --git a/apps/desktop/src/main/window.ts b/apps/desktop/src/main/window.ts index ec9e31b4d..25e28ca7c 100644 --- a/apps/desktop/src/main/window.ts +++ b/apps/desktop/src/main/window.ts @@ -173,7 +173,7 @@ export function decideNavigation(target: string, appOrigin: string | null): Navi * * WHY A SECOND RULE AND NOT A WIDER FIRST ONE. The Designer's preview is a frame * on `http://localhost:` that signs in with a 303 on that name. Tried on a - * packed build (66-T12, B5): `will-redirect` fires for subframes too, so the + * packed build: `will-redirect` fires for subframes too, so the * main frame's rule cancelled that redirect and the preview stayed blank. * Adding `localhost` to the main frame's rule instead would let a page written * by a model take the whole window to `localhost` (with a click's activation diff --git a/apps/e2e/tests-desktop/desktop-project.spec.ts b/apps/e2e/tests-desktop/desktop-project.spec.ts index e7a572caf..3eddfac39 100644 --- a/apps/e2e/tests-desktop/desktop-project.spec.ts +++ b/apps/e2e/tests-desktop/desktop-project.spec.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only /** - * A project folder in the desktop app (plan 66, 66-T12): the app's own server + * A project folder in the desktop app: the app's own server * child serves the folder as a terminal would, and the window opens Adminium * Designer on it, signed in. * @@ -67,7 +67,7 @@ test.beforeAll(async ({}, testInfo) => { await new Promise((resolve) => model.listen(0, '127.0.0.1', resolve)); const modelUrl = `http://localhost:${String((model.address() as AddressInfo).port)}`; - // The model, where the Designer's own model screen writes it today: the project's `.env` (66-T17 moves keys to the + // The model, where the Designer's own model screen writes it today: the project's `.env` (keys are to move to the // app's own store). Not the environment: the app's server child inherits none of the app's. appendFileSync(join(project.root, '.env'), `\nADMINIUM_AI_OLLAMA_BASE_URL=${modelUrl}\nADMINIUM_AI_MODEL=ollama/fake\n`); From b82308b1b14b1991d3ef1616c92b1b0421d1f8e0 Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Fri, 9 Oct 2026 21:14:38 +0200 Subject: [PATCH 12/79] fix(desktop): build the preview's origin from the parsed address --- apps/desktop/src/main/index.ts | 2 +- apps/desktop/src/main/window.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index b8360b991..92ff7f632 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -184,7 +184,7 @@ export const CLASSIC_ONLY_SETTING = 'This setting belongs to the classic workspa * browser. Shared: the dashboard's front door. */ export function projectUrl(opts: { port: number; mode: 'design' | 'serve'; token: string | null }): string { - const origin = `http://${LOOPBACK_HOST}:${String(opts.port)}`; + const origin = `http://127.0.0.1:${String(opts.port)}`; if (opts.mode === 'serve') return `${origin}/`; return opts.token === null ? `${origin}/design` : `${origin}/design#designToken=${opts.token}`; } diff --git a/apps/desktop/src/main/window.ts b/apps/desktop/src/main/window.ts index 25e28ca7c..1db138384 100644 --- a/apps/desktop/src/main/window.ts +++ b/apps/desktop/src/main/window.ts @@ -217,7 +217,9 @@ export function projectPartition(root: string): string { export function previewOriginOf(appUrl: string): string | null { const parsed = safeParse(appUrl); if (parsed === null || parsed.protocol !== 'http:' || parsed.hostname !== '127.0.0.1' || parsed.port === '') return null; - return `http://localhost:${parsed.port}`; + // Built from the parsed URL, not written out: the same port, the other name. + parsed.hostname = 'localhost'; + return parsed.origin; } /** `new URL`, as a value rather than an exception. */ From 91917d78ffe0b46926436775421227f13c2e085c Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Fri, 9 Oct 2026 21:19:09 +0200 Subject: [PATCH 13/79] test(desktop): the project entries and the manager at their edges --- apps/desktop/src/main/index.test.ts | 22 ++++++- apps/desktop/src/main/server-manager.test.ts | 51 +++++++++++++++ apps/desktop/src/server/project.test.ts | 69 +++++++++++++++++++- 3 files changed, 140 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/index.test.ts b/apps/desktop/src/main/index.test.ts index ebf78bfd4..21b5abe30 100644 --- a/apps/desktop/src/main/index.test.ts +++ b/apps/desktop/src/main/index.test.ts @@ -1466,7 +1466,7 @@ describe('createDesktopApp opening a project folder', () => { return Promise.resolve(over.confirm ?? true); }, }; - return { h, deps, opts, shown, sessions, confirmed, stops: () => stops, manager, fireExit: (e: ServerExit) => exitListener(e) }; + return { h, deps, opts, shown, sessions, confirmed, 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(); @@ -1566,6 +1566,26 @@ describe('createDesktopApp opening a project folder', () => { 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'] 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(); diff --git a/apps/desktop/src/main/server-manager.test.ts b/apps/desktop/src/main/server-manager.test.ts index a3d817a7f..81a22261a 100644 --- a/apps/desktop/src/main/server-manager.test.ts +++ b/apps/desktop/src/main/server-manager.test.ts @@ -841,6 +841,20 @@ describe('ServerManager serving a project', () => { 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'); + 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('a server that is not up is not asked', async () => { const h = project(); await expect(h.manager.busy()).resolves.toBeNull(); @@ -884,6 +898,43 @@ describe('ServerManager serving a project', () => { 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/server/project.test.ts b/apps/desktop/src/server/project.test.ts index 8dda95fc1..a49830255 100644 --- a/apps/desktop/src/server/project.test.ts +++ b/apps/desktop/src/server/project.test.ts @@ -15,7 +15,7 @@ import { parseDesktopProjectEnv, STRIPPED_INHERITED_ENV_KEYS, } from './env.js'; -import { runProjectEntry, type ParentPortLike } from './index.js'; +import { resolveParentPort, runProjectEntry, runServerEntry, type ParentPortLike } from './index.js'; import { parseParentMessage, parseServerMessage } from './protocol.js'; const TOKEN = 'ab'.repeat(32); @@ -224,3 +224,70 @@ describe('runProjectEntry', () => { expect(posted).toEqual([expect.objectContaining({ type: 'error', stage: 'env' })]); }); }); + +describe('the entries, at their edges', () => { + it('a child that was not forked says so on its log instead of posting', async () => { + const lines: string[] = []; + const exit = vi.fn(); + await runProjectEntry({ parentPort: null, env: block(), start: vi.fn(async () => started()), chdir: vi.fn(), exit, onLog: (line) => lines.push(line) }); + expect(lines.join('\n')).toContain('no parentPort; {"type":"ready"'); + expect(exit).not.toHaveBeenCalled(); + }); + + it('a start that throws something that is not an Error is still told, as text', async () => { + const { port, posted } = fakePort(); + await runProjectEntry({ parentPort: port, env: block(), start: vi.fn(async () => Promise.reject('no disk')), chdir: vi.fn(), exit: vi.fn(), onLog: vi.fn() }); + expect(posted).toEqual([{ type: 'error', stage: 'project', message: 'no disk' }]); + }); + + it('a message that is not one of main’s is ignored, and said so', async () => { + const { port, send } = fakePort(); + const lines: string[] = []; + const handle = started(); + await runProjectEntry({ parentPort: port, env: block(), start: vi.fn(async () => handle), chdir: vi.fn(), exit: vi.fn(), onLog: (line) => lines.push(line) }); + send({ type: 'eval', code: 'process.exit()' }); + expect(handle.close).not.toHaveBeenCalled(); + expect(lines.join('\n')).toContain('ignoring unrecognized parent message'); + }); + + it('a close that fails still ends the child, with a failing exit', async () => { + const { port, send } = fakePort(); + const exit = vi.fn(); + await runProjectEntry({ parentPort: port, env: block(), start: vi.fn(async () => started({ close: vi.fn(async () => Promise.reject(new Error('busy file'))) })), chdir: vi.fn(), exit, onLog: vi.fn() }); + send({ type: 'shutdown' }); + await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1)); + }); + + it('the classic child refuses an environment that is not its own, at the first step', async () => { + const { port, posted } = fakePort(); + const exit = vi.fn(); + const lines: string[] = []; + expect(await runServerEntry({ parentPort: port, env: { ADMINIUM_RUNTIME: 'desktop' }, exit, onLog: (line) => lines.push(line) })).toBeNull(); + expect(posted).toEqual([expect.objectContaining({ type: 'error', stage: 'env' })]); + expect(exit).toHaveBeenCalledWith(1); + expect(lines.join('\n')).toContain('boot failed at stage "env"'); + // And with no parent it says the same on its log. + const alone: string[] = []; + await runServerEntry({ parentPort: null, env: {}, exit: vi.fn(), onLog: (line) => alone.push(line) }); + expect(alone.join('\n')).toContain('no parentPort; {"type":"error"'); + }); + + it('starts the port when it has a start, and skips a name inherited with no value', async () => { + const { port } = fakePort(); + const startPort = vi.fn(); + await runProjectEntry({ parentPort: { ...port, start: startPort }, env: block({ inherit: { EMPTY: undefined, LANG: 'C' } }), start: vi.fn(async () => started()), chdir: vi.fn(), exit: vi.fn(), onLog: vi.fn() }); + expect(startPort).toHaveBeenCalledTimes(1); + expect(block({ inherit: { EMPTY: undefined } })).not.toHaveProperty('EMPTY'); + }); + + it('finds the parent’s port where Electron puts it, or nothing under plain Node', () => { + const parentPort = fakePort().port; + expect(resolveParentPort({ parentPort } as unknown as NodeJS.Process)).toBe(parentPort); + expect(resolveParentPort({} as NodeJS.Process)).toBeNull(); + }); + + it('parseDesktopProjectEnv names everything an empty environment lacks', () => { + expect(() => parseDesktopProjectEnv({})).toThrow(/ADMINIUM_DESKTOP_PROJECT[\s\S]*ADMINIUM_DESKTOP_PROJECT_MODE[\s\S]*ADMINIUM_PORT[\s\S]*ADMINIUM_HOST[\s\S]*ADMINIUM_BOOT_TOKEN[\s\S]*ADMINIUM_RUNTIME/); + expect(parseDesktopProjectEnv({ ...block(), ADMINIUM_LOG_LEVEL: 'loud' }).logLevel).toBeUndefined(); + }); +}); From 09752662b958ed0ff3388a50890416acc7f2a94b Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Fri, 9 Oct 2026 21:43:54 +0200 Subject: [PATCH 14/79] feat(server): one module says how a program is started, here or in the app --- apps/server/src/cli/commands/app/new.ts | 11 +- apps/server/src/cli/commands/design.ts | 8 +- apps/server/src/cli/commands/dev.ts | 7 +- apps/server/src/cli/commands/home.ts | 5 +- apps/server/src/cli/commands/new.ts | 21 ++- apps/server/src/cli/runtime.ts | 6 +- apps/server/src/designer/ask-needs.ts | 13 +- apps/server/src/designer/tools.ts | 8 +- apps/server/src/project/apps/own-build.ts | 7 +- apps/server/src/project/programs.ts | 196 ++++++++++++++++++++++ apps/server/test/programs-source.test.ts | 95 +++++++++++ apps/server/test/programs.test.ts | 165 ++++++++++++++++++ 12 files changed, 514 insertions(+), 28 deletions(-) create mode 100644 apps/server/src/project/programs.ts create mode 100644 apps/server/test/programs-source.test.ts create mode 100644 apps/server/test/programs.test.ts diff --git a/apps/server/src/cli/commands/app/new.ts b/apps/server/src/cli/commands/app/new.ts index 16cd48165..b8b8f4909 100644 --- a/apps/server/src/cli/commands/app/new.ts +++ b/apps/server/src/cli/commands/app/new.ts @@ -6,7 +6,7 @@ import { checkApp } from '../../../project/apps/check-app.js'; import { appPath, type AppSide } from '../../../project/apps/read-app.js'; import { addSideDependencies, appKeyProblem, nameFromKey, scaffoldApp } from '../../../project/apps/scaffold-app.js'; -import { installCommand, projectPackageManager } from '../../../project/package-manager.js'; +import { packageManagerProgram } from '../../../project/programs.js'; import { APP_VERSION } from '../../../version.js'; import { parseFlags, type FlagSpecs } from '../../args.js'; import type { Command } from '../../command.js'; @@ -52,12 +52,17 @@ export const appNewCommand: Command = { let installFailed = false; if (added.length > 0) { io.out(`Added to package.json: ${added.join(', ')}.`); - const { command, args } = installCommand(projectPackageManager(project.root, deps.env)); + const program = packageManagerProgram(project.root, deps.env); + const launch = program.launch(['install']); + // What is said is the manager's own name, whatever file starts it. + const command = program.manager; + const args = ['install']; + if (program.note !== null) io.out(program.note); if (values['no-install'] === true) { io.out(`Install them before building: ${command} ${args.join(' ')}`); } else { io.out(`Running ${command} ${args.join(' ')} …`); - const result = (deps.runProcess ?? defaultRunProcess)(command, args, { cwd: project.root, inherit: true }); + const result = (deps.runProcess ?? defaultRunProcess)(launch.command, launch.args, { cwd: project.root, inherit: true, ...(Object.keys(launch.env).length === 0 ? {} : { env: launch.env }) }); if (result.status !== 0) { installFailed = true; io.err(`\`${command} ${args.join(' ')}\` failed. The app's files are in place; run it again before building.`); diff --git a/apps/server/src/cli/commands/design.ts b/apps/server/src/cli/commands/design.ts index 90be126db..1c449357f 100644 --- a/apps/server/src/cli/commands/design.ts +++ b/apps/server/src/cli/commands/design.ts @@ -27,7 +27,7 @@ import { runChild } from '../../designer/child.js'; import { DESIGNER_REACT_VERSION } from '../../designer/tools.js'; import { PUBLIC_CLIENT_PACKAGE } from '../../project/apps/scaffold-app.js'; import { findProject } from '../../project/locate.js'; -import { projectPackageManager } from '../../project/package-manager.js'; +import { addPackagesArgs, packageManagerProgram } from '../../project/programs.js'; import { APP_VERSION } from '../../version.js'; import { boolFlag, numberFlag, parseFlags, stringFlag } from '../args.js'; import type { Command } from '../command.js'; @@ -43,10 +43,10 @@ import { localOwnerStart, runStart } from './start.js'; * for them on one card when a side is first added. */ async function addScreenPackages(root: string, env: NodeJS.ProcessEnv): Promise { - const manager = projectPackageManager(root, env); - const exact = manager === 'npm' || manager === 'pnpm' ? '--save-exact' : '--exact'; + const program = packageManagerProgram(root, env); const specs = [`react@${DESIGNER_REACT_VERSION}`, `react-dom@${DESIGNER_REACT_VERSION}`, `${PUBLIC_CLIENT_PACKAGE}@${APP_VERSION}`]; - await runChild(manager, [manager === 'npm' ? 'install' : 'add', ...specs, '--ignore-scripts', exact], { cwd: root, timeoutMs: 180_000, signal: new AbortController().signal }).catch(() => undefined); + const launch = program.launch(addPackagesArgs(program.manager, specs)); + await runChild(launch.command, launch.args, { cwd: root, timeoutMs: 180_000, signal: new AbortController().signal, env: { ...process.env, ...launch.env } }).catch(() => undefined); } /** The ports `design` tries, in order, when none is named. */ diff --git a/apps/server/src/cli/commands/dev.ts b/apps/server/src/cli/commands/dev.ts index e0b0d9f7b..cbd8efaa8 100644 --- a/apps/server/src/cli/commands/dev.ts +++ b/apps/server/src/cli/commands/dev.ts @@ -7,6 +7,8 @@ import { spawn } from 'node:child_process'; import { existsSync, readdirSync, statSync } from 'node:fs'; import { extname, join, resolve } from 'node:path'; + +import { nodeProgram } from '../../project/programs.js'; import { fileURLToPath } from 'node:url'; import { @@ -187,9 +189,10 @@ export const devCommand: Command = { spawnServer: (): DevChild => { const args = ['start', ...forwarded]; if (deps.spawnDevServer !== undefined) return deps.spawnDevServer(args, { cwd: project.root, env }); - const child = spawn(process.execPath, [CLI_ENTRY, ...args], { + const node = nodeProgram([CLI_ENTRY, ...args], env); + const child = spawn(node.command, node.args, { cwd: project.root, - env, + env: { ...env, ...node.env }, stdio: 'inherit', }); return { diff --git a/apps/server/src/cli/commands/home.ts b/apps/server/src/cli/commands/home.ts index bbeaa0640..ff8990e18 100644 --- a/apps/server/src/cli/commands/home.ts +++ b/apps/server/src/cli/commands/home.ts @@ -12,7 +12,8 @@ import { existsSync, readFileSync } from 'node:fs'; import { join, relative } from 'node:path'; import { findProject } from '../../project/locate.js'; -import { detectPackageManager, runScript } from '../../project/package-manager.js'; +import { runScript } from '../../project/package-manager.js'; +import { namedPackageManager } from '../../project/programs.js'; import type { Command } from '../command.js'; import { CliError, EXIT_OK } from '../exit.js'; import { designCommand } from './design.js'; @@ -50,7 +51,7 @@ export const homeCommand: Command = { const { io, deps, argv } = ctx; const project = findProject(deps.cwd, deps.env); if (project !== null) { - const pm = detectPackageManager(deps.env); + const pm = namedPackageManager(deps.env); const scripts = adminiumScripts(project.root); const where = relative(deps.cwd, project.root) || '.'; io.out(`This is an Adminium project (${where}).`); diff --git a/apps/server/src/cli/commands/new.ts b/apps/server/src/cli/commands/new.ts index f5157e176..3103ed8d1 100644 --- a/apps/server/src/cli/commands/new.ts +++ b/apps/server/src/cli/commands/new.ts @@ -29,13 +29,13 @@ import { diskFileStore } from '../../project/file-store.js'; import { configFileIn } from '../../project/locate.js'; import { pullProject } from '../../project/reconcile.js'; import { - detectPackageManager, installCommand, isPackageManager, PACKAGE_MANAGERS, runScript, type PackageManager, } from '../../project/package-manager.js'; +import { gitProgram, namedPackageManager, packageManagerProgram } from '../../project/programs.js'; import { createSampleDatabase } from '../../project/sample.js'; import { scaffoldProject } from '../../project/scaffold.js'; import { APP_VERSION } from '../../version.js'; @@ -161,11 +161,13 @@ async function adoptInstance(root: string, secret: string, deps: CliDeps, io: Cl } } -function initGit(root: string, run: RunProcess, io: CliIo): void { - if (run('git', ['--version'], { cwd: root }).status !== 0) return; - const inside = run('git', ['rev-parse', '--is-inside-work-tree'], { cwd: root }); +function initGit(root: string, run: RunProcess, io: CliIo, git: string | null): void { + // No git to use (the desktop app found none): the project is made without a repository, as with --no-git. + if (git === null) return; + if (run(git, ['--version'], { cwd: root }).status !== 0) return; + const inside = run(git, ['rev-parse', '--is-inside-work-tree'], { cwd: root }); if (inside.status === 0 && inside.stdout.trim() === 'true') return; - if (run('git', ['init', '--quiet'], { cwd: root }).status === 0) { + if (run(git, ['init', '--quiet'], { cwd: root }).status === 0) { io.out('Started a git repository.'); } } @@ -249,7 +251,9 @@ export const newCommand: Command = { newCommand.name, ); } - const packageManager: PackageManager = pmFlag ?? detectPackageManager(deps.env); + // In the desktop app there is one manager, the npm it carries, whatever was asked for. + const program = packageManagerProgram(deps.cwd, deps.env, pmFlag ?? namedPackageManager(deps.env)); + const packageManager: PackageManager = program.manager; const databaseFlag = stringFlag(values.database); const sampleFlag = boolFlag(values.sample); @@ -373,13 +377,14 @@ export const newCommand: Command = { await adoptInstance(root, secret ?? dotenv?.ADMINIUM_SECRET ?? '', deps, io); } - if (!boolFlag(values['no-git'])) initGit(root, run, io); + if (!boolFlag(values['no-git'])) initGit(root, run, io, gitProgram(deps.env)); let installed: boolean | null = null; if (!boolFlag(values['no-install'])) { const { command, args } = installCommand(packageManager); + const launch = program.launch(args); io.out(`Installing dependencies with ${command}…`); - installed = run(command, args, { cwd: root, inherit: true }).status === 0; + installed = run(launch.command, launch.args, { cwd: root, inherit: true, ...(Object.keys(launch.env).length === 0 ? {} : { env: launch.env }) }).status === 0; if (!installed) io.err(`\`${command} ${args.join(' ')}\` failed. Fix the problem above and run it again.`); } diff --git a/apps/server/src/cli/runtime.ts b/apps/server/src/cli/runtime.ts index 0ff7e95a0..c3c840322 100644 --- a/apps/server/src/cli/runtime.ts +++ b/apps/server/src/cli/runtime.ts @@ -37,6 +37,7 @@ import { createConnectionStatsCollector } from '../llm/stats-collector.js'; import type { CollectRunStats } from '../llm/prompt-service.js'; import type { OnMetaRelocated } from '../meta/relocate.js'; import { openMetaStore, type MetaStoreHandle } from '../meta/store.js'; +import { mayOpenBrowser } from '../project/programs.js'; import type { ProjectServerOptions } from '../project/service.js'; import { loadAllowedVocabularies } from './allowlist.js'; import { CliError } from './exit.js'; @@ -506,7 +507,7 @@ export function installSignalShutdown( export type RunProcess = ( command: string, args: readonly string[], - opts: { cwd: string; inherit?: boolean }, + opts: { cwd: string; inherit?: boolean; env?: Readonly> }, ) => { status: number | null; stdout: string }; export const runProcess: RunProcess = (command, args, opts) => { @@ -514,6 +515,7 @@ export const runProcess: RunProcess = (command, args, opts) => { cwd: opts.cwd, stdio: opts.inherit === true ? 'inherit' : ['ignore', 'pipe', 'pipe'], encoding: 'utf8', + ...(opts.env === undefined ? {} : { env: { ...process.env, ...opts.env } }), // npm, pnpm and yarn are `.cmd` shims on Windows, which only a shell runs. // The arguments are fixed words, never user input. shell: process.platform === 'win32', @@ -582,6 +584,8 @@ export const openBrowser: OpenBrowser = async (url) => { // Only ever called with a URL this process just bound, but the check is cheap // and keeps a future caller from turning this into a shell-injection sink. if (!/^https?:\/\//.test(url)) return false; + // Inside the desktop app the window is the browser: nothing is opened beside it. + if (!mayOpenBrowser()) return false; try { const { spawn } = await import('node:child_process'); const { command, args } = browserCommand(process.platform); diff --git a/apps/server/src/designer/ask-needs.ts b/apps/server/src/designer/ask-needs.ts index f44c78b68..a08e9ff28 100644 --- a/apps/server/src/designer/ask-needs.ts +++ b/apps/server/src/designer/ask-needs.ts @@ -12,7 +12,7 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { applyLook, missingFonts, readLook, type Look } from '../project/apps/look.js'; -import { projectPackageManager } from '../project/package-manager.js'; +import { addPackagesArgs, DESKTOP_PROGRAMS_ENV, packageManagerProgram } from '../project/programs.js'; import { APPS_DIR, type AppSide } from '../project/apps/read-app.js'; import { PUBLIC_CLIENT_PACKAGE } from '../project/apps/scaffold-app.js'; import { runChild } from './child.js'; @@ -49,10 +49,13 @@ export type NeedsAsker = (ctx: Pick, a /** Add packages to the project, exact versions, no install scripts. */ export function installer(root: string): NonNullable { return async (specs, signal) => { - const manager = projectPackageManager(root, {}); - const exact = manager === 'npm' || manager === 'pnpm' ? '--save-exact' : '--exact'; - const args = [manager === 'npm' ? 'install' : 'add', ...specs.map((spec) => `${spec.name}@${spec.version}`), '--ignore-scripts', exact]; - const result = await runChild(manager, args, { cwd: root, timeoutMs: INSTALL_TIMEOUT_MS, signal }); + // The folder's own manager by its lockfile, else npm (never the one that happens to run this server: only the + // desktop app's own name is read from the environment). In the desktop app, the npm it carries. + const program = packageManagerProgram(root, { [DESKTOP_PROGRAMS_ENV]: process.env[DESKTOP_PROGRAMS_ENV] }); + const manager = program.manager; + const args = addPackagesArgs(manager, specs.map((spec) => `${spec.name}@${spec.version}`)); + const launch = program.launch(args); + const result = await runChild(launch.command, launch.args, { cwd: root, timeoutMs: INSTALL_TIMEOUT_MS, signal, env: { ...process.env, ...launch.env } }); return result.code === 0 ? null : `${manager} ${args.join(' ')} failed:\n${result.output.split('\n').slice(-30).join('\n')}`; }; } diff --git a/apps/server/src/designer/tools.ts b/apps/server/src/designer/tools.ts index 1f5abebe2..0e75b389c 100644 --- a/apps/server/src/designer/tools.ts +++ b/apps/server/src/designer/tools.ts @@ -30,6 +30,8 @@ import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { dirname, join, relative, sep } from 'node:path'; +import { nodeProgram } from '../project/programs.js'; + import { checkApp, type AppFinding } from '../project/apps/check-app.js'; import { builtInStylesDir, findDesignSkill, listDesignSkills, skillGuidance, styleForBusiness, styleNamed, stylesToOffer, type DesignSkill } from '../project/apps/design-skills.js'; import { applyLook, cleanLook, directionFromWords, lookInUse, mentionsLook, missingFonts, ownFontPatch, readLook, resolveLook, sidesWithScreens, type Look } from '../project/apps/look.js'; @@ -932,12 +934,14 @@ export function createDesignerTools(deps: ToolsDeps, appKey: string): DesignerTo if (answer.type !== 'question' || answer.text !== RUN_THEM) return text('The person said not to run the tests. Go on without them.', 'Tests not run'); testsAllowed = true; } - const result = await runChild(process.execPath, ['--test', ...files.map((name) => `apps/${appKey}/tests/${name}`)], { + // Node, wherever this server runs: inside the desktop app its own path is a helper that cannot be Node. + const node = nodeProgram(['--test', ...files.map((name) => `apps/${appKey}/tests/${name}`)]); + const result = await runChild(node.command, node.args, { cwd: deps.root, timeoutMs: TESTS_TIMEOUT_MS, signal: ctx.signal, // The starter's own test calls `adminium app check` through the CLI; check_app is that check, run here. - env: { ...process.env, ADMINIUM_SKIP_CLI: '1' }, + env: { ...process.env, ...node.env, ADMINIUM_SKIP_CLI: '1' }, }); const tail = result.output.split('\n').slice(-200).join('\n'); if (result.timedOut) return refused(`The tests ran longer than ${String(TESTS_TIMEOUT_MS / 1000)} s and were stopped.\n${tail}`, 'Tests took too long'); diff --git a/apps/server/src/project/apps/own-build.ts b/apps/server/src/project/apps/own-build.ts index 26c05ae78..f555f23f1 100644 --- a/apps/server/src/project/apps/own-build.ts +++ b/apps/server/src/project/apps/own-build.ts @@ -35,6 +35,8 @@ import { createHash } from 'node:crypto'; import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { dirname, join, posix } from 'node:path'; +import { buildLinePath } from '../programs.js'; + import { BUILD_DIR } from '../build-shared.js'; import { APPS_DIR, SIDES, appDir, type AppSide } from './read-app.js'; @@ -305,7 +307,10 @@ export const runLine: StepRunner = (line, cwd, signal, env) => let output = ''; // Its own process group where there are groups: the shell's children (npm, Vite) end with it. const grouped = process.platform !== 'win32'; - const child = spawn(line, { cwd, env: { ...buildEnvironment(process.env), ...env }, shell: true, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, detached: grouped }); + const base = buildEnvironment(process.env); + // In the desktop app `node`, `npm` and `npx` on a build line are the app's own: their folder comes first. + const path = buildLinePath(base['PATH']); + const child = spawn(line, { cwd, env: { ...base, ...(path === undefined ? {} : { PATH: path }), ...env }, shell: true, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, detached: grouped }); const keep = (chunk: Buffer): void => { output = `${output}${chunk.toString('utf8')}`.slice(-MAX_OUTPUT); }; diff --git a/apps/server/src/project/programs.ts b/apps/server/src/project/programs.ts new file mode 100644 index 000000000..837649556 --- /dev/null +++ b/apps/server/src/project/programs.ts @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** + * How this server starts another program: Node, the package manager, git, a + * browser, and the PATH an app's own build line runs with. + * + * ONE PLACE, because there are two answers. On a terminal the server is a Node + * process among the person's own tools: `process.execPath` is Node, `npm` is on + * the PATH, and nothing here changes what was always done. Inside the desktop + * app neither is true. The server runs in a helper process whose own path + * cannot run as Node, and the person may have no Node, no npm and no git at + * all. There the app says where its own are, in `ADMINIUM_DESKTOP_PROGRAMS`: + * its main program (which runs as Node when asked to), the npm it carries, a + * folder of `node`/`npm`/`npx` stand-ins for an app's own build lines, and the + * git it found. + * + * Nothing else in the server names `npm`, `pnpm`, `node` or + * `process.execPath` to start a child: `test/programs-source.test.ts` reads + * the sources and fails on a new one. + */ +import { existsSync } from 'node:fs'; +import { delimiter, isAbsolute, join } from 'node:path'; + +import { detectPackageManager, projectPackageManager, type PackageManager } from './package-manager.js'; + +/** The name the desktop app's main process sets. A JSON value; see {@link DesktopPrograms}. */ +export const DESKTOP_PROGRAMS_ENV = 'ADMINIUM_DESKTOP_PROGRAMS'; + +/** The one registry the carried npm installs from unless the folder's own `.npmrc` names another. */ +export const NPM_REGISTRY = 'https://registry.npmjs.org/'; + +export interface DesktopPrograms { + /** The app's main program. Run with `ELECTRON_RUN_AS_NODE=1` it is Node. Never the helper this server may run in. */ + binary: string; + /** The folder of the npm the app carries (it holds `bin/npm-cli.js`). */ + npm: string; + /** A folder holding `node`, `npm` and `npx`, each starting {@link binary} as Node. For an app's own build lines only. */ + shims: string; + /** A git that works here, or `null`: versions are off and nothing asks for one. */ + git: string | null; + /** npm's user and global settings: two files the app owns, so a person's own `~/.npmrc` decides nothing. */ + npmUserConfig: string; + npmGlobalConfig: string; + /** npm's cache, in the app's own folder. */ + npmCache: string; +} + +/** What it takes to start a program: the file, its arguments, and what to add to the child's environment. */ +export interface Launch { + command: string; + args: string[]; + env: Record; +} + +/** + * The desktop app's programs, or `null` on a terminal. + * + * A value that is there but wrong THROWS. Falling back to the terminal's + * answer would start `npm` by name on a machine the app promised needs none, + * or the helper as if it were Node; both fail later, in words nobody could + * act on. + */ +export function desktopPrograms(env: Readonly> = process.env): DesktopPrograms | null { + const raw = env[DESKTOP_PROGRAMS_ENV]; + if (raw === undefined || raw === '') return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error(`${DESKTOP_PROGRAMS_ENV} is not JSON.`); + } + const value = (typeof parsed === 'object' && parsed !== null ? parsed : {}) as Record; + const path = (name: string): string => { + const found = value[name]; + if (typeof found !== 'string' || !isAbsolute(found)) throw new Error(`${DESKTOP_PROGRAMS_ENV}: "${name}" must be an absolute path.`); + return found; + }; + const git = value['git']; + if (git !== null && (typeof git !== 'string' || !isAbsolute(git))) throw new Error(`${DESKTOP_PROGRAMS_ENV}: "git" must be an absolute path or null.`); + return { + binary: path('binary'), + npm: path('npm'), + shims: path('shims'), + git, + npmUserConfig: path('npmUserConfig'), + npmGlobalConfig: path('npmGlobalConfig'), + npmCache: path('npmCache'), + }; +} + +/** Node, with arguments. */ +export function nodeProgram(args: readonly string[], env: Readonly> = process.env): Launch { + const desktop = desktopPrograms(env); + if (desktop === null) return { command: process.execPath, args: [...args], env: {} }; + return { command: desktop.binary, args: [...args], env: { ELECTRON_RUN_AS_NODE: '1' } }; +} + +/** The lockfile of a package manager the desktop app does not carry, when the folder has one. */ +function otherManagersLockfile(root: string): { manager: Exclude; file: string } | null { + for (const [manager, file] of [ + ['pnpm', 'pnpm-lock.yaml'], + ['yarn', 'yarn.lock'], + ['bun', 'bun.lock'], + ['bun', 'bun.lockb'], + ] as const) { + if (existsSync(join(root, file))) return { manager, file }; + } + return null; +} + +export interface PackageManagerProgram { + /** The manager that will run: the folder's own on a terminal, always npm in the desktop app. */ + manager: PackageManager; + /** Start it with these arguments (the verb and what follows). */ + launch(args: readonly string[]): Launch; + /** + * In the desktop app, for a folder another manager made: what the person is + * told. The app never starts a manager it does not carry (yarn can run a + * file the folder names), and it leaves that manager's lockfile as it is. + */ + note: string | null; +} + +/** + * The package manager for the project in `root`. + * + * On a terminal: the one the folder's lockfile names, else the one running + * this command (`fallback`, where the caller already knows it). In the desktop + * app: the carried npm, started as "the app's program, with npm's own entry as + * its first argument", so no `.cmd` is involved and `shell: false` holds on + * Windows. It runs with settings of the app's own and one registry. + */ +export function packageManagerProgram( + root: string, + env: Readonly> = process.env, + fallback?: PackageManager, +): PackageManagerProgram { + const desktop = desktopPrograms(env); + if (desktop === null) { + const manager = fallback ?? projectPackageManager(root, env); + return { manager, launch: (args) => ({ command: manager, args: [...args], env: {} }), note: null }; + } + const other = otherManagersLockfile(root); + return { + manager: 'npm', + launch: (args) => ({ + command: desktop.binary, + args: [join(desktop.npm, 'bin', 'npm-cli.js'), ...args], + env: { + ELECTRON_RUN_AS_NODE: '1', + NPM_CONFIG_USERCONFIG: desktop.npmUserConfig, + NPM_CONFIG_GLOBALCONFIG: desktop.npmGlobalConfig, + NPM_CONFIG_CACHE: desktop.npmCache, + NPM_CONFIG_REGISTRY: NPM_REGISTRY, + NPM_CONFIG_UPDATE_NOTIFIER: 'false', + NPM_CONFIG_FUND: 'false', + // A package's own install scripts never run: the same rule the terminal's installs already pass as a flag. + NPM_CONFIG_IGNORE_SCRIPTS: 'true', + }, + }), + note: other === null ? null : `This project uses ${other.manager}. The app installs with npm; your ${other.manager} file (${other.file}) is left as it is.`, + }; +} + +/** The arguments that add exact versions of packages with no install scripts, in `manager`'s own words. */ +export function addPackagesArgs(manager: PackageManager, specs: readonly string[]): string[] { + const exact = manager === 'npm' || manager === 'pnpm' ? '--save-exact' : '--exact'; + return [manager === 'npm' ? 'install' : 'add', ...specs, '--ignore-scripts', exact]; +} + +/** The manager running this command, for words only (a hint that says "npm install"). In the desktop app it is npm. */ +export function namedPackageManager(env: Readonly> = process.env): PackageManager { + return desktopPrograms(env) === null ? detectPackageManager(env) : 'npm'; +} + +/** git, or `null` where there is none to use. On a terminal it is whatever `git` the PATH finds. */ +export function gitProgram(env: Readonly> = process.env): string | null { + const desktop = desktopPrograms(env); + return desktop === null ? 'git' : desktop.git; +} + +/** + * The PATH an app's own build line runs with. A build line is a shell line the + * app's author wrote (`npm run build`, `node scripts/x.js`): in the desktop app + * the stand-ins' folder comes first, so those names mean the app's own Node and + * npm. It is the PATH of a build line's child and of nothing else. + */ +export function buildLinePath(path: string | undefined, env: Readonly> = process.env): string | undefined { + const desktop = desktopPrograms(env); + if (desktop === null) return path; + return path === undefined || path === '' ? desktop.shims : `${desktop.shims}${delimiter}${path}`; +} + +/** Whether this server may open a page in the person's browser. Never in the desktop app: its window is the browser. */ +export function mayOpenBrowser(env: Readonly> = process.env): boolean { + return desktopPrograms(env) === null; +} diff --git a/apps/server/test/programs-source.test.ts b/apps/server/test/programs-source.test.ts new file mode 100644 index 000000000..daff442eb --- /dev/null +++ b/apps/server/test/programs-source.test.ts @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** + * The sentences that keep the desktop app working, read from the sources. + * + * 1. One module says how a program is started (`project/programs.ts`). A new + * `spawn('npm', …)` or `process.execPath` elsewhere works on every terminal + * and fails only inside the desktop app, where no test of that file runs. + * 2. Nothing copies a folder out of the server's own package, and no child is + * given a working folder inside it: inside the desktop app that package is + * an archive, where `fs.cp` throws and a `cwd` does not exist. + */ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const SRC = join(import.meta.dirname, '..', 'src'); + +function sources(dir: string = SRC): string[] { + const found: string[] = []; + for (const name of readdirSync(dir)) { + const file = join(dir, name); + if (statSync(file).isDirectory()) found.push(...sources(file)); + else if (name.endsWith('.ts') && !name.endsWith('.test.ts') && !name.endsWith('.d.ts')) found.push(file); + } + return found; +} + +const files = sources().map((file) => ({ name: relative(SRC, file).split(sep).join('/'), text: readFileSync(file, 'utf8') })); +/** The text with its comments taken out, so a sentence about a name is not a use of it. */ +const code = (text: string): string => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1'); +const users = (pattern: RegExp): string[] => files.filter((file) => pattern.test(code(file.text))).map((file) => file.name).sort(); + +describe('who may start a program', () => { + it('finds the sources', () => { + expect(files.length).toBeGreaterThan(400); + expect(files.map((file) => file.name)).toContain('project/programs.ts'); + }); + + it('only the resolver names this process’s own path', () => { + expect(users(/process\.execPath/)).toEqual([ + // The CLI's own tests of a child, where the harness is always Node. + 'project/programs.ts', + ]); + }); + + it('only the resolver and the words-only helpers pick a package manager', () => { + expect(users(/\b(projectPackageManager|detectPackageManager)\(/)).toEqual([ + // Where they are defined. + 'project/package-manager.ts', + // The resolver itself. + 'project/programs.ts', + ]); + }); + + it('no file starts a package manager, Node or git by a name written out', () => { + // `spawn('npm'`, `runChild('pnpm'`, `execFile('git'`, `run('git'` … with the name as a literal. + expect(users(/\b(spawn|spawnSync|execFile|execFileSync|runChild|run|runProcess)\(\s*['"`](npm|npx|pnpm|yarn|bun|node|git)['"`]/)).toEqual([]); + }); + + it('every file that starts a child is known, so a new one is read before it lands', () => { + expect(users(/\b(spawn|spawnSync|execFile|execFileSync|fork)\(/)).toEqual([ + // The system's opener for a page, refused inside the desktop app (`mayOpenBrowser`); `runProcess`, given a resolved program. + 'cli/commands/dev.ts', + 'cli/runtime.ts', + // `runChild`: given a resolved program by its callers. + 'designer/child.ts', + // git, by the path `createVersions` is handed. + 'designer/versions.ts', + // An app's own build line, through the shell, with the resolver's PATH; and `taskkill` on Windows. + 'project/apps/own-build.ts', + ]); + }); +}); + +const COPIERS = ['project/adopt.ts', 'project/apps/own-build.ts', 'project/apps/pack-app.ts', 'project/apps/side-build.ts']; + +describe('what is read from the server’s own package', () => { + it('only a project’s own folders are copied', () => { + expect(users(/\b(cpSync|cp)\(|promises\.cp\b|\bcp\b\s*[,}]\s*from 'node:fs/).filter((name) => !COPIERS.includes(name))).toEqual([]); + // The four that copy do it between a project's folders (an instance's data, an app's logo and public files, a build's + // output, an app's sides and seeds into a pack). + for (const name of COPIERS) { + const text = code(files.find((file) => file.name === name)?.text ?? ''); + for (const call of text.match(/cpSync\([^)]*\)/g) ?? []) { + expect(call, `${name}: ${call}`).not.toMatch(/import\.meta|PACKAGE_ROOT|TEMPLATES|__dirname/); + } + } + }); + + it('no child’s working folder is built from the package’s own location', () => { + const offenders = files.filter((file) => /cwd:\s*[^,}\n]*(import\.meta\.(dirname|url)|PACKAGE_ROOT|__dirname)/.test(code(file.text))).map((file) => file.name); + expect(offenders).toEqual([]); + }); +}); diff --git a/apps/server/test/programs.test.ts b/apps/server/test/programs.test.ts new file mode 100644 index 000000000..2179dfe69 --- /dev/null +++ b/apps/server/test/programs.test.ts @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** + * How the server starts another program, on a terminal and inside the desktop + * app: each row of the table, in both columns. + */ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { delimiter, join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + addPackagesArgs, + buildLinePath, + DESKTOP_PROGRAMS_ENV, + desktopPrograms, + gitProgram, + mayOpenBrowser, + namedPackageManager, + nodeProgram, + NPM_REGISTRY, + packageManagerProgram, +} from '../src/project/programs.js'; + +const APP = { + binary: '/Applications/Adminium.app/Contents/MacOS/Adminium', + npm: '/Applications/Adminium.app/Contents/Resources/npm', + shims: '/Users/a/Library/Application Support/Adminium/shims', + git: '/usr/bin/git', + npmUserConfig: '/Users/a/Library/Application Support/Adminium/npm/user.npmrc', + npmGlobalConfig: '/Users/a/Library/Application Support/Adminium/npm/global.npmrc', + npmCache: '/Users/a/Library/Application Support/Adminium/npm/cache', +}; +const desktop = (over: Record = {}): Record => ({ [DESKTOP_PROGRAMS_ENV]: JSON.stringify({ ...APP, ...over }) }); +const TERMINAL: Record = {}; + +let root: string; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'adminium-programs-')); +}); +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe('desktopPrograms', () => { + it('is nothing on a terminal', () => { + expect(desktopPrograms(TERMINAL)).toBeNull(); + expect(desktopPrograms({ [DESKTOP_PROGRAMS_ENV]: '' })).toBeNull(); + }); + + it('reads what the app wrote', () => { + expect(desktopPrograms(desktop())).toEqual(APP); + expect(desktopPrograms(desktop({ git: null }))?.git).toBeNull(); + }); + + it.each([ + ['not JSON', 'npm', /is not JSON/], + ['not an object', '"npm"', /must be an absolute path/], + ['a relative program', JSON.stringify({ ...APP, binary: 'Adminium' }), /"binary" must be an absolute path/], + ['no npm', JSON.stringify({ ...APP, npm: undefined }), /"npm" must be an absolute path/], + ['a git by name', JSON.stringify({ ...APP, git: 'git' }), /"git" must be an absolute path or null/], + ['no git key at all', JSON.stringify({ ...APP, git: undefined }), /"git" must be an absolute path or null/], + ])('refuses a value that is %s, rather than falling back to the terminal’s answer', (_label, value, message) => { + expect(() => desktopPrograms({ [DESKTOP_PROGRAMS_ENV]: value })).toThrow(message); + // And so does every question asked of it. + expect(() => nodeProgram(['-v'], { [DESKTOP_PROGRAMS_ENV]: value })).toThrow(message); + }); +}); + +describe('Node, with arguments', () => { + it('on a terminal: this process’s own program', () => { + expect(nodeProgram(['--test', 'a.test.js'], TERMINAL)).toEqual({ command: process.execPath, args: ['--test', 'a.test.js'], env: {} }); + }); + + it('in the desktop app: the app’s main program, asked to be Node, never this process’s own path', () => { + const launch = nodeProgram(['--test', 'a.test.js'], desktop()); + expect(launch).toEqual({ command: APP.binary, args: ['--test', 'a.test.js'], env: { ELECTRON_RUN_AS_NODE: '1' } }); + expect(launch.command).not.toBe(process.execPath); + }); +}); + +describe('the package manager', () => { + it('on a terminal: the folder’s own by its lockfile, else the one named, else the one running the command', () => { + expect(packageManagerProgram(root, TERMINAL).manager).toBe('npm'); + expect(packageManagerProgram(root, { npm_config_user_agent: 'pnpm/10.1.0 node/v22' }).manager).toBe('pnpm'); + expect(packageManagerProgram(root, TERMINAL, 'yarn').manager).toBe('yarn'); + writeFileSync(join(root, 'pnpm-lock.yaml'), ''); + const program = packageManagerProgram(root, TERMINAL); + expect(program.manager).toBe('pnpm'); + expect(program.launch(['install'])).toEqual({ command: 'pnpm', args: ['install'], env: {} }); + expect(program.note).toBeNull(); + }); + + it('in the desktop app: always the carried npm, as the app’s program with npm’s entry first (no .cmd for a shell to run)', () => { + const program = packageManagerProgram(root, desktop(), 'pnpm'); + expect(program.manager).toBe('npm'); + const launch = program.launch(['install', 'react@19.2.0', '--save-exact']); + expect(launch.command).toBe(APP.binary); + expect(launch.args).toEqual([join(APP.npm, 'bin', 'npm-cli.js'), 'install', 'react@19.2.0', '--save-exact']); + expect([launch.command, ...launch.args].some((part) => /\.(cmd|bat|ps1)$/i.test(part))).toBe(false); + expect(launch.env).toEqual({ + ELECTRON_RUN_AS_NODE: '1', + NPM_CONFIG_USERCONFIG: APP.npmUserConfig, + NPM_CONFIG_GLOBALCONFIG: APP.npmGlobalConfig, + NPM_CONFIG_CACHE: APP.npmCache, + NPM_CONFIG_REGISTRY: NPM_REGISTRY, + NPM_CONFIG_UPDATE_NOTIFIER: 'false', + NPM_CONFIG_FUND: 'false', + NPM_CONFIG_IGNORE_SCRIPTS: 'true', + }); + expect(NPM_REGISTRY).toBe('https://registry.npmjs.org/'); + expect(program.note).toBeNull(); + }); + + it.each([ + ['pnpm-lock.yaml', 'pnpm'], + ['yarn.lock', 'yarn'], + ['bun.lock', 'bun'], + ['bun.lockb', 'bun'], + ])('in the desktop app a folder with %s is told, installed with npm, and its file left alone', (file, manager) => { + writeFileSync(join(root, file), 'theirs'); + const program = packageManagerProgram(root, desktop()); + expect(program.manager).toBe('npm'); + expect(program.launch(['install']).command).toBe(APP.binary); + expect(program.note).toBe(`This project uses ${manager}. The app installs with npm; your ${manager} file (${file}) is left as it is.`); + }); + + it('adds exact versions with no install scripts, in each manager’s words', () => { + expect(addPackagesArgs('npm', ['a@1.0.0'])).toEqual(['install', 'a@1.0.0', '--ignore-scripts', '--save-exact']); + expect(addPackagesArgs('pnpm', ['a@1.0.0'])).toEqual(['add', 'a@1.0.0', '--ignore-scripts', '--save-exact']); + expect(addPackagesArgs('yarn', ['a@1.0.0'])).toEqual(['add', 'a@1.0.0', '--ignore-scripts', '--exact']); + expect(addPackagesArgs('bun', ['a@1.0.0'])).toEqual(['add', 'a@1.0.0', '--ignore-scripts', '--exact']); + }); + + it('is named npm in the desktop app whatever started the app', () => { + expect(namedPackageManager({ npm_config_user_agent: 'pnpm/10.1.0' })).toBe('pnpm'); + expect(namedPackageManager({ ...desktop(), npm_config_user_agent: 'pnpm/10.1.0' })).toBe('npm'); + }); +}); + +describe('an app’s own build line', () => { + it('on a terminal: the person’s PATH, untouched', () => { + expect(buildLinePath('/usr/bin:/bin', TERMINAL)).toBe('/usr/bin:/bin'); + expect(buildLinePath(undefined, TERMINAL)).toBeUndefined(); + }); + + it('in the desktop app: the stand-ins’ folder first', () => { + expect(buildLinePath('/usr/bin:/bin', desktop())).toBe(`${APP.shims}${delimiter}/usr/bin:/bin`); + expect(buildLinePath(undefined, desktop())).toBe(APP.shims); + expect(buildLinePath('', desktop())).toBe(APP.shims); + }); +}); + +describe('git, and a browser', () => { + it('on a terminal: git by name, and a page may be opened', () => { + expect(gitProgram(TERMINAL)).toBe('git'); + expect(mayOpenBrowser(TERMINAL)).toBe(true); + }); + + it('in the desktop app: the git the app found or none, and never a browser beside its window', () => { + expect(gitProgram(desktop())).toBe('/usr/bin/git'); + expect(gitProgram(desktop({ git: null }))).toBeNull(); + expect(mayOpenBrowser(desktop())).toBe(false); + }); +}); From 056e6586635a59f8e0ee678631422bf51ff0febe Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Fri, 9 Oct 2026 21:47:09 +0200 Subject: [PATCH 15/79] feat(desktop): the app makes node, npm and npx stand-ins at every launch --- apps/desktop/src/main/index.test.ts | 5 +- apps/desktop/src/main/index.ts | 19 +++ apps/desktop/src/main/programs.test.ts | 81 +++++++++++++ apps/desktop/src/main/programs.ts | 117 +++++++++++++++++++ apps/desktop/src/main/server-manager.test.ts | 3 + apps/desktop/src/main/server-manager.ts | 3 + apps/desktop/src/server/env.ts | 6 + apps/desktop/src/server/project.test.ts | 6 + apps/server/src/index.ts | 1 + 9 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/main/programs.test.ts create mode 100644 apps/desktop/src/main/programs.ts diff --git a/apps/desktop/src/main/index.test.ts b/apps/desktop/src/main/index.test.ts index 21b5abe30..4d93fad58 100644 --- a/apps/desktop/src/main/index.test.ts +++ b/apps/desktop/src/main/index.test.ts @@ -1461,6 +1461,7 @@ describe('createDesktopApp opening a project folder', () => { pickProjectPort: () => Promise.resolve(4700), bundledAppsDir: '/app/apps-bundle', projectEnv: { PATH: '/usr/bin:/bin', ADMINIUM_SECRET: 'from the shell' }, + projectPrograms: () => '{"binary":"/app/Adminium"}', confirmStopBusy: (busy) => { confirmed.push(busy.kind); return Promise.resolve(over.confirm ?? true); @@ -1477,7 +1478,7 @@ describe('createDesktopApp opening a project folder', () => { await createDesktopApp(p.deps).start(); expect(p.opts).toHaveLength(1); - expect(p.opts[0]).toMatchObject({ project: { root: ROOT, mode: 'design', bundledAppsDir: '/app/apps-bundle' }, staticRoot: '/app/out/dashboard' }); + 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' }); @@ -1569,7 +1570,7 @@ describe('createDesktopApp opening a project folder', () => { 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'] as const) Reflect.deleteProperty(bare, name); + 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'); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 92ff7f632..4760e9713 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -84,6 +84,7 @@ import { buildAppMenu, menuTranslator, type MenuHandlers, type MenuTranslate } f import { EPHEMERAL_PORT, generateBootToken, LOOPBACK_HOST } from '../server/env.js'; import { LAN_PORT_IN_USE, registerIpcHandlers, type DesktopRuntimeSnapshot } from './ipc.js'; import { createDesktopLogging } from './logging.js'; +import { provideDesktopPrograms } from './programs.js'; import { firstFreePort, projectPortRange, seamProject, sessionCookieNames, stopBusyWords } from './project.js'; import { createServerManager, @@ -552,6 +553,11 @@ export interface DesktopBootDeps { 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) | 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 @@ -1049,6 +1055,7 @@ export function createDesktopApp(deps: DesktopBootDeps): DesktopApp { mode: 'design', pickPort, ...(deps.bundledAppsDir === undefined ? {} : { bundledAppsDir: deps.bundledAppsDir }), + ...(deps.projectPrograms === undefined ? {} : { programs: deps.projectPrograms() }), }, }); manager = projectManager; @@ -2106,6 +2113,18 @@ export function electronBootDeps(): DesktopBootDeps { // only way in is the test seam, which a packaged app does not read. openProject: openedProject, projectEnv: process.env, + // 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. git is not looked for yet: versions find it on the PATH as before. + projectPrograms: () => + JSON.stringify( + provideDesktopPrograms({ + binary: process.execPath, + npmDir: resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'resources', 'npm'), + userDataDir, + git: null, + platform: process.platform, + }), + ), pickProjectPort: () => { const range = projectPortRange(process.env, app.isPackaged); return firstFreePort(range.first, range.last); diff --git a/apps/desktop/src/main/programs.test.ts b/apps/desktop/src/main/programs.test.ts new file mode 100644 index 000000000..5af6b6231 --- /dev/null +++ b/apps/desktop/src/main/programs.test.ts @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { execFileSync } from 'node:child_process'; +import { 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 { 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'), + }); + 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']); + expect(readFileSync(join(value.shims, 'npm.cmd'), 'utf8')).toContain('\r\n"C:\\Users\\Ava\\AppData\\Local\\Programs\\Adminium\\Adminium.exe" "C:\\Users\\Ava\\AppData\\Local\\Programs\\Adminium\\resources\\npm\\bin\\npm-cli.js" %*\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 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\nexit /b %ERRORLEVEL%\r\n'); + expect(shimForCmd('C:\\a.exe', 'C:\\npm\\bin\\npx-cli.js', NPM)).toContain('set "NPM_CONFIG_CACHE=/u/cache"'); + }); +}); diff --git a/apps/desktop/src/main/programs.ts b/apps/desktop/src/main/programs.ts new file mode 100644 index 000000000..58ef76b69 --- /dev/null +++ b/apps/desktop/src/main/programs.ts @@ -0,0 +1,117 @@ +// 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, 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; +} + +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; + platform: NodeJS.Platform; +} + +/** 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', + ); + } + lines.push(`exec ${shQuote(binary)}${entry === null ? '' : ` ${shQuote(entry)}`} "$@"`, ''); + 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', + ); + } + lines.push(`${q(binary)}${entry === null ? '' : ` ${q(entry)}`} %*`, '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'), + }; + 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/server-manager.test.ts b/apps/desktop/src/main/server-manager.test.ts index 81a22261a..fff595830 100644 --- a/apps/desktop/src/main/server-manager.test.ts +++ b/apps/desktop/src/main/server-manager.test.ts @@ -728,6 +728,7 @@ describe('ServerManager serving a project', () => { root: ROOT, mode: 'design', bundledAppsDir: '/app/apps-bundle', + programs: '{"binary":"/app/Adminium"}', pickPort: (mode) => { asked.push(mode); return Promise.resolve(ports.shift() ?? 4799); @@ -758,6 +759,7 @@ describe('ServerManager serving a project', () => { 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); @@ -846,6 +848,7 @@ describe('ServerManager serving a project', () => { 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(); diff --git a/apps/desktop/src/main/server-manager.ts b/apps/desktop/src/main/server-manager.ts index f81457989..458521b97 100644 --- a/apps/desktop/src/main/server-manager.ts +++ b/apps/desktop/src/main/server-manager.ts @@ -229,6 +229,8 @@ export interface ProjectServerOptions { 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; } /** Lines of log attached to a failure. Enough for a stack, short enough to read. */ export const CRASH_EXCERPT_LINES = 20; @@ -649,6 +651,7 @@ class ServerManagerImpl implements ServerManager { ...(this.#opts.staticRoot === undefined ? {} : { staticRoot: this.#opts.staticRoot }), ...(this.#opts.bundledAddOnsDir === undefined ? {} : { bundledAddOnsDir: this.#opts.bundledAddOnsDir }), ...(project.bundledAppsDir === undefined ? {} : { bundledAppsDir: project.bundledAppsDir }), + ...(project.programs === undefined ? {} : { programs: project.programs }), ...(this.#opts.inheritEnv === undefined ? {} : { inherit: this.#opts.inheritEnv }), }); }); diff --git a/apps/desktop/src/server/env.ts b/apps/desktop/src/server/env.ts index 92693de58..071522963 100644 --- a/apps/desktop/src/server/env.ts +++ b/apps/desktop/src/server/env.ts @@ -455,6 +455,11 @@ export interface BuildProjectServerEnvInput { staticRoot?: string | undefined; bundledAddOnsDir?: string | undefined; bundledAppsDir?: string | undefined; + /** + * Where the app's own programs are, as JSON (`main/programs.ts` makes it): the + * server starts Node, npm and git by this and by nothing on the PATH. + */ + programs?: string | undefined; inherit?: NodeJS.ProcessEnv | undefined; } @@ -496,6 +501,7 @@ export function buildProjectServerEnv(input: BuildProjectServerEnvInput): Record if (input.bundledAddOnsDir !== undefined) env.ADMINIUM_BUNDLED_ADD_ONS = input.bundledAddOnsDir; // Absolute, always: the server's default is `./apps-bundle`, which in this child would be looked for inside the opened folder. if (input.bundledAppsDir !== undefined) env.ADMINIUM_BUNDLED_APPS = input.bundledAppsDir; + if (input.programs !== undefined) env.ADMINIUM_DESKTOP_PROGRAMS = input.programs; return env; } diff --git a/apps/desktop/src/server/project.test.ts b/apps/desktop/src/server/project.test.ts index a49830255..17f2c1949 100644 --- a/apps/desktop/src/server/project.test.ts +++ b/apps/desktop/src/server/project.test.ts @@ -68,6 +68,12 @@ describe('buildProjectServerEnv', () => { expect(env).toMatchObject({ ADMINIUM_STATIC_ROOT: '/app/out/dashboard', ADMINIUM_BUNDLED_ADD_ONS: '/app/add-ons', ADMINIUM_BUNDLED_APPS: '/app/apps-bundle', ADMINIUM_LOG_LEVEL: 'info' }); }); + it('says where the app’s own programs are, and lets nothing inherited say it instead', () => { + expect(block()).not.toHaveProperty('ADMINIUM_DESKTOP_PROGRAMS'); + expect(block({ inherit: { ADMINIUM_DESKTOP_PROGRAMS: '{"binary":"/tmp/evil"}' } })).not.toHaveProperty('ADMINIUM_DESKTOP_PROGRAMS'); + expect(block({ programs: '{"binary":"/app"}', inherit: { ADMINIUM_DESKTOP_PROGRAMS: '{"binary":"/tmp/evil"}' } }).ADMINIUM_DESKTOP_PROGRAMS).toBe('{"binary":"/app"}'); + }); + it('refuses a relative folder, a port that was not picked and a token of the wrong size', () => { expect(() => block({ root: 'juniper' })).toThrow(/absolute path/); expect(() => block({ port: 0 })).toThrow(/picked before the fork/); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 9dca4dea5..1c2abc92b 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -257,6 +257,7 @@ export { COMMANDS, findCommand, runCli, type RunCliOptions } from './cli/run.js' export { HOST_DECIDED_ENV, startProject, type StartProjectOptions, type StartedProject } from './cli/start-project.js'; export type { StartBusy } from './cli/commands/start.js'; export { readRunning, runningMessage, type RunningMark } from './project/running.js'; +export { DESKTOP_PROGRAMS_ENV, desktopPrograms, type DesktopPrograms } from './project/programs.js'; export { defaultCliDeps, displayUrl, From 08d6c4855cf9fda9faed902bde107b4e780dc680 Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Fri, 9 Oct 2026 21:47:44 +0200 Subject: [PATCH 16/79] feat(desktop): the Mac build may load a project's own libraries --- apps/desktop/build/entitlements.mac.plist | 24 +++++++- .../src/test/packaging-posture.test.ts | 56 +++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/test/packaging-posture.test.ts diff --git a/apps/desktop/build/entitlements.mac.plist b/apps/desktop/build/entitlements.mac.plist index d0cee2b84..9a945ea38 100644 --- a/apps/desktop/build/entitlements.mac.plist +++ b/apps/desktop/build/entitlements.mac.plist @@ -23,14 +23,32 @@ is not required. No sandbox means no need for user-selected-file entitlements either — the app already has ordinary file access. - 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.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.cs.allow-jit + com.apple.security.cs.disable-library-validation + diff --git a/apps/desktop/src/test/packaging-posture.test.ts b/apps/desktop/src/test/packaging-posture.test.ts new file mode 100644 index 000000000..cd5a56af0 --- /dev/null +++ b/apps/desktop/src/test/packaging-posture.test.ts @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** + * Two packaging facts a project in the app stands on, read from the files a + * release is built from. + * + * 1. The Mac entitlements: exactly these, no more. One of them is wide + * (library validation off), and a key added here beside it should be a + * decision somebody made, not a line that rode along. + * 2. Two fuses stay as Electron ships them. The app runs its own program as + * Node to be the project's Node and npm, and the read guard that keeps a + * copied app's build inside its own folder is put in front of that Node + * through NODE_OPTIONS. Flipping either fuse breaks one of those with no + * error at build time. + */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const DESKTOP = join(import.meta.dirname, '..', '..'); +const read = (file: string): string => readFileSync(join(DESKTOP, file), 'utf8'); + +describe('the Mac entitlements', () => { + const plist = read('build/entitlements.mac.plist'); + const body = plist.slice(plist.indexOf('([^<]+)<\/key>\s*/g)].map((match) => match[1]); + + it('grants the JIT and loading a project’s own libraries, and nothing else', () => { + expect(granted).toEqual(['com.apple.security.cs.allow-jit', 'com.apple.security.cs.disable-library-validation']); + expect([...body.matchAll(//g)]).toHaveLength(2); + }); + + it('says what the wide one costs, where the next reader will look', () => { + expect(plist).toContain('load a library nobody at'); + expect(plist).not.toContain('Nothing here disables library validation'); + }); + + it('serves the app and its helpers from the one file', () => { + const builder = read('electron-builder.yml'); + expect(builder).toMatch(/^\s*entitlements: build\/entitlements\.mac\.plist$/m); + expect(builder).toMatch(/^\s*entitlementsInherit: build\/entitlements\.mac\.plist$/m); + expect(builder).toMatch(/^\s*hardenedRuntime: true$/m); + }); +}); + +describe('the fuses', () => { + /** The build's settings with their comments taken out. */ + const settings = read('electron-builder.yml').replace(/^\s*#.*$/gm, ''); + + it('the app’s program still runs as Node, and still reads NODE_OPTIONS', () => { + expect(settings).not.toMatch(/runAsNode:\s*false/i); + expect(settings).not.toMatch(/enableNodeOptionsEnvironmentVariable:\s*false/i); + // Nor through the fuses' own tool, should a script ever call it. + expect(read('package.json')).not.toContain('@electron/fuses'); + }); +}); From a6bc9c3e659bc77648b7d752bb420c5c09e20033 Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Fri, 9 Oct 2026 21:59:46 +0200 Subject: [PATCH 17/79] feat(desktop): carry npm 10.9.8 as real files beside the archive --- apps/desktop/build/after-pack.cjs | 41 +++++++++++++ apps/desktop/electron-builder.yml | 11 ++++ apps/desktop/package.json | 1 + apps/desktop/src/main/index.ts | 5 +- apps/desktop/src/main/programs.test.ts | 21 ++++++- apps/desktop/src/main/programs.ts | 17 +++++- pnpm-lock.yaml | 79 ++++++++++++++++++++++++++ 7 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/build/after-pack.cjs diff --git a/apps/desktop/build/after-pack.cjs b/apps/desktop/build/after-pack.cjs new file mode 100644 index 000000000..84bc26d13 --- /dev/null +++ b/apps/desktop/build/after-pack.cjs @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** + * electron-builder's `afterPack`: put the carried npm beside the archive. + * + * 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 { cpSync, existsSync, readFileSync, 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'); +} + +exports.default = async function afterPack(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/electron-builder.yml b/apps/desktop/electron-builder.yml index 4e2ca40de..58f210ae3 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -122,6 +122,17 @@ 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{,/**}" + +# 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 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 15a52c3bd..2b8d73073 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": { diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 4760e9713..17617aaed 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -84,7 +84,7 @@ import { buildAppMenu, menuTranslator, type MenuHandlers, type MenuTranslate } f import { EPHEMERAL_PORT, generateBootToken, LOOPBACK_HOST } from '../server/env.js'; import { LAN_PORT_IN_USE, registerIpcHandlers, type DesktopRuntimeSnapshot } from './ipc.js'; import { createDesktopLogging } from './logging.js'; -import { provideDesktopPrograms } from './programs.js'; +import { carriedNpmDir, provideDesktopPrograms } from './programs.js'; import { firstFreePort, projectPortRange, seamProject, sessionCookieNames, stopBusyWords } from './project.js'; import { createServerManager, @@ -2119,7 +2119,8 @@ export function electronBootDeps(): DesktopBootDeps { JSON.stringify( provideDesktopPrograms({ binary: process.execPath, - npmDir: resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'resources', 'npm'), + // 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: null, platform: process.platform, diff --git a/apps/desktop/src/main/programs.test.ts b/apps/desktop/src/main/programs.test.ts index 5af6b6231..bc4b34790 100644 --- a/apps/desktop/src/main/programs.test.ts +++ b/apps/desktop/src/main/programs.test.ts @@ -7,7 +7,7 @@ import { join } from 'node:path'; import { desktopPrograms } from '@adminium/server'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { provideDesktopPrograms, shimForCmd, shimForSh } from './programs.js'; +import { carriedNpmDir, provideDesktopPrograms, shimForCmd, shimForSh } from './programs.js'; let userData: string; beforeEach(() => { @@ -79,3 +79,22 @@ describe('the stand-ins’ text', () => { 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'])); + }); +}); diff --git a/apps/desktop/src/main/programs.ts b/apps/desktop/src/main/programs.ts index 58ef76b69..162e55880 100644 --- a/apps/desktop/src/main/programs.ts +++ b/apps/desktop/src/main/programs.ts @@ -14,7 +14,7 @@ * * Electron-free, so every line is tested under plain Node. */ -import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; +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). */ @@ -40,6 +40,21 @@ export interface ProvideProgramsInput { 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, `'\\''`)}'`; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93e25be10..60d76a6fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -202,6 +202,9 @@ importers: fflate: specifier: ^0.8.3 version: 0.8.3 + npm: + specifier: 10.9.8 + version: 10.9.8 zod: specifier: 'catalog:' version: 4.4.3 @@ -6389,6 +6392,80 @@ packages: resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} engines: {node: '>=10'} + npm@10.9.8: + resolution: {integrity: sha512-fYwb6ODSmHkqrJQQaCxY3M2lPf/mpgC7ik0HSzzIwG5CGtabRp4bNqikatvCoT42b5INQSqudVH0R7yVmC9hVg==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + bundledDependencies: + - '@isaacs/string-locale-compare' + - '@npmcli/arborist' + - '@npmcli/config' + - '@npmcli/fs' + - '@npmcli/map-workspaces' + - '@npmcli/package-json' + - '@npmcli/promise-spawn' + - '@npmcli/redact' + - '@npmcli/run-script' + - '@sigstore/tuf' + - abbrev + - archy + - cacache + - chalk + - ci-info + - cli-columns + - fastest-levenshtein + - fs-minipass + - glob + - graceful-fs + - hosted-git-info + - ini + - init-package-json + - is-cidr + - json-parse-even-better-errors + - libnpmaccess + - libnpmdiff + - libnpmexec + - libnpmfund + - libnpmhook + - libnpmorg + - libnpmpack + - libnpmpublish + - libnpmsearch + - libnpmteam + - libnpmversion + - make-fetch-happen + - minimatch + - minipass + - minipass-pipeline + - ms + - node-gyp + - nopt + - normalize-package-data + - npm-audit-report + - npm-install-checks + - npm-package-arg + - npm-pick-manifest + - npm-profile + - npm-registry-fetch + - npm-user-validate + - p-map + - pacote + - parse-conflict-json + - proc-log + - qrcode-terminal + - read + - semver + - spdx-expression-parse + - ssri + - supports-color + - tar + - text-table + - tiny-relative-date + - treeverse + - validate-npm-package-name + - which + - write-file-atomic + nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} @@ -13971,6 +14048,8 @@ snapshots: normalize-url@6.1.0: {} + npm@10.9.8: {} + nth-check@2.1.1: dependencies: boolbase: 1.0.0 From 70b04b40baf5ba811bf682899fb1648415e0d8ba Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Fri, 9 Oct 2026 22:27:01 +0200 Subject: [PATCH 18/79] fix(desktop): the build guard reaches a signed Mac app as an argument --- apps/desktop/src/main/programs.test.ts | 27 +++++++++++++++++++++-- apps/desktop/src/main/programs.ts | 24 ++++++++++++++++++-- apps/server/src/project/apps/own-build.ts | 10 ++++++++- 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main/programs.test.ts b/apps/desktop/src/main/programs.test.ts index bc4b34790..0d46c4498 100644 --- a/apps/desktop/src/main/programs.test.ts +++ b/apps/desktop/src/main/programs.test.ts @@ -53,7 +53,11 @@ describe('provideDesktopPrograms', () => { 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']); - expect(readFileSync(join(value.shims, 'npm.cmd'), 'utf8')).toContain('\r\n"C:\\Users\\Ava\\AppData\\Local\\Programs\\Adminium\\Adminium.exe" "C:\\Users\\Ava\\AppData\\Local\\Programs\\Adminium\\resources\\npm\\bin\\npm-cli.js" %*\r\n'); + 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', () => { @@ -64,6 +68,25 @@ describe('provideDesktopPrograms', () => { }); }); +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); @@ -75,7 +98,7 @@ describe('the stand-ins’ text', () => { 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\nexit /b %ERRORLEVEL%\r\n'); + 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"'); }); }); diff --git a/apps/desktop/src/main/programs.ts b/apps/desktop/src/main/programs.ts index 162e55880..83e5d804d 100644 --- a/apps/desktop/src/main/programs.ts +++ b/apps/desktop/src/main/programs.ts @@ -71,7 +71,17 @@ export function shimForSh(binary: string, entry: string | null, npm: Pick Date: Fri, 9 Oct 2026 22:40:28 +0200 Subject: [PATCH 19/79] fix(desktop): the .deb asks for libgbm and libasound --- apps/desktop/electron-builder.yml | 21 +++++++++++++++++++ .../src/test/packaging-posture.test.ts | 9 ++++++++ 2 files changed, 30 insertions(+) diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index 58f210ae3..54c3fca0f 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -216,6 +216,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/src/test/packaging-posture.test.ts b/apps/desktop/src/test/packaging-posture.test.ts index cd5a56af0..d11fa015b 100644 --- a/apps/desktop/src/test/packaging-posture.test.ts +++ b/apps/desktop/src/test/packaging-posture.test.ts @@ -54,3 +54,12 @@ describe('the fuses', () => { expect(read('package.json')).not.toContain('@electron/fuses'); }); }); + +describe('the .deb', () => { + it('asks for every library the program links against, the two a desktop system hides included', () => { + const builder = read('electron-builder.yml').replace(/^\s*#.*$/gm, ''); + const block = /^deb:\n\s+depends:\n((?:\s+- .*\n)+)/m.exec(builder)?.[1] ?? ''; + const asked = block.split('\n').map((line) => line.replace(/^\s+- /, '').trim()).filter((line) => line !== ''); + expect(asked).toEqual(['libgtk-3-0', 'libnotify4', 'libnss3', 'libxss1', 'libxtst6', 'xdg-utils', 'libatspi2.0-0', 'libuuid1', 'libsecret-1-0', 'libgbm1', 'libasound2 | libasound2t64']); + }); +}); From 78615ec7df96b94122243758a9693408bc960610 Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Fri, 9 Oct 2026 22:40:29 +0200 Subject: [PATCH 20/79] ci: one trial run, npm under the app's program on a real Linux --- .github/workflows/zz-trial-linux-npm.yml | 66 ++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .github/workflows/zz-trial-linux-npm.yml diff --git a/.github/workflows/zz-trial-linux-npm.yml b/.github/workflows/zz-trial-linux-npm.yml new file mode 100644 index 000000000..45dea5893 --- /dev/null +++ b/.github/workflows/zz-trial-linux-npm.yml @@ -0,0 +1,66 @@ +# TEMPORARY, and never for main: one question asked of a real x86_64 Linux machine. +# +# Does npm install a project when it is run by the desktop app's own program as +# Node? In an emulated amd64 container it stopped at once ("cannot set +# sizeCalculation without setting maxSize or maxEntrySize") and the same line +# works on arm64 macOS, so only a real machine can say which of the two it is. +# +# It runs when THIS FILE is pushed to the branch it was written on, and on +# nothing else. It reads no secret and writes nothing anywhere. It is deleted +# from the branch once it has answered. +name: trial — npm under the app's program on Linux +on: + push: + branches: [plan-66-d] + paths: ['.github/workflows/zz-trial-linux-npm.yml'] +permissions: + contents: read +jobs: + trial: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: The machine + run: uname -a && lsb_release -ds && nproc && free -g | head -2 + - name: The released 0.2.10 .deb, its checksum, and the two libraries its list lacks + run: | + set -eu + cd "$RUNNER_TEMP" + curl -fsSL -o adminium.deb https://github.com/MoSofi/Adminium/releases/download/desktop-v0.2.10/adminium-0.2.10-linux-amd64.deb + echo "8d7a11e404de07809b57fa1c4c6d30394973fc8d519704ad92da3bc3750b02ef adminium.deb" | sha256sum -c - + mkdir deb && dpkg-deb -x adminium.deb deb + echo "missing before anything is added:"; ldd deb/opt/Adminium/adminium | grep 'not found' || echo " (none: this runner has them)" + sudo apt-get update -qq && sudo apt-get install -y -qq libgbm1 libasound2t64 || sudo apt-get install -y -qq libgbm1 libasound2 + echo "BIN=$RUNNER_TEMP/deb/opt/Adminium/adminium" >> "$GITHUB_ENV" + - name: Electron 44.4.5 too (what the next build carries), and npm 10.9.8 as the app carries it + run: | + set -eu + cd "$RUNNER_TEMP" + mkdir e44 && cd e44 && npm init -y >/dev/null && npm install --no-audit --no-fund electron@44.4.5 >/dev/null + echo "E44=$RUNNER_TEMP/e44/node_modules/electron/dist/electron" >> "$GITHUB_ENV" + cd "$RUNNER_TEMP" && npm pack npm@10.9.8 >/dev/null && mkdir carried && tar -xzf npm-10.9.8.tgz -C carried + echo "NPM=$RUNNER_TEMP/carried/package" >> "$GITHUB_ENV" + - name: The question + run: | + set -u + for P in "$BIN" "$E44"; do + echo "=================== $P" + ELECTRON_RUN_AS_NODE=1 "$P" -e ' + const v8 = require("node:v8"); + console.log("electron", process.versions.electron, "node", process.versions.node, process.platform + "/" + process.arch, "heap_size_limit", v8.getHeapStatistics().heap_size_limit); + try { const C = require(process.argv[1] + "/node_modules/@npmcli/arborist/lib/packument-cache.js"); console.log("packument cache made, maxSize", new C().maxSize); } + catch (e) { console.log("PACKUMENT CACHE FAILED:", e.message); }' "$NPM" + echo 'console.log("LOADED via NODE_OPTIONS")' > "$RUNNER_TEMP/probe.cjs" + ELECTRON_RUN_AS_NODE=1 NODE_OPTIONS="--require $RUNNER_TEMP/probe.cjs" "$P" -e 'console.log("main ran")' + D=$(mktemp -d) && cd "$D" && echo '{"name":"t","private":true}' > package.json && : > "$D/u" && : > "$D/g" + START=$(date +%s) + ELECTRON_RUN_AS_NODE=1 NPM_CONFIG_USERCONFIG="$D/u" NPM_CONFIG_GLOBALCONFIG="$D/g" NPM_CONFIG_CACHE="$D/cache" NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ NPM_CONFIG_UPDATE_NOTIFIER=false NPM_CONFIG_FUND=false NPM_CONFIG_IGNORE_SCRIPTS=true \ + "$P" "$NPM/bin/npm-cli.js" install @tailwindcss/oxide@4.3.3 lightningcss@1.33.0 better-sqlite3@13.0.1 argon2@0.44.0 --ignore-scripts --save-exact 2>&1 | tail -4 + echo "install exit ${PIPESTATUS[0]} in $(( $(date +%s) - START )) s; node_modules $(du -sh node_modules 2>/dev/null | cut -f1)" + ELECTRON_RUN_AS_NODE=1 "$P" -e ' + const t = (n, f) => { try { console.log("ok ", n, f()); } catch (e) { console.log("FAIL", n, String(e.message).split("\n")[0].slice(0, 150)); } }; + t("@tailwindcss/oxide", () => typeof require("@tailwindcss/oxide").Scanner); + t("lightningcss", () => require("lightningcss").transform({ filename: "a.css", code: Buffer.from("a{color:red}"), minify: true }).code.toString()); + t("better-sqlite3", () => new (require("better-sqlite3"))(":memory:").prepare("select sqlite_version() v").get().v); + t("argon2", () => typeof require("argon2").hash);' + done From b8b1ebd9ff4f3188c8d5def14d4c8a025ae7305d Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Fri, 9 Oct 2026 23:38:54 +0200 Subject: [PATCH 21/79] ci: remove the trial run, it has answered --- .github/workflows/zz-trial-linux-npm.yml | 66 ------------------------ 1 file changed, 66 deletions(-) delete mode 100644 .github/workflows/zz-trial-linux-npm.yml diff --git a/.github/workflows/zz-trial-linux-npm.yml b/.github/workflows/zz-trial-linux-npm.yml deleted file mode 100644 index 45dea5893..000000000 --- a/.github/workflows/zz-trial-linux-npm.yml +++ /dev/null @@ -1,66 +0,0 @@ -# TEMPORARY, and never for main: one question asked of a real x86_64 Linux machine. -# -# Does npm install a project when it is run by the desktop app's own program as -# Node? In an emulated amd64 container it stopped at once ("cannot set -# sizeCalculation without setting maxSize or maxEntrySize") and the same line -# works on arm64 macOS, so only a real machine can say which of the two it is. -# -# It runs when THIS FILE is pushed to the branch it was written on, and on -# nothing else. It reads no secret and writes nothing anywhere. It is deleted -# from the branch once it has answered. -name: trial — npm under the app's program on Linux -on: - push: - branches: [plan-66-d] - paths: ['.github/workflows/zz-trial-linux-npm.yml'] -permissions: - contents: read -jobs: - trial: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: The machine - run: uname -a && lsb_release -ds && nproc && free -g | head -2 - - name: The released 0.2.10 .deb, its checksum, and the two libraries its list lacks - run: | - set -eu - cd "$RUNNER_TEMP" - curl -fsSL -o adminium.deb https://github.com/MoSofi/Adminium/releases/download/desktop-v0.2.10/adminium-0.2.10-linux-amd64.deb - echo "8d7a11e404de07809b57fa1c4c6d30394973fc8d519704ad92da3bc3750b02ef adminium.deb" | sha256sum -c - - mkdir deb && dpkg-deb -x adminium.deb deb - echo "missing before anything is added:"; ldd deb/opt/Adminium/adminium | grep 'not found' || echo " (none: this runner has them)" - sudo apt-get update -qq && sudo apt-get install -y -qq libgbm1 libasound2t64 || sudo apt-get install -y -qq libgbm1 libasound2 - echo "BIN=$RUNNER_TEMP/deb/opt/Adminium/adminium" >> "$GITHUB_ENV" - - name: Electron 44.4.5 too (what the next build carries), and npm 10.9.8 as the app carries it - run: | - set -eu - cd "$RUNNER_TEMP" - mkdir e44 && cd e44 && npm init -y >/dev/null && npm install --no-audit --no-fund electron@44.4.5 >/dev/null - echo "E44=$RUNNER_TEMP/e44/node_modules/electron/dist/electron" >> "$GITHUB_ENV" - cd "$RUNNER_TEMP" && npm pack npm@10.9.8 >/dev/null && mkdir carried && tar -xzf npm-10.9.8.tgz -C carried - echo "NPM=$RUNNER_TEMP/carried/package" >> "$GITHUB_ENV" - - name: The question - run: | - set -u - for P in "$BIN" "$E44"; do - echo "=================== $P" - ELECTRON_RUN_AS_NODE=1 "$P" -e ' - const v8 = require("node:v8"); - console.log("electron", process.versions.electron, "node", process.versions.node, process.platform + "/" + process.arch, "heap_size_limit", v8.getHeapStatistics().heap_size_limit); - try { const C = require(process.argv[1] + "/node_modules/@npmcli/arborist/lib/packument-cache.js"); console.log("packument cache made, maxSize", new C().maxSize); } - catch (e) { console.log("PACKUMENT CACHE FAILED:", e.message); }' "$NPM" - echo 'console.log("LOADED via NODE_OPTIONS")' > "$RUNNER_TEMP/probe.cjs" - ELECTRON_RUN_AS_NODE=1 NODE_OPTIONS="--require $RUNNER_TEMP/probe.cjs" "$P" -e 'console.log("main ran")' - D=$(mktemp -d) && cd "$D" && echo '{"name":"t","private":true}' > package.json && : > "$D/u" && : > "$D/g" - START=$(date +%s) - ELECTRON_RUN_AS_NODE=1 NPM_CONFIG_USERCONFIG="$D/u" NPM_CONFIG_GLOBALCONFIG="$D/g" NPM_CONFIG_CACHE="$D/cache" NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ NPM_CONFIG_UPDATE_NOTIFIER=false NPM_CONFIG_FUND=false NPM_CONFIG_IGNORE_SCRIPTS=true \ - "$P" "$NPM/bin/npm-cli.js" install @tailwindcss/oxide@4.3.3 lightningcss@1.33.0 better-sqlite3@13.0.1 argon2@0.44.0 --ignore-scripts --save-exact 2>&1 | tail -4 - echo "install exit ${PIPESTATUS[0]} in $(( $(date +%s) - START )) s; node_modules $(du -sh node_modules 2>/dev/null | cut -f1)" - ELECTRON_RUN_AS_NODE=1 "$P" -e ' - const t = (n, f) => { try { console.log("ok ", n, f()); } catch (e) { console.log("FAIL", n, String(e.message).split("\n")[0].slice(0, 150)); } }; - t("@tailwindcss/oxide", () => typeof require("@tailwindcss/oxide").Scanner); - t("lightningcss", () => require("lightningcss").transform({ filename: "a.css", code: Buffer.from("a{color:red}"), minify: true }).code.toString()); - t("better-sqlite3", () => new (require("better-sqlite3"))(":memory:").prepare("select sqlite_version() v").get().v); - t("argon2", () => typeof require("argon2").hash);' - done From 8cdd18d40f97da9ef227db2f57d5dd49bd689482 Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Sat, 10 Oct 2026 00:01:28 +0200 Subject: [PATCH 22/79] build(desktop): the archive holds only what each package ships --- apps/desktop/build/after-pack.cjs | 48 ++++++++++++++++++- apps/desktop/electron-builder.yml | 13 +++++ .../src/test/packaging-posture.test.ts | 19 ++++++++ apps/server/scripts/bundle-skills.mjs | 5 +- 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/apps/desktop/build/after-pack.cjs b/apps/desktop/build/after-pack.cjs index 84bc26d13..595b60f78 100644 --- a/apps/desktop/build/after-pack.cjs +++ b/apps/desktop/build/after-pack.cjs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only /** - * electron-builder's `afterPack`: put the carried npm beside the archive. + * 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 @@ -14,7 +15,7 @@ * library, so it adds nothing to sign; it is covered by the app's seal like any * other resource. */ -const { cpSync, existsSync, readFileSync, realpathSync, rmSync } = require('node:fs'); +const { closeSync, cpSync, existsSync, openSync, readFileSync, readSync, realpathSync, rmSync } = require('node:fs'); const { join } = require('node:path'); /** Where a packed app keeps its resources, per system. */ @@ -25,7 +26,50 @@ function resourcesDir(context) { 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). + */ +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 = []; + for (const [name, node] of Object.entries(scope)) { + const manifest = join(context.packager.projectDir, 'node_modules', '@adminium', name, 'package.json'); + if (!existsSync(manifest)) continue; + 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`); +} + exports.default = async function afterPack(context) { + checkWorkspacePackages(context); const from = realpathSync(join(context.packager.projectDir, 'node_modules', 'npm')); const to = join(resourcesDir(context), 'npm'); rmSync(to, { recursive: true, force: true }); diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index 54c3fca0f..cb3d494d7 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -126,6 +126,19 @@ files: # 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,eslint.config.js,openapi.json,tsconfig.json,tsconfig.build.json,*.tsbuildinfo,CHANGELOG.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 diff --git a/apps/desktop/src/test/packaging-posture.test.ts b/apps/desktop/src/test/packaging-posture.test.ts index d11fa015b..095d00a9e 100644 --- a/apps/desktop/src/test/packaging-posture.test.ts +++ b/apps/desktop/src/test/packaging-posture.test.ts @@ -63,3 +63,22 @@ describe('the .deb', () => { expect(asked).toEqual(['libgtk-3-0', 'libnotify4', 'libnss3', 'libxss1', 'libxtst6', 'xdg-utils', 'libatspi2.0-0', 'libuuid1', 'libsecret-1-0', 'libgbm1', 'libasound2 | libasound2t64']); }); }); + +describe('the archive', () => { + const builder = read('electron-builder.yml').replace(/^\s*#.*$/gm, ''); + + it('cuts a workspace package back to what the package itself ships', () => { + const cut = /"!node_modules\/@adminium\/\*\/\{([^}]*)\}\{,\/\*\*\}"/.exec(builder)?.[1]?.split(',') ?? []; + expect(cut).toEqual(expect.arrayContaining(['coverage', '.turbo', 'vrt', 'storybook-static', 'scripts', 'data'])); + expect(builder).toContain('/src{,/**}"'); + // The carried npm is beside the archive, not in it. + expect(builder).toContain('"!node_modules/npm{,/**}"'); + }); + + it('is checked after it is made, by the step that also carries npm', () => { + expect(builder).toMatch(/^afterPack: build\/after-pack\.cjs$/m); + const hook = read('build/after-pack.cjs'); + expect(hook).toContain('checkWorkspacePackages(context)'); + expect(hook).toContain('the carried npm is not whole'); + }); +}); diff --git a/apps/server/scripts/bundle-skills.mjs b/apps/server/scripts/bundle-skills.mjs index dbc6737e7..5665878e5 100644 --- a/apps/server/scripts/bundle-skills.mjs +++ b/apps/server/scripts/bundle-skills.mjs @@ -17,5 +17,8 @@ if (!existsSync(join(source, 'adminium', 'SKILL.md'))) { process.exit(1); } rmSync(target, { recursive: true, force: true }); -cpSync(source, target, { recursive: true, filter: (path) => !path.split(/[\\/]/).some((part) => part.startsWith('.') && part.length > 1) }); +// `VERSION` stays behind: it is the number the skills were last mirrored at, written by the mirror step for the +// mirror. Inside the server it would be a second, older answer to "which version is this" beside the server's own. +const versionFile = join(source, 'VERSION'); +cpSync(source, target, { recursive: true, filter: (path) => path !== versionFile && !path.split(/[\\/]/).some((part) => part.startsWith('.') && part.length > 1) }); console.log(`bundle-skills: copied ${source} → ${target}`); From 67089287a5ca710b6283c965e6df064ccdae59ba Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Sat, 10 Oct 2026 00:05:56 +0200 Subject: [PATCH 23/79] feat(server): an install is marked finished; a hidden install command --- apps/server/src/cli/commands/app/new.ts | 2 + apps/server/src/cli/commands/design.ts | 4 +- apps/server/src/cli/commands/install.ts | 49 ++++++++++ apps/server/src/cli/commands/new.ts | 3 + apps/server/src/cli/run.ts | 2 + apps/server/src/designer/ask-needs.ts | 4 + apps/server/src/project/install-stamp.ts | 103 ++++++++++++++++++++ apps/server/src/project/install.ts | 90 ++++++++++++++++++ apps/server/test/install-stamp.test.ts | 110 +++++++++++++++++++++ apps/server/test/project-install.test.ts | 116 +++++++++++++++++++++++ 10 files changed, 482 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/cli/commands/install.ts create mode 100644 apps/server/src/project/install-stamp.ts create mode 100644 apps/server/src/project/install.ts create mode 100644 apps/server/test/install-stamp.test.ts create mode 100644 apps/server/test/project-install.test.ts diff --git a/apps/server/src/cli/commands/app/new.ts b/apps/server/src/cli/commands/app/new.ts index b8b8f4909..0e22f3d47 100644 --- a/apps/server/src/cli/commands/app/new.ts +++ b/apps/server/src/cli/commands/app/new.ts @@ -6,6 +6,7 @@ import { checkApp } from '../../../project/apps/check-app.js'; import { appPath, type AppSide } from '../../../project/apps/read-app.js'; import { addSideDependencies, appKeyProblem, nameFromKey, scaffoldApp } from '../../../project/apps/scaffold-app.js'; +import { refreshInstallStamp } from '../../../project/install-stamp.js'; import { packageManagerProgram } from '../../../project/programs.js'; import { APP_VERSION } from '../../../version.js'; import { parseFlags, type FlagSpecs } from '../../args.js'; @@ -63,6 +64,7 @@ export const appNewCommand: Command = { } else { io.out(`Running ${command} ${args.join(' ')} …`); const result = (deps.runProcess ?? defaultRunProcess)(launch.command, launch.args, { cwd: project.root, inherit: true, ...(Object.keys(launch.env).length === 0 ? {} : { env: launch.env }) }); + if (result.status === 0) refreshInstallStamp(project.root, APP_VERSION); if (result.status !== 0) { installFailed = true; io.err(`\`${command} ${args.join(' ')}\` failed. The app's files are in place; run it again before building.`); diff --git a/apps/server/src/cli/commands/design.ts b/apps/server/src/cli/commands/design.ts index 1c449357f..bcb5790fe 100644 --- a/apps/server/src/cli/commands/design.ts +++ b/apps/server/src/cli/commands/design.ts @@ -27,6 +27,7 @@ import { runChild } from '../../designer/child.js'; import { DESIGNER_REACT_VERSION } from '../../designer/tools.js'; import { PUBLIC_CLIENT_PACKAGE } from '../../project/apps/scaffold-app.js'; import { findProject } from '../../project/locate.js'; +import { refreshInstallStamp } from '../../project/install-stamp.js'; import { addPackagesArgs, packageManagerProgram } from '../../project/programs.js'; import { APP_VERSION } from '../../version.js'; import { boolFlag, numberFlag, parseFlags, stringFlag } from '../args.js'; @@ -46,7 +47,8 @@ async function addScreenPackages(root: string, env: NodeJS.ProcessEnv): Promise< const program = packageManagerProgram(root, env); const specs = [`react@${DESIGNER_REACT_VERSION}`, `react-dom@${DESIGNER_REACT_VERSION}`, `${PUBLIC_CLIENT_PACKAGE}@${APP_VERSION}`]; const launch = program.launch(addPackagesArgs(program.manager, specs)); - await runChild(launch.command, launch.args, { cwd: root, timeoutMs: 180_000, signal: new AbortController().signal, env: { ...process.env, ...launch.env } }).catch(() => undefined); + const added = await runChild(launch.command, launch.args, { cwd: root, timeoutMs: 180_000, signal: new AbortController().signal, env: { ...process.env, ...launch.env } }).catch(() => undefined); + if (added?.code === 0) refreshInstallStamp(root, APP_VERSION); } /** The ports `design` tries, in order, when none is named. */ diff --git a/apps/server/src/cli/commands/install.ts b/apps/server/src/cli/commands/install.ts new file mode 100644 index 000000000..a2800c575 --- /dev/null +++ b/apps/server/src/cli/commands/install.ts @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** + * `adminium install` — install the project's packages, whole. + * + * Hidden: on a terminal a person runs their package manager themselves. It is + * the desktop app's way to make a folder ready before it starts that folder's + * server, with the npm the app carries, and to learn whether that is needed + * at all (`--check`). + */ +import { findProject } from '../../project/locate.js'; +import { installNeed, installNeedWords, installProject } from '../../project/install.js'; +import { boolFlag, parseFlags } from '../args.js'; +import type { Command } from '../command.js'; +import { CliError, EXIT_CONFIG, EXIT_ERROR, EXIT_OK } from '../exit.js'; + +export const installCommand: Command = { + name: 'install', + summary: 'Install the project’s packages', + usage: 'adminium install [--check]', + hidden: true, + describe: + 'Installs the packages the project in this folder lists, from its lockfile when it\n' + + 'has one, with no install scripts, and marks the install as finished.\n' + + '--check installs nothing: it says whether an install is needed and why (exit 0: in place; 3: needed).', + flags: { + check: { type: 'boolean', describe: 'Say whether an install is needed; install nothing' }, + }, + async run({ io, deps, argv }) { + const { values } = parseFlags(argv, installCommand.flags, installCommand.name); + const project = findProject(deps.cwd, deps.env); + if (project === null) throw new CliError('There is no project in this folder (no adminium.config.ts).', { code: EXIT_CONFIG }); + const need = installNeed(project.root); + if (boolFlag(values.check)) { + io.out(need === null ? 'in place' : `needed: ${need}`); + if (need !== null) io.out(installNeedWords(need)); + return need === null ? EXIT_OK : 3; + } + if (need !== null) io.out(installNeedWords(need)); + const result = await installProject(project.root, { env: deps.env, ...(deps.signal === undefined ? {} : { signal: deps.signal }) }); + if (result.note !== null) io.out(result.note); + if (result.output.trim() !== '') io.out(result.output); + if (!result.ok) { + io.err(result.stopped ? 'The install was stopped.' : result.timedOut ? 'The install took too long and was stopped.' : `\`${result.command}\` failed.`); + return EXIT_ERROR; + } + io.out('Installed.'); + return EXIT_OK; + }, +}; diff --git a/apps/server/src/cli/commands/new.ts b/apps/server/src/cli/commands/new.ts index 3103ed8d1..f5f8cfb18 100644 --- a/apps/server/src/cli/commands/new.ts +++ b/apps/server/src/cli/commands/new.ts @@ -35,6 +35,7 @@ import { runScript, type PackageManager, } from '../../project/package-manager.js'; +import { writeInstallStamp } from '../../project/install-stamp.js'; import { gitProgram, namedPackageManager, packageManagerProgram } from '../../project/programs.js'; import { createSampleDatabase } from '../../project/sample.js'; import { scaffoldProject } from '../../project/scaffold.js'; @@ -385,6 +386,8 @@ export const newCommand: Command = { const launch = program.launch(args); io.out(`Installing dependencies with ${command}…`); installed = run(launch.command, launch.args, { cwd: root, inherit: true, ...(Object.keys(launch.env).length === 0 ? {} : { env: launch.env }) }).status === 0; + // Marked as a finished install, so the desktop app does not install the same folder again. + if (installed) writeInstallStamp(root, APP_VERSION); if (!installed) io.err(`\`${command} ${args.join(' ')}\` failed. Fix the problem above and run it again.`); } diff --git a/apps/server/src/cli/run.ts b/apps/server/src/cli/run.ts index d715dfb83..5c0cb3404 100644 --- a/apps/server/src/cli/run.ts +++ b/apps/server/src/cli/run.ts @@ -8,6 +8,7 @@ */ import { designCommand } from './commands/design.js'; +import { installCommand } from './commands/install.js'; import { ownerCommand } from './commands/owner.js'; import { APP_VERSION } from '../version.js'; import { renderCommandHelp, renderRootHelp, type Command } from './command.js'; @@ -36,6 +37,7 @@ export const COMMANDS: readonly Command[] = [ newCommand, devCommand, designCommand, + installCommand, buildCommand, startCommand, checkCommand, diff --git a/apps/server/src/designer/ask-needs.ts b/apps/server/src/designer/ask-needs.ts index a08e9ff28..626021a9d 100644 --- a/apps/server/src/designer/ask-needs.ts +++ b/apps/server/src/designer/ask-needs.ts @@ -12,6 +12,8 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { applyLook, missingFonts, readLook, type Look } from '../project/apps/look.js'; +import { refreshInstallStamp } from '../project/install-stamp.js'; +import { APP_VERSION } from '../version.js'; import { addPackagesArgs, DESKTOP_PROGRAMS_ENV, packageManagerProgram } from '../project/programs.js'; import { APPS_DIR, type AppSide } from '../project/apps/read-app.js'; import { PUBLIC_CLIENT_PACKAGE } from '../project/apps/scaffold-app.js'; @@ -56,6 +58,8 @@ export function installer(root: string): NonNullable { const args = addPackagesArgs(manager, specs.map((spec) => `${spec.name}@${spec.version}`)); const launch = program.launch(args); const result = await runChild(launch.command, launch.args, { cwd: root, timeoutMs: INSTALL_TIMEOUT_MS, signal, env: { ...process.env, ...launch.env } }); + // The lockfile changed with what was added: an installed project stays marked as installed. + if (result.code === 0) refreshInstallStamp(root, APP_VERSION); return result.code === 0 ? null : `${manager} ${args.join(' ')} failed:\n${result.output.split('\n').slice(-30).join('\n')}`; }; } diff --git a/apps/server/src/project/install-stamp.ts b/apps/server/src/project/install-stamp.ts new file mode 100644 index 000000000..fe6458b31 --- /dev/null +++ b/apps/server/src/project/install-stamp.ts @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** + * `node_modules/.adminium-install.json` — the mark that a project's packages + * were installed, whole, for this machine. + * + * "Is it installed?" is not "is there a `node_modules` folder". An install that + * was cancelled leaves a folder that looks present and is missing half of what + * the build needs; a folder that travelled inside an archive from another + * machine holds that machine's programs (esbuild's, SQLite's, Tailwind's) and + * none of this one's. So the answer is a small file written only after an + * install succeeded, saying what it was an install OF (the lockfile, by hash) + * and FOR (the system and the chip), and anything else is "install again". + * + * The desktop app reads it before it starts a project's server. A terminal's + * own installs write it too, so a project made there is not installed twice. + */ +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +export const INSTALL_STAMP_FILE = join('node_modules', '.adminium-install.json'); + +export interface InstallStamp { + /** What was installed: a hash of `package-lock.json`, or of `package.json` where there is no lockfile. */ + lockHash: string; + /** `process.platform` and `process.arch` of the machine it was installed on. */ + system: string; + chip: string; + /** The Adminium that did it. Told to the person when it differs; never a reason of its own to install again. */ + engine: string; +} + +/** Why a project's packages must be installed (again), or `null` when they are in place. */ +export type InstallNeed = 'no-packages' | 'not-finished' | 'another-machine' | 'changed' | null; + +function hashOf(file: string): string | null { + try { + return createHash('sha256').update(readFileSync(file)).digest('hex'); + } catch { + return null; + } +} + +/** What an install of this folder would be an install of, as it stands now. `null`: there is no `package.json`. */ +export function projectLockHash(root: string): string | null { + const lock = hashOf(join(root, 'package-lock.json')); + if (lock !== null) return `lock:${lock}`; + const manifest = hashOf(join(root, 'package.json')); + return manifest === null ? null : `manifest:${manifest}`; +} + +export function readInstallStamp(root: string): InstallStamp | null { + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(join(root, INSTALL_STAMP_FILE), 'utf8')); + } catch { + return null; + } + if (typeof parsed !== 'object' || parsed === null) return null; + const stamp = parsed as Partial; + if (typeof stamp.lockHash !== 'string' || typeof stamp.system !== 'string' || typeof stamp.chip !== 'string' || typeof stamp.engine !== 'string') return null; + return { lockHash: stamp.lockHash, system: stamp.system, chip: stamp.chip, engine: stamp.engine }; +} + +/** + * Whether the project's packages must be installed, and why. + * + * The order is the order a person would be told in: nothing there; something + * there that never finished (or that this app did not install); installed on + * another kind of machine; installed, but the list of packages changed since. + */ +export function installNeed(root: string, machine: { system: string; chip: string } = { system: process.platform, chip: process.arch }): InstallNeed { + if (!existsSync(join(root, 'node_modules'))) return 'no-packages'; + const stamp = readInstallStamp(root); + if (stamp === null) return 'not-finished'; + if (stamp.system !== machine.system || stamp.chip !== machine.chip) return 'another-machine'; + return stamp.lockHash === projectLockHash(root) ? null : 'changed'; +} + +/** Take the mark away BEFORE an install starts: one that is cancelled or fails must leave none. */ +export function clearInstallStamp(root: string): void { + rmSync(join(root, INSTALL_STAMP_FILE), { force: true }); +} + +/** Write the mark, after an install succeeded. A folder with no `package.json` or no `node_modules` gets none. */ +export function writeInstallStamp(root: string, engine: string, machine: { system: string; chip: string } = { system: process.platform, chip: process.arch }): InstallStamp | null { + const lockHash = projectLockHash(root); + if (lockHash === null || !existsSync(join(root, 'node_modules'))) return null; + const stamp: InstallStamp = { lockHash, system: machine.system, chip: machine.chip, engine }; + const file = join(root, INSTALL_STAMP_FILE); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, `${JSON.stringify(stamp, null, 2)}\n`); + return stamp; +} + +/** + * After packages were ADDED to an installed project (a needs card, the screen + * packages): the lockfile changed with them, so the mark is brought up to + * date. A project with no mark keeps none: it was never installed whole. + */ +export function refreshInstallStamp(root: string, engine: string): void { + if (readInstallStamp(root) !== null) writeInstallStamp(root, engine); +} diff --git a/apps/server/src/project/install.ts b/apps/server/src/project/install.ts new file mode 100644 index 000000000..807f00d8a --- /dev/null +++ b/apps/server/src/project/install.ts @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** + * Install a project's packages, whole, and mark that it was done. + * + * One place for "the folder has a `package.json`; make its `node_modules` + * right": the desktop app runs it before it starts a project's server (a new + * project, a folder with none, a folder from another machine), through the + * hidden `adminium install`. + * + * - From the lockfile when there is one (`npm ci`: exactly what the lockfile + * says, or a refusal), else a plain install that writes one. + * - No package's own install scripts run. The engine's packages need none + * (their programs come prebuilt), and a script is somebody else's code run + * before the person was asked anything about it. + * - The mark is taken away first and written last, so an install that is + * stopped or fails leaves a folder that says "install me" (`install-stamp.ts`). + */ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +import { runChild } from '../designer/child.js'; +import { APP_VERSION } from '../version.js'; +import { clearInstallStamp, installNeed, writeInstallStamp, type InstallNeed } from './install-stamp.js'; +import { packageManagerProgram } from './programs.js'; + +export const PROJECT_INSTALL_TIMEOUT_MS = 15 * 60_000; + +export interface InstallResult { + ok: boolean; + /** Stopped by the signal (the person cancelled), as against a failure. */ + stopped: boolean; + timedOut: boolean; + /** The manager that ran, and the words it was run with (for the screen and the log). */ + manager: string; + command: string; + /** For a folder another manager made, in the desktop app: what the person is told. */ + note: string | null; + /** The end of what the manager printed. */ + output: string; +} + +/** The arguments for a whole install with `manager`: from the lockfile when npm has one. */ +export function wholeInstallArgs(manager: string, root: string): string[] { + if (manager === 'npm' && existsSync(join(root, 'package-lock.json'))) return ['ci', '--ignore-scripts']; + return ['install', '--ignore-scripts']; +} + +export async function installProject( + root: string, + opts: { env?: Readonly>; signal?: AbortSignal; timeoutMs?: number; run?: typeof runChild } = {}, +): Promise { + const env = opts.env ?? process.env; + const program = packageManagerProgram(root, env); + const args = wholeInstallArgs(program.manager, root); + const launch = program.launch(args); + clearInstallStamp(root); + const result = await (opts.run ?? runChild)(launch.command, launch.args, { + cwd: root, + timeoutMs: opts.timeoutMs ?? PROJECT_INSTALL_TIMEOUT_MS, + ...(opts.signal === undefined ? {} : { signal: opts.signal }), + env: { ...env, ...launch.env }, + }); + const ok = result.code === 0 && !result.stopped && !result.timedOut; + if (ok) writeInstallStamp(root, APP_VERSION); + return { + ok, + stopped: result.stopped, + timedOut: result.timedOut, + manager: program.manager, + command: `${program.manager} ${args.join(' ')}`, + note: program.note, + output: result.output.split('\n').slice(-40).join('\n'), + }; +} + +/** What the person is told for each reason a project's packages must be installed. */ +export function installNeedWords(need: Exclude): string { + switch (need) { + case 'no-packages': + return 'This project’s building blocks are not installed yet.'; + case 'not-finished': + return 'The last install of this project’s building blocks did not finish.'; + case 'another-machine': + return 'This project’s building blocks were installed on another kind of computer.'; + case 'changed': + return 'This project’s list of building blocks changed since they were installed.'; + } +} + +export { installNeed }; diff --git a/apps/server/test/install-stamp.test.ts b/apps/server/test/install-stamp.test.ts new file mode 100644 index 000000000..a92faca50 --- /dev/null +++ b/apps/server/test/install-stamp.test.ts @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { clearInstallStamp, INSTALL_STAMP_FILE, installNeed, projectLockHash, readInstallStamp, refreshInstallStamp, writeInstallStamp } from '../src/project/install-stamp.js'; + +const HERE = { system: 'darwin', chip: 'arm64' }; +let root: string; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'adminium-stamp-')); + writeFileSync(join(root, 'package.json'), '{"name":"p","private":true,"dependencies":{"react":"19.2.0"}}\n'); +}); +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); +const installed = (): void => { + mkdirSync(join(root, 'node_modules', 'react'), { recursive: true }); +}; + +describe('installNeed', () => { + it('a folder with no packages needs them', () => { + expect(installNeed(root, HERE)).toBe('no-packages'); + }); + + it('a node_modules folder is not an install: without the mark it never finished', () => { + installed(); + expect(installNeed(root, HERE)).toBe('not-finished'); + }); + + it('a finished install is in place', () => { + installed(); + expect(writeInstallStamp(root, '0.3.21', HERE)).toEqual({ lockHash: projectLockHash(root), system: 'darwin', chip: 'arm64', engine: '0.3.21' }); + expect(installNeed(root, HERE)).toBeNull(); + expect(JSON.parse(readFileSync(join(root, INSTALL_STAMP_FILE), 'utf8'))).toMatchObject({ engine: '0.3.21' }); + }); + + it('a cancelled install leaves no mark, because the mark is taken away first', () => { + installed(); + writeInstallStamp(root, '0.3.21', HERE); + clearInstallStamp(root); + // …the install is stopped here… + expect(installNeed(root, HERE)).toBe('not-finished'); + expect(() => clearInstallStamp(root)).not.toThrow(); + }); + + it('packages that came from another system or another chip are installed again', () => { + installed(); + writeInstallStamp(root, '0.3.21', { system: 'win32', chip: 'x64' }); + expect(installNeed(root, HERE)).toBe('another-machine'); + writeInstallStamp(root, '0.3.21', { system: 'darwin', chip: 'x64' }); + expect(installNeed(root, HERE)).toBe('another-machine'); + }); + + it('a list of packages that changed since is installed again; a lockfile outranks package.json', () => { + installed(); + writeInstallStamp(root, '0.3.21', HERE); + writeFileSync(join(root, 'package.json'), '{"name":"p","private":true,"dependencies":{"react":"19.2.1"}}\n'); + expect(installNeed(root, HERE)).toBe('changed'); + + writeFileSync(join(root, 'package-lock.json'), '{"lockfileVersion":3}'); + expect(projectLockHash(root)).toMatch(/^lock:[0-9a-f]{64}$/); + writeInstallStamp(root, '0.3.21', HERE); + // With a lockfile, package.json alone changing is not a change of what is installed. + writeFileSync(join(root, 'package.json'), '{"name":"renamed","private":true,"dependencies":{"react":"19.2.1"}}\n'); + expect(installNeed(root, HERE)).toBeNull(); + writeFileSync(join(root, 'package-lock.json'), '{"lockfileVersion":3,"packages":{}}'); + expect(installNeed(root, HERE)).toBe('changed'); + }); + + it('another Adminium having installed it is not a reason to install again', () => { + installed(); + writeInstallStamp(root, '0.3.19', HERE); + expect(installNeed(root, HERE)).toBeNull(); + expect(readInstallStamp(root)?.engine).toBe('0.3.19'); + }); +}); + +describe('the mark itself', () => { + it('is not written for a folder with nothing installed or nothing to install', () => { + expect(writeInstallStamp(root, '0.3.21', HERE)).toBeNull(); + installed(); + rmSync(join(root, 'package.json')); + expect(writeInstallStamp(root, '0.3.21', HERE)).toBeNull(); + expect(projectLockHash(root)).toBeNull(); + }); + + it('reads nothing from a file that is not one', () => { + installed(); + for (const text of ['', 'x', 'null', '{"lockHash":1}', '{"lockHash":"a","system":"darwin","chip":"arm64"}']) { + writeFileSync(join(root, INSTALL_STAMP_FILE), text); + expect(readInstallStamp(root)).toBeNull(); + expect(installNeed(root, HERE)).toBe('not-finished'); + } + }); + + it('is brought up to date when packages are added to an installed project, and never made for one that was not', () => { + installed(); + refreshInstallStamp(root, '0.3.21'); + expect(existsSync(join(root, INSTALL_STAMP_FILE))).toBe(false); + + writeInstallStamp(root, '0.3.21'); + writeFileSync(join(root, 'package-lock.json'), '{"lockfileVersion":3,"added":true}'); + expect(installNeed(root)).toBe('changed'); + refreshInstallStamp(root, '0.3.21'); + expect(installNeed(root)).toBeNull(); + }); +}); diff --git a/apps/server/test/project-install.test.ts b/apps/server/test/project-install.test.ts new file mode 100644 index 000000000..04e9474b7 --- /dev/null +++ b/apps/server/test/project-install.test.ts @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** + * Installing a project's packages whole: what is run, on a terminal and in the + * desktop app, and that the mark is there only after a success. + */ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { runCli } from '../src/cli/run.js'; +import type { ChildResult } from '../src/designer/child.js'; +import { INSTALL_STAMP_FILE, installNeed, writeInstallStamp } from '../src/project/install-stamp.js'; +import { installNeedWords, installProject, wholeInstallArgs } from '../src/project/install.js'; +import { DESKTOP_PROGRAMS_ENV } from '../src/project/programs.js'; +import { APP_VERSION } from '../src/version.js'; +import { fakeDeps, fakeIo } from './cli-helpers.js'; + +const APP = { binary: '/app/Adminium', npm: '/app/npm', shims: '/u/bin', git: null, npmUserConfig: '/u/user.npmrc', npmGlobalConfig: '/u/global.npmrc', npmCache: '/u/cache' }; +const DESKTOP = { [DESKTOP_PROGRAMS_ENV]: JSON.stringify(APP) }; + +let root: string; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'adminium-install-')); + writeFileSync(join(root, 'package.json'), '{"name":"p","private":true}\n'); + writeFileSync(join(root, 'adminium.config.mjs'), 'export default {};\n'); +}); +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +/** A manager that "installs": it makes node_modules, then ends as told. */ +const runner = (end: Partial = {}) => { + const calls: Array<{ command: string; args: readonly string[]; env: Record }> = []; + const run = async (command: string, args: readonly string[], opts: { cwd: string; env?: NodeJS.ProcessEnv }): Promise => { + calls.push({ command, args, env: opts.env ?? {} }); + // The mark must already be gone while the manager runs. + expect(existsSync(join(opts.cwd, INSTALL_STAMP_FILE))).toBe(false); + mkdirSync(join(opts.cwd, 'node_modules', 'react'), { recursive: true }); + return { code: 0, output: 'added 18 packages', timedOut: false, stopped: false, ...end }; + }; + return { run, calls }; +}; + +describe('installProject', () => { + it('on a terminal: the folder’s own manager, a plain install with no scripts, then the mark', async () => { + const { run, calls } = runner(); + const result = await installProject(root, { env: {}, run }); + expect(result).toMatchObject({ ok: true, manager: 'npm', command: 'npm install --ignore-scripts', note: null }); + expect(calls).toEqual([expect.objectContaining({ command: 'npm', args: ['install', '--ignore-scripts'] })]); + expect(installNeed(root)).toBeNull(); + }); + + it('from the lockfile when there is one', async () => { + writeFileSync(join(root, 'package-lock.json'), '{"lockfileVersion":3}'); + const { run, calls } = runner(); + expect((await installProject(root, { env: {}, run })).command).toBe('npm ci --ignore-scripts'); + expect(calls[0]?.args).toEqual(['ci', '--ignore-scripts']); + expect(wholeInstallArgs('pnpm', root)).toEqual(['install', '--ignore-scripts']); + }); + + it('in the desktop app: the carried npm through the app’s program, whatever the folder was made with, and it says so', async () => { + writeFileSync(join(root, 'pnpm-lock.yaml'), 'theirs'); + const { run, calls } = runner(); + const result = await installProject(root, { env: DESKTOP, run }); + expect(calls[0]?.command).toBe('/app/Adminium'); + expect(calls[0]?.args).toEqual([join('/app/npm', 'bin', 'npm-cli.js'), 'install', '--ignore-scripts']); + expect(calls[0]?.env).toMatchObject({ ELECTRON_RUN_AS_NODE: '1', NPM_CONFIG_REGISTRY: 'https://registry.npmjs.org/', NPM_CONFIG_IGNORE_SCRIPTS: 'true' }); + expect(result.manager).toBe('npm'); + expect(result.note).toBe('This project uses pnpm. The app installs with npm; your pnpm file (pnpm-lock.yaml) is left as it is.'); + expect(installNeed(root)).toBeNull(); + }); + + it.each([ + ['fails', { code: 1 }, { ok: false, stopped: false, timedOut: false }], + ['is cancelled', { code: null, stopped: true }, { ok: false, stopped: true }], + ['takes too long', { code: null, timedOut: true }, { ok: false, timedOut: true }], + ])('an install that %s leaves no mark, even over one that was there', async (_label, end, expected) => { + mkdirSync(join(root, 'node_modules'), { recursive: true }); + writeInstallStamp(root, APP_VERSION); + const { run } = runner(end as Partial); + expect(await installProject(root, { env: {}, run })).toMatchObject(expected); + expect(installNeed(root)).toBe('not-finished'); + }); + + it('has words for each reason', () => { + for (const need of ['no-packages', 'not-finished', 'another-machine', 'changed'] as const) expect(installNeedWords(need)).toMatch(/building blocks/); + }); +}); + +describe('adminium install --check', () => { + const check = async (): Promise<{ code: number; out: string }> => { + const io = fakeIo(); + const code = await runCli(['install', '--check'], { io, deps: fakeDeps({ cwd: root, env: {} }) }); + return { code, out: io.stdout() }; + }; + + it('says whether an install is needed and why, and installs nothing', async () => { + expect(await check()).toEqual({ code: 3, out: expect.stringContaining('needed: no-packages') as string }); + mkdirSync(join(root, 'node_modules')); + expect((await check()).out).toContain('needed: not-finished'); + writeInstallStamp(root, APP_VERSION); + expect(await check()).toEqual({ code: 0, out: expect.stringContaining('in place') as string }); + }); + + it('is not listed in the help, and refuses a folder that is no project', async () => { + const io = fakeIo(); + await runCli(['--help'], { io, deps: fakeDeps({ cwd: root, env: {} }) }); + expect(io.stdout()).not.toMatch(/^\s+install\s/m); + rmSync(join(root, 'adminium.config.mjs')); + const refused = fakeIo(); + expect(await runCli(['install', '--check'], { io: refused, deps: fakeDeps({ cwd: root, env: {} }) })).not.toBe(0); + expect(refused.stderr()).toContain('There is no project in this folder'); + }); +}); From 32f370cdbd11b03a90b2eaef05833aaf44ae15b1 Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Sat, 10 Oct 2026 00:10:08 +0200 Subject: [PATCH 24/79] feat(desktop): a new project installs from the lockfile the app carries --- .gitignore | 3 + apps/desktop/src/main/index.ts | 2 + apps/desktop/src/main/programs.test.ts | 15 +++- apps/desktop/src/main/programs.ts | 5 ++ apps/server/src/cli/commands/new.ts | 15 +++- apps/server/src/project/programs.ts | 3 + apps/server/src/project/starter-lock.ts | 109 ++++++++++++++++++++++++ apps/server/test/programs.test.ts | 5 ++ apps/server/test/starter-lock.test.ts | 85 ++++++++++++++++++ scripts/release/starter-lockfile.mjs | 86 +++++++++++++++++++ 10 files changed, 325 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/project/starter-lock.ts create mode 100644 apps/server/test/starter-lock.test.ts create mode 100644 scripts/release/starter-lockfile.mjs diff --git a/.gitignore b/.gitignore index c8940a3eb..f4b93a222 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,6 @@ scripts/release/out/ # locks. Ignore the whole directory. Nothing here is shared today; add a negated # `!` line if a specific config ever needs to be committed. /.claude/ + +# Made at release (scripts/release/starter-lockfile.mjs): the lockfile a new project starts with. +apps/desktop/resources/starter/ diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 17617aaed..8f1e4cadd 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -2123,6 +2123,8 @@ export function electronBootDeps(): DesktopBootDeps { npmDir: carriedNpmDir(app.isPackaged, process.resourcesPath, dirname(fileURLToPath(import.meta.url))), userDataDir, git: 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, }), ), diff --git a/apps/desktop/src/main/programs.test.ts b/apps/desktop/src/main/programs.test.ts index 0d46c4498..d11b506c3 100644 --- a/apps/desktop/src/main/programs.test.ts +++ b/apps/desktop/src/main/programs.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only import { execFileSync } from 'node:child_process'; -import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -30,6 +30,7 @@ describe('provideDesktopPrograms', () => { npmUserConfig: join(userData, 'programs', 'npm', 'user.npmrc'), npmGlobalConfig: join(userData, 'programs', 'npm', 'global.npmrc'), npmCache: join(userData, 'programs', 'npm', 'cache'), + 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); @@ -121,3 +122,15 @@ describe('carriedNpmDir', () => { 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, 'package-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 index 83e5d804d..716fb538f 100644 --- a/apps/desktop/src/main/programs.ts +++ b/apps/desktop/src/main/programs.ts @@ -26,6 +26,7 @@ export interface DesktopProgramsValue { npmUserConfig: string; npmGlobalConfig: string; npmCache: string; + starter: string | null; } export interface ProvideProgramsInput { @@ -37,6 +38,8 @@ export interface ProvideProgramsInput { 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; } @@ -131,6 +134,8 @@ export function provideDesktopPrograms(input: ProvideProgramsInput): DesktopProg npmUserConfig: join(npmHome, 'user.npmrc'), npmGlobalConfig: join(npmHome, 'global.npmrc'), npmCache: join(npmHome, 'cache'), + // Named only when it is really there: a build made outside a release carries none. + starter: input.starterDir != null && existsSync(join(input.starterDir, 'package-lock.json')) ? input.starterDir : null, }; mkdirSync(shims, { recursive: true }); mkdirSync(value.npmCache, { recursive: true }); diff --git a/apps/server/src/cli/commands/new.ts b/apps/server/src/cli/commands/new.ts index f5f8cfb18..98dc593a6 100644 --- a/apps/server/src/cli/commands/new.ts +++ b/apps/server/src/cli/commands/new.ts @@ -36,7 +36,9 @@ import { type PackageManager, } from '../../project/package-manager.js'; import { writeInstallStamp } from '../../project/install-stamp.js'; -import { gitProgram, namedPackageManager, packageManagerProgram } from '../../project/programs.js'; +import { wholeInstallArgs } from '../../project/install.js'; +import { desktopPrograms, gitProgram, namedPackageManager, packageManagerProgram } from '../../project/programs.js'; +import { addScreenPackagesTo, applyStarterLock } from '../../project/starter-lock.js'; import { createSampleDatabase } from '../../project/sample.js'; import { scaffoldProject } from '../../project/scaffold.js'; import { APP_VERSION } from '../../version.js'; @@ -382,7 +384,16 @@ export const newCommand: Command = { let installed: boolean | null = null; if (!boolFlag(values['no-install'])) { - const { command, args } = installCommand(packageManager); + // Inside the desktop app a new project is one the Designer will build screens in: what those need is listed + // now, and the lockfile the app carries is laid in, so this first install is the only one and installs exactly + // what the release was tried with. + const inApp = desktopPrograms(deps.env) !== null; + if (inApp) { + addScreenPackagesTo(root, APP_VERSION); + if (applyStarterLock(root, deps.env)) io.out('Installing exactly what this version of Adminium was tried with.'); + } + const { command } = installCommand(packageManager); + const args = inApp ? wholeInstallArgs(packageManager, root) : installCommand(packageManager).args; const launch = program.launch(args); io.out(`Installing dependencies with ${command}…`); installed = run(launch.command, launch.args, { cwd: root, inherit: true, ...(Object.keys(launch.env).length === 0 ? {} : { env: launch.env }) }).status === 0; diff --git a/apps/server/src/project/programs.ts b/apps/server/src/project/programs.ts index 837649556..f3cfbf917 100644 --- a/apps/server/src/project/programs.ts +++ b/apps/server/src/project/programs.ts @@ -42,6 +42,8 @@ export interface DesktopPrograms { npmGlobalConfig: string; /** npm's cache, in the app's own folder. */ npmCache: string; + /** The folder of the lockfile a new project starts with (`starter-lock.ts`), or `null`: this build carries none. */ + starter: string | null; } /** What it takes to start a program: the file, its arguments, and what to add to the child's environment. */ @@ -84,6 +86,7 @@ export function desktopPrograms(env: Readonly npmUserConfig: path('npmUserConfig'), npmGlobalConfig: path('npmGlobalConfig'), npmCache: path('npmCache'), + starter: value['starter'] === undefined || value['starter'] === null ? null : path('starter'), }; } diff --git a/apps/server/src/project/starter-lock.ts b/apps/server/src/project/starter-lock.ts new file mode 100644 index 000000000..1c173efce --- /dev/null +++ b/apps/server/src/project/starter-lock.ts @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** + * The lockfile a new project starts with, inside the desktop app. + * + * A new project's `package.json` names ranges ("^19.2.0"), so two people who + * make a project a month apart get different packages, and a release that was + * walked through and found good installs something else the week after. The + * app therefore carries the lockfile its release was tried with: made at + * release for exactly what a new project lists (`scripts/release/ + * starter-lockfile.mjs`), and laid into a new project so its first install is + * `npm ci`, which installs what the lockfile says or refuses. + * + * It is used only when it FITS: the new project's dependencies must be, name + * for name and range for range, what the lockfile was made for. A project + * made with a flag that changes them simply installs the ordinary way. + */ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { DESIGNER_REACT_VERSION } from '../designer/needs.js'; +import { PUBLIC_CLIENT_PACKAGE } from './apps/scaffold-app.js'; +import { desktopPrograms } from './programs.js'; + +/** The two files of a carried starter: the lockfile, and what it was made for. */ +export const STARTER_LOCK = 'package-lock.json'; +export const STARTER_MANIFEST = 'starter.json'; + +/** The React a project's screens are built with: what the Designer adds to a project it makes. */ +export const STARTER_REACT_VERSION = DESIGNER_REACT_VERSION; + +type Dependencies = Record; +interface Listed { + dependencies: Dependencies; + devDependencies: Dependencies; +} + +const record = (value: unknown): Dependencies => (typeof value === 'object' && value !== null ? { ...(value as Dependencies) } : {}); +const same = (a: Dependencies, b: Dependencies): boolean => { + const keys = Object.keys(a).sort(); + return keys.length === Object.keys(b).length && keys.every((key) => a[key] === b[key]); +}; +const readJson = (file: string): Record | null => { + try { + const parsed: unknown = JSON.parse(readFileSync(file, 'utf8')); + return typeof parsed === 'object' && parsed !== null ? (parsed as Record) : null; + } catch { + return null; + } +}; + +/** What a project lists, as a lockfile is made for it. */ +export function listedDependencies(manifest: Record): Listed { + return { dependencies: record(manifest['dependencies']), devDependencies: record(manifest['devDependencies']) }; +} + +/** + * Add what an app's own screens are built with (React, and Adminium's public + * client at the engine's own version), exact, to a project's `package.json`. + * The Designer adds the same three to a project it makes on a terminal; here + * they are written before the first install, so that install is the only one. + */ +export function addScreenPackagesTo(root: string, version: string): void { + const file = join(root, 'package.json'); + const manifest = readJson(file); + if (manifest === null) return; + const dependencies = record(manifest['dependencies']); + dependencies['react'] = STARTER_REACT_VERSION; + dependencies['react-dom'] = STARTER_REACT_VERSION; + dependencies[PUBLIC_CLIENT_PACKAGE] = version; + manifest['dependencies'] = dependencies; + writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`); +} + +/** + * The carried lockfile's text for the project in `root`, with the project's + * own name on it, or `null`: there is none, the project already has a + * lockfile, or the lockfile was made for other dependencies. + */ +export function starterLockFor(root: string, starterDir: string): string | null { + if (existsSync(join(root, STARTER_LOCK))) return null; + const project = readJson(join(root, 'package.json')); + const madeFor = readJson(join(starterDir, STARTER_MANIFEST)); + const lock = readJson(join(starterDir, STARTER_LOCK)); + if (project === null || madeFor === null || lock === null) return null; + const wants = listedDependencies(project); + const has = listedDependencies(madeFor); + if (!same(wants.dependencies, has.dependencies) || !same(wants.devDependencies, has.devDependencies)) return null; + const packages = typeof lock['packages'] === 'object' && lock['packages'] !== null ? (lock['packages'] as Record>) : null; + const top = packages?.['']; + if (packages === null || top === undefined) return null; + const name = typeof project['name'] === 'string' ? project['name'] : 'project'; + const version = typeof project['version'] === 'string' ? project['version'] : undefined; + lock['name'] = name; + if (version === undefined) delete lock['version']; + else lock['version'] = version; + packages[''] = { ...top, name, ...(version === undefined ? {} : { version }) }; + if (version === undefined) delete packages['']['version']; + return `${JSON.stringify(lock, null, 2)}\n`; +} + +/** Lay the carried lockfile into a new project, when the app carries one that fits. Says whether it did. */ +export function applyStarterLock(root: string, env: Readonly> = process.env): boolean { + const starter = desktopPrograms(env)?.starter ?? null; + if (starter === null) return false; + const text = starterLockFor(root, starter); + if (text === null) return false; + writeFileSync(join(root, STARTER_LOCK), text); + return true; +} diff --git a/apps/server/test/programs.test.ts b/apps/server/test/programs.test.ts index 2179dfe69..1f85b349d 100644 --- a/apps/server/test/programs.test.ts +++ b/apps/server/test/programs.test.ts @@ -30,6 +30,7 @@ const APP = { npmUserConfig: '/Users/a/Library/Application Support/Adminium/npm/user.npmrc', npmGlobalConfig: '/Users/a/Library/Application Support/Adminium/npm/global.npmrc', npmCache: '/Users/a/Library/Application Support/Adminium/npm/cache', + starter: null as string | null, }; const desktop = (over: Record = {}): Record => ({ [DESKTOP_PROGRAMS_ENV]: JSON.stringify({ ...APP, ...over }) }); const TERMINAL: Record = {}; @@ -51,6 +52,10 @@ describe('desktopPrograms', () => { it('reads what the app wrote', () => { expect(desktopPrograms(desktop())).toEqual(APP); expect(desktopPrograms(desktop({ git: null }))?.git).toBeNull(); + // The starter's lockfile is carried by a release build, and by no other. + expect(desktopPrograms(desktop({ starter: '/Applications/Adminium.app/Contents/Resources/starter' }))?.starter).toBe('/Applications/Adminium.app/Contents/Resources/starter'); + expect(desktopPrograms(desktop({ starter: undefined }))?.starter).toBeNull(); + expect(() => desktopPrograms(desktop({ starter: 'starter' }))).toThrow(/"starter" must be an absolute path/); }); it.each([ diff --git a/apps/server/test/starter-lock.test.ts b/apps/server/test/starter-lock.test.ts new file mode 100644 index 000000000..10ece5452 --- /dev/null +++ b/apps/server/test/starter-lock.test.ts @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { DESKTOP_PROGRAMS_ENV } from '../src/project/programs.js'; +import { addScreenPackagesTo, applyStarterLock, STARTER_REACT_VERSION, starterLockFor } from '../src/project/starter-lock.js'; + +const LISTED = { dependencies: { '@adminiumjs/adminium': '0.3.21', react: '19.2.0' }, devDependencies: { esbuild: '^0.28.0' } }; +let root: string; +let starter: string; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'adminium-starter-')); + starter = join(root, '.carried'); + mkdirSync(starter); + writeFileSync(join(starter, 'starter.json'), JSON.stringify(LISTED)); + writeFileSync(join(starter, 'package-lock.json'), JSON.stringify({ name: 'starter', version: '0.0.0', lockfileVersion: 3, packages: { '': { name: 'starter', version: '0.0.0', ...LISTED }, 'node_modules/react': { version: '19.2.0', integrity: 'sha512-x' } } })); + writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'juniper-kitchen', version: '0.1.0', private: true, ...LISTED })); +}); +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); +const env = (): Record => ({ + [DESKTOP_PROGRAMS_ENV]: JSON.stringify({ binary: '/a', npm: '/n', shims: '/s', git: null, npmUserConfig: '/u', npmGlobalConfig: '/g', npmCache: '/c', starter }), +}); + +describe('the lockfile a new project starts with', () => { + it('is laid in under the project’s own name when it was made for exactly what the project lists', () => { + expect(applyStarterLock(root, env())).toBe(true); + const lock = JSON.parse(readFileSync(join(root, 'package-lock.json'), 'utf8')) as { name: string; version: string; packages: Record }; + expect(lock.name).toBe('juniper-kitchen'); + expect(lock.packages['']).toMatchObject({ name: 'juniper-kitchen', version: '0.1.0' }); + // What it pins is untouched. + expect(lock.packages['node_modules/react']).toEqual({ version: '19.2.0', integrity: 'sha512-x' }); + }); + + it.each([ + ['another range', (m: typeof LISTED) => ({ ...m, dependencies: { ...m.dependencies, react: '^19.2.0' } })], + ['one more package', (m: typeof LISTED) => ({ ...m, dependencies: { ...m.dependencies, zod: '4.0.0' } })], + ['one package fewer', (m: typeof LISTED) => ({ ...m, devDependencies: {} })], + ])('is not used for a project that lists %s: that one installs the ordinary way', (_label, change) => { + writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'p', ...change(LISTED) })); + expect(starterLockFor(root, starter)).toBeNull(); + expect(applyStarterLock(root, env())).toBe(false); + expect(existsSync(join(root, 'package-lock.json'))).toBe(false); + }); + + it('never replaces a lockfile the project already has', () => { + writeFileSync(join(root, 'package-lock.json'), '{"theirs":true}'); + expect(applyStarterLock(root, env())).toBe(false); + expect(readFileSync(join(root, 'package-lock.json'), 'utf8')).toBe('{"theirs":true}'); + }); + + it('is nothing on a terminal, in a build that carries none, or when what is carried is not whole', () => { + expect(applyStarterLock(root, {})).toBe(false); + rmSync(join(starter, 'package-lock.json')); + expect(applyStarterLock(root, env())).toBe(false); + writeFileSync(join(starter, 'package-lock.json'), '{"lockfileVersion":3}'); + expect(starterLockFor(root, starter)).toBeNull(); + }); + + it('a project with no version of its own gets a lockfile with none', () => { + writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'p', ...LISTED })); + const lock = JSON.parse(starterLockFor(root, starter) ?? '{}') as { version?: string; packages: Record }; + expect(lock).not.toHaveProperty('version'); + expect(lock.packages['']).not.toHaveProperty('version'); + }); +}); + +describe('addScreenPackagesTo', () => { + it('lists React and the public client, exact, beside what is there', () => { + addScreenPackagesTo(root, '0.3.21'); + const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { dependencies: Record; devDependencies: Record }; + expect(manifest.dependencies).toEqual({ '@adminiumjs/adminium': '0.3.21', react: STARTER_REACT_VERSION, 'react-dom': STARTER_REACT_VERSION, '@adminiumjs/public-client': '0.3.21' }); + expect(manifest.devDependencies).toEqual(LISTED.devDependencies); + }); + + it('leaves a folder with no package.json alone', () => { + rmSync(join(root, 'package.json')); + expect(() => addScreenPackagesTo(root, '0.3.21')).not.toThrow(); + expect(existsSync(join(root, 'package.json'))).toBe(false); + }); +}); diff --git a/scripts/release/starter-lockfile.mjs b/scripts/release/starter-lockfile.mjs new file mode 100644 index 000000000..dec0efa47 --- /dev/null +++ b/scripts/release/starter-lockfile.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: AGPL-3.0-only +/** + * Make the lockfile a new project starts with inside the desktop app. + * + * node scripts/release/starter-lockfile.mjs + * + * It makes a new project exactly as the app does (the built CLI's `new`, then + * the three packages an app's screens are built with), asks npm to resolve it + * without installing anything, and writes two files the app carries: + * + * apps/desktop/resources/starter/package-lock.json what to install + * apps/desktop/resources/starter/starter.json what it was made for + * + * A new project is given this lockfile only when it lists exactly what + * `starter.json` says (`apps/server/src/project/starter-lock.ts`), and is then + * installed with `npm ci`: the packages a release was tried with are the + * packages a person gets, this month and next. + * + * RUN IT AFTER THE ENGINE IS ON NPM. A new project pins the engine's exact + * version, so that version must be published before a lockfile for it can + * exist; this script asks the registry first and says so plainly when it is + * not there, because the other way to find out is a person's first project + * failing to install. + * + * It needs the server built (`apps/server/dist`) and the network (package + * metadata only; nothing is installed). The two files are not committed. + */ +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const DIST = join(ROOT, 'apps', 'server', 'dist'); +const OUT = join(ROOT, 'apps', 'desktop', 'resources', 'starter'); +const REGISTRY = 'https://registry.npmjs.org/'; + +const fail = (message) => { + console.error(`starter-lockfile: ${message}`); + process.exit(1); +}; +const run = (command, args, opts = {}) => spawnSync(command, args, { encoding: 'utf8', ...opts }); + +let starter; +let version; +try { + starter = await import(pathToFileURL(join(DIST, 'project', 'starter-lock.js')).href); + ({ APP_VERSION: version } = await import(pathToFileURL(join(DIST, 'version.js')).href)); +} catch { + fail(`the server is not built (${DIST}). Run \`pnpm --filter @adminium/server build\` first.`); +} + +// The engine, and the public client at the same version: both must be on npm (a new project pins them exactly). +for (const name of ['@adminiumjs/adminium', '@adminiumjs/public-client']) { + const seen = run('npm', ['view', `${name}@${version}`, 'version', '--registry', REGISTRY]); + if (seen.status !== 0 || seen.stdout.trim() !== version) { + fail(`${name}@${version} is not on npm. Publish the engine first: a new project in the app installs exactly that version.`); + } +} + +const work = mkdtempSync(join(tmpdir(), 'adminium-starter-')); +try { + const made = run(process.execPath, [join(DIST, 'cli', 'index.js'), 'new', 'starter', '--yes', '--no-install', '--no-git', '--database', 'sqlite:./data/app.sqlite'], { + cwd: work, + env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '' }, + }); + if (made.status !== 0) fail(`\`adminium new\` failed:\n${made.stdout}${made.stderr}`); + const project = join(work, 'starter'); + starter.addScreenPackagesTo(project, version); + + const locked = run('npm', ['install', '--package-lock-only', '--ignore-scripts', '--no-audit', '--no-fund', '--registry', REGISTRY], { cwd: project }); + if (locked.status !== 0) fail(`npm could not resolve the starter:\n${locked.stdout}${locked.stderr}`); + + const manifest = JSON.parse(readFileSync(join(project, 'package.json'), 'utf8')); + const lock = readFileSync(join(project, 'package-lock.json'), 'utf8'); + const packages = Object.keys(JSON.parse(lock).packages ?? {}).length - 1; + rmSync(OUT, { recursive: true, force: true }); + mkdirSync(OUT, { recursive: true }); + writeFileSync(join(OUT, starter.STARTER_LOCK), lock); + writeFileSync(join(OUT, starter.STARTER_MANIFEST), `${JSON.stringify({ engine: version, ...starter.listedDependencies(manifest) }, null, 2)}\n`); + console.log(`starter-lockfile: ${packages} packages locked for Adminium ${version} → ${OUT}`); +} finally { + rmSync(work, { recursive: true, force: true }); +} From fe46cb481202e7ad4b783047293c2622384956af Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Sat, 10 Oct 2026 00:12:11 +0200 Subject: [PATCH 25/79] feat(desktop): the offline gate checks what a project may reach --- .../src/test/check-offline-assets.test.ts | 67 ++++++++++++- scripts/check-offline-assets.mjs | 95 ++++++++++++++++--- 2 files changed, 149 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/test/check-offline-assets.test.ts b/apps/desktop/src/test/check-offline-assets.test.ts index fcbfee986..6d6962085 100644 --- a/apps/desktop/src/test/check-offline-assets.test.ts +++ b/apps/desktop/src/test/check-offline-assets.test.ts @@ -44,9 +44,9 @@ interface Run { } /** Spawn the gate over `dir`. Never throws — the exit code IS the assertion. */ -function run(dir: string): Run { +function run(dir: string, as: '--root' | '--project-root' = '--root'): Run { try { - const stdout = execFileSync(process.execPath, [script, '--root', dir], { + const stdout = execFileSync(process.execPath, [script, as, dir], { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], @@ -302,3 +302,66 @@ describe('check-offline-assets — what it must NOT flag', () => { expect(result.status).toBe(0); }); }); + +// ─── the second half: what a project may reach ─────────────────────────────── + +describe('check-offline-assets — a project reaches a written list, and the classic workspace still reaches nothing', () => { + const inProject = (code: string): Run => run(fixture({ 'server.js': code }), '--project-root'); + const inClassic = (code: string): Run => run(fixture({ 'main.js': code })); + + it.each([ + ['the npm registry, for a project’s packages', 'const REGISTRY = "https://registry.npmjs.org/";'], + ['an app’s archive at its tag', 'fetch(`https://codeload.github.com/${owner}/${repo}/tar.gz/refs/tags/v${version}`);'], + ['the pattern that recognizes a repository’s address', 'const found = /^https:\\/\\/github\\.com\\/([^/]+)\\/([^/]+)/.exec(url);'], + ['picture search on a site the person allowed', 'fetch(`https://api.pexels.com/v1/search?${query.toString()}`); fetch("https://api.openverse.org/v1/images/"); fetch("https://api.unsplash.com/search/photos");'], + ['the licence pages picture search links to', 'const licences = ["https://www.pexels.com/license/", "https://unsplash.com/license"];'], + ['the preview’s own name on this machine', 'const preview = `http://localhost:${String(port)}`;'], + ['a bare scheme prefix, which is no address', 'const schemes = ["http://", "https://"];'], + ])('a project’s server may name %s', (_label, code) => { + const result = inProject(code); + expect(result.output).toContain('[offline-assets] ok'); + expect(result.status).toBe(0); + }); + + it.each([ + ['the npm registry', 'const REGISTRY = "https://registry.npmjs.org/";', 'registry.npmjs.org'], + ['an app’s archive', 'fetch("https://codeload.github.com/a/b/tar.gz/refs/tags/v1");', 'codeload.github.com'], + ['a picture site', 'fetch("https://api.pexels.com/v1/search");', 'api.pexels.com'], + ])('the classic workspace’s own output may not name %s: it makes no request', (_label, code, host) => { + const result = inClassic(code); + expect(result.status).toBe(1); + expect(result.output).toContain('rule: project-address-in-the-classic-workspace'); + expect(result.output).toContain(`'${host}' is an address only a project's server may reach`); + }); + + it('a project’s server naming an address nobody wrote down fails, by its own rule', () => { + const result = inProject('fetch("https://telemetry.somewhere-new.io/v1/collect");'); + expect(result.status).toBe(1); + expect(result.output).toContain('rule: unlisted-project-address'); + expect(result.output).toContain('telemetry.somewhere-new.io'); + }); + + it('another registry is not the registry', () => { + for (const code of ['const R = "https://registry.npmjs.org.evil-mirror.io/";', 'const R = "https://registry.yarnpkg.com/";', 'const R = "https://npm.pkg.github.com/";']) { + expect(inProject(code).status, code).toBe(1); + } + }); + + it('what is blocked everywhere is blocked in a project too', () => { + const result = inProject('const css = "https://fonts.googleapis.com/css2?family=Manrope";'); + expect(result.status).toBe(1); + expect(result.output).toContain('rule: blocked-host'); + }); + + it('the real outputs pass: the code that serves a project names only what is on the list', () => { + // The default run is every root, both halves; it needs the builds, as the gate itself does. + let output: string; + try { + output = execFileSync(process.execPath, [script], { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + } catch (error) { + output = `${(error as { stdout?: string }).stdout ?? ''}${(error as { stderr?: string }).stderr ?? ''}`; + } + if (output.includes('MISSING BUILD OUTPUT')) return; + expect(output).toMatch(/\[offline-assets\] ok — \d+ file\(s\) across 6 build output\(s\)/); + }); +}); diff --git a/scripts/check-offline-assets.mjs b/scripts/check-offline-assets.mjs index 8c62d6eac..a4e0aa2e2 100644 --- a/scripts/check-offline-assets.mjs +++ b/scripts/check-offline-assets.mjs @@ -7,8 +7,21 @@ * > dashboard build for `https?://` string literals outside an allowlist (docs * > deep-links, `shell.openExternal` targets). Runs in CI on every desktop build. * - * The promise is that "the desktop build must be fully functional with the network - * cable unplugged, forever". The offline smoke test proves that for the paths it + * The promise has two halves since the app also serves project folders, and + * the gate checks each against its own list: + * + * - THE CLASSIC WORKSPACE MAKES NO REQUEST. "The desktop build must be fully + * functional with the network cable unplugged, forever": the window, the + * dashboard and the main process name no remote address at all. + * - A PROJECT REACHES A SHORT, WRITTEN LIST, each address for one thing a + * person did: the npm registry once for a project's packages, the place an + * app was copied from, a picture site the person asked for, and the model + * they connected. That list is {@link PROJECT_REACHES}; the code that serves + * a project is scanned against it, so a new address there fails here and is + * read by someone before it ships. Without that half, this gate stayed + * green while "no request, ever" stopped being true of the app. + * + * What follows was written for the first half and holds for both. The offline smoke test proves that for the paths it * walks; this gate covers what a smoke test structurally cannot — a remote asset * on a page nobody clicked during the run. A `` to Google Fonts on the login * screen, a CDN ` + + diff --git a/apps/desktop/src/renderer/app/main.tsx b/apps/desktop/src/renderer/app/main.tsx new file mode 100644 index 000000000..dd8433b11 --- /dev/null +++ b/apps/desktop/src/renderer/app/main.tsx @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { I18nProvider } from '@adminium/i18n/react'; +import { ThemeProvider } from '@adminium/ui'; + +import { App } from './App.js'; +import { startApi } from './bridge.js'; +import { initWords, localeFor } from './words.js'; +import './styles.css'; + +async function start(): Promise { + const state = await startApi().state(); + const locale = localeFor(state.language, navigator.language); + const i18n = await initWords(locale); + + const container = document.getElementById('root'); + if (container === null) throw new Error('missing #root container'); + createRoot(container).render( + + + {/* No control for either on these pages: they follow the app's saved choice, set in the window's menu. */} + + + + + , + ); +} + +void start(); diff --git a/apps/desktop/src/renderer/app/new/NewProjectScreen.tsx b/apps/desktop/src/renderer/app/new/NewProjectScreen.tsx new file mode 100644 index 000000000..b6f806974 --- /dev/null +++ b/apps/desktop/src/renderer/app/new/NewProjectScreen.tsx @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** + * New app: a name, and where its project is kept. The folder is always one of + * its own, made from the name; main judges it as the name is typed and again + * when "Create" is pressed. + */ +import { useT } from '@adminium/i18n/react'; +import { Ban, LoaderCircle, TriangleAlert } from 'lucide-react'; +import { useEffect, useId, useRef, useState, type ReactNode } from 'react'; + +import type { DesktopFolderRefusal, DesktopFolderWarning, DesktopNewFolderJudgement } from '../../../preload/api.js'; +import { startApi } from '../bridge.js'; +import { BackLink } from '../shell/PlainShell.js'; + +type Say = (title: string, variant?: 'success' | 'error' | 'info') => void; +type T = (key: string, fallback: string, args?: Record) => string; + +/** The sentence for each thing a person is warned of and may still choose. */ +export function warningWords(t: T, warning: DesktopFolderWarning): string { + switch (warning) { + case 'icloud': + return t('desktop:new.warn.icloud', 'This folder is synced by iCloud Drive. Projects work badly in synced folders: the sync can damage their data.'); + case 'onedrive': + return t('desktop:new.warn.onedrive', 'This folder is synced by OneDrive. Projects work badly in synced folders: the sync can damage their data.'); + case 'dropbox': + return t('desktop:new.warn.dropbox', 'This folder is synced by Dropbox. Projects work badly in synced folders: the sync can damage their data.'); + case 'googledrive': + return t('desktop:new.warn.googledrive', 'This folder is synced by Google Drive. Projects work badly in synced folders: the sync can damage their data.'); + case 'no-links': + return t('desktop:new.warn.noLinks', 'This disk cannot hold the links a project’s packages need, so getting them is likely to fail.'); + } +} + +/** The sentence for each folder a project may not go in; `null` when the empty name says it all. */ +export function refusalWords(t: T, refused: DesktopFolderRefusal): string | null { + switch (refused) { + case 'no-name': + return null; + case 'bad-name': + return t('desktop:new.refuse.badName', 'Use at least one letter or number in the name.'); + case 'home-folder': + return t('desktop:new.refuse.homeFolder', 'A project cannot be kept directly in your home folder. Choose or make a folder inside it.'); + case 'system-folder': + return t('desktop:new.refuse.systemFolder', 'A project cannot be kept in a folder that belongs to the system. Choose a folder of your own.'); + case 'inside-the-app': + return t('desktop:new.refuse.insideTheApp', 'A project cannot be kept inside Adminium itself. Choose another folder.'); + case 'inside-a-project': + return t('desktop:new.refuse.insideAProject', 'This folder is inside another project. Choose a folder outside it.'); + case 'exists-with-files': + return t('desktop:new.refuse.existsWithFiles', 'A folder with this name is already there and holds files. Choose another name or another folder.'); + case 'not-absolute': + return t('desktop:new.refuse.notAbsolute', 'Choose a folder with the “Change…” button.'); + } +} + +export function NewProjectScreen({ + proposedParent, + proposedParentDisplay, + onBack, + say, +}: { + proposedParent: string; + proposedParentDisplay: string; + onBack: () => void; + say: Say; +}): ReactNode { + const t = useT(); + const nameId = useId(); + const whereId = useId(); + const [name, setName] = useState(''); + const [parent, setParent] = useState(proposedParent); + const [judged, setJudged] = useState(null); + // The warning the person chose to go past, for THIS folder only: another folder asks again. + const [acceptedFor, setAcceptedFor] = useState(null); + const [creating, setCreating] = useState(false); + const [failure, setFailure] = useState(null); + const asked = useRef(0); + + useEffect(() => { + const mine = (asked.current += 1); + void startApi() + .judgeNewFolder({ parent, name }) + .then((result) => { + // Only the answer to what is in the fields now. + if (asked.current === mine) setJudged(result); + }); + }, [parent, name]); + + const changeFolder = async (): Promise => { + const picked = await startApi().chooseParent({ from: parent, title: t('desktop:new.where', 'Where to keep it') }); + if (picked !== null) setParent(picked); + }; + + const create = async (): Promise => { + setCreating(true); + setFailure(null); + try { + const accepted = judged?.ok === true && acceptedFor === judged.path; + const result = await startApi().createProject(accepted ? { parent, name, acceptWarning: true } : { parent, name }); + if (result.status === 'failed') setFailure(result.detail); + // 'refused' and 'warned': main saw something this page had not yet; ask again and show it. + else if (result.status !== 'created') setJudged(await startApi().judgeNewFolder({ parent, name })); + // 'created': main is already taking the window to the project. + } catch (error) { + say(error instanceof Error ? error.message : String(error), 'error'); + } finally { + setCreating(false); + } + }; + + const shownPath = judged?.displayPath ?? (parent === proposedParent ? proposedParentDisplay : parent); + const warning = judged?.ok === true && judged.warning !== null && acceptedFor !== judged.path ? judged.warning : null; + const refusal = judged?.ok === false ? refusalWords(t, judged.refused) : null; + const canCreate = judged?.ok === true && warning === null && !creating; + + return ( +
+ +

{t('desktop:new.heading', 'Build an app')}

+ +
+ + { + setName(event.target.value); + }} + className="h-11 w-full rounded-[10px] border border-border-strong bg-surface px-3.5 text-[14px] text-fg outline-none focus-visible:border-accent focus-visible:shadow-[0_0_0_3px_var(--accent-soft)]" + /> +
+ +
+ + {t('desktop:new.where', 'Where to keep it')} + +
+ + +
+ + {warning === null ? null : ( +
+ +
+
{warningWords(t, warning)}
+
+ + +
+
+
+ )} + + {refusal === null ? null : ( +
+ +
{refusal}
+
+ )} + +

+ {t('desktop:new.help', 'Adminium makes this folder for you. Everything about your app lives in it.')} +

+
+ + {failure === null ? null : ( +
+ +
+
{t('desktop:new.failed', 'The project could not be made.')}
+
+              {failure}
+            
+
+
+ )} + +
+ +
+
+ ); +} diff --git a/apps/desktop/src/renderer/app/pages.test.tsx b/apps/desktop/src/renderer/app/pages.test.tsx new file mode 100644 index 000000000..61cf580b1 --- /dev/null +++ b/apps/desktop/src/renderer/app/pages.test.tsx @@ -0,0 +1,421 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { I18nInstance } from '@adminium/i18n'; +import { I18nProvider } from '@adminium/i18n/react'; +import type { ReactNode } from 'react'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { AdminiumDesktopApi, DesktopRecentProject, DesktopStartApi, DesktopStartState } from '../../preload/api.js'; +import { App, screenFromHash } from './App.js'; +import { desktopApi } from './bridge.js'; +import { refusalWords, warningWords } from './new/NewProjectScreen.js'; +import { openedWhen } from './start/StartScreen.js'; +import { PAGE_LANGUAGES, initWords, loadWords, localeFor } from './words.js'; + +const recentProject = (over: Partial = {}): DesktopRecentProject => ({ + path: '/Users/sam/Adminium/juniper-kitchen', + displayPath: '~/Adminium/juniper-kitchen', + name: 'Juniper Kitchen', + lastOpened: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), + state: 'shared', + missing: false, + ...over, +}); + +const STATE: DesktopStartState = { + firstLaunch: false, + recent: [recentProject(), recentProject({ path: '/Users/sam/Documents/old-bakery', displayPath: '~/Documents/old-bakery', name: 'Old Bakery', state: 'building', missing: true })], + proposedParent: '/Users/sam/Adminium', + proposedParentDisplay: '~/Adminium', + language: null, + theme: 'light', +}; + +function fakeStart(over: Partial = {}): DesktopStartApi { + return { + state: vi.fn(() => Promise.resolve(STATE)), + judgeNewFolder: vi.fn(({ parent, name }: { parent: string; name: string }) => + Promise.resolve( + name.trim() === '' + ? { ok: false as const, path: null, displayPath: null, refused: 'no-name' as const } + : { ok: true as const, path: `${parent}/${name.toLowerCase().replace(/\s+/g, '-')}`, displayPath: `${parent.replace('/Users/sam', '~')}/${name.toLowerCase().replace(/\s+/g, '-')}`, warning: null }, + ), + ), + chooseParent: vi.fn(() => Promise.resolve('/Users/sam/Documents')), + createProject: vi.fn(() => Promise.resolve({ status: 'created' as const, path: '/Users/sam/Adminium/shop' })), + chooseFolder: vi.fn(() => Promise.resolve<{ path: string; displayPath: string } | null>({ path: '/Users/sam/Downloads/shop', displayPath: '~/Downloads/shop' })), + openProject: vi.fn(() => Promise.resolve({ status: 'opened' as const })), + forgetProject: vi.fn(() => Promise.resolve([recentProject()])), + locateProject: vi.fn(() => Promise.resolve({ status: 'cancelled' as const })), + useClassic: vi.fn(() => Promise.resolve()), + ...over, + }; +} + +let i18n: I18nInstance; +beforeAll(async () => { + i18n = await initWords('en_US'); +}); +beforeEach(() => { + window.location.hash = ''; +}); +afterEach(() => { + cleanup(); + Reflect.deleteProperty(window, 'adminiumDesktop'); +}); + +function show(start: DesktopStartApi, state: DesktopStartState = STATE): void { + Object.defineProperty(window, 'adminiumDesktop', { value: { platform: 'darwin', start } as unknown as AdminiumDesktopApi, configurable: true }); + const wrap = (children: ReactNode): ReactNode => {children}; + render(wrap()); +} + +describe('the bridge as the pages see it', () => { + it('says so when it is missing, rather than drawing dead buttons', () => { + expect(() => desktopApi()).toThrow(/bridge is missing/); + }); +}); + +describe('the words', () => { + it('are loaded for the eight languages, by a loader that names two namespaces and no more', async () => { + expect([...PAGE_LANGUAGES].sort()).toEqual(['ar-EG', 'cs-CZ', 'da-DK', 'de-DE', 'en-US', 'fr-FR', 'zh-CN', 'zh-TW']); + expect(await loadWords('de-DE', 'desktop')).not.toBeNull(); + expect(await loadWords('de-DE', 'ui')).not.toBeNull(); + // The dashboard's vocabulary is not this bundle's to carry. + for (const namespace of ['common', 'studio', 'designer', 'errors']) expect(await loadWords('de-DE', namespace)).toBeNull(); + }); + + it('follow the saved language, or the system’s when none was saved', () => { + expect(localeFor('de-DE', 'en-US')).toBe('de_DE'); + expect(localeFor(null, 'fr-FR')).toBe('fr_FR'); + expect(localeFor(null, 'xx-YY')).toBe('en_US'); + }); + + it('draw the page in another language when it is the app’s', async () => { + const german = await initWords('de_DE'); + expect(german.t('desktop:start.heading')).toBe('Was möchten Sie tun?'); + expect(german.t('desktop:trust.title')).toBe('Diesen Ordner öffnen?'); + }); +}); + +describe('which screen the address names', () => { + it('is New app for #/new and Start for anything else', () => { + expect(screenFromHash('#/new')).toBe('new'); + expect(screenFromHash('')).toBe('start'); + expect(screenFromHash('#/nowhere')).toBe('start'); + }); +}); + +describe('how long ago a project was opened', () => { + const now = new Date(2026, 9, 10, 9, 0, 0); + it('is the system’s own words', () => { + expect(openedWhen(new Date(2026, 9, 10, 1, 0, 0).toISOString(), now, 'en-US')).toBe('today'); + expect(openedWhen(new Date(2026, 9, 9, 23, 0, 0).toISOString(), now, 'en-US')).toBe('yesterday'); + expect(openedWhen(new Date(2026, 9, 7, 9, 0, 0).toISOString(), now, 'en-US')).toBe('3 days ago'); + expect(openedWhen(new Date(2026, 6, 10, 9, 0, 0).toISOString(), now, 'en-US')).toBe('3 months ago'); + expect(openedWhen(new Date(2024, 9, 10, 9, 0, 0).toISOString(), now, 'en-US')).toBe('2 years ago'); + expect(openedWhen(new Date(2026, 9, 9, 23, 0, 0).toISOString(), now, 'de-DE')).toBe('gestern'); + }); + it('never says a project was opened in the future because a clock moved', () => { + expect(openedWhen(new Date(2026, 9, 12).toISOString(), now, 'en-US')).toBe('today'); + }); +}); + +describe('Start', () => { + it('offers the four things, the first as the main one, and welcomes only a first launch', () => { + show(fakeStart()); + expect(screen.getByRole('heading', { level: 1 }).textContent).toBe('What would you like to do?'); + expect(screen.getAllByRole('button').filter((button) => button.hasAttribute('data-choice')).map((button) => button.getAttribute('data-choice'))).toEqual(['build', 'open', 'connect', 'db']); + expect(screen.queryByText('Welcome to Adminium.')).toBeNull(); + // Nothing to connect to yet. + expect((screen.getByText('Connect to another Adminium').closest('button') as HTMLButtonElement).disabled).toBe(true); + cleanup(); + show(fakeStart(), { ...STATE, firstLaunch: true, recent: [] }); + expect(screen.getByText('Welcome to Adminium.')).toBeTruthy(); + expect(screen.queryByText('Recent projects')).toBeNull(); + }); + + it('lists the recent projects: a path that reads left to right, when it was opened, and how it was last served', () => { + show(fakeStart()); + expect(screen.getByRole('heading', { level: 2 }).textContent).toBe('Recent projects'); + const path = screen.getByText('~/Adminium/juniper-kitchen'); + expect(path.getAttribute('dir')).toBe('ltr'); + expect(screen.getByText('Opened yesterday')).toBeTruthy(); + expect(screen.getByText('Shared')).toBeTruthy(); + // A folder that is gone is not a button, and says so. + expect(screen.queryByRole('button', { name: 'Open Old Bakery' })).toBeNull(); + expect(screen.getByText('This folder was moved or deleted')).toBeTruthy(); + expect(screen.getByText('~/Documents/old-bakery').className).toContain('line-through'); + }); + + it('goes to New app, and back', async () => { + show(fakeStart()); + fireEvent.click(screen.getByText('Build an app')); + window.dispatchEvent(new Event('hashchange')); + expect(await screen.findByLabelText('Name')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + window.dispatchEvent(new Event('hashchange')); + expect(await screen.findByText('What would you like to do?')).toBeTruthy(); + }); + + it('chooses the classic workspace', async () => { + const start = fakeStart(); + show(start); + fireEvent.click(screen.getByText('Use my own database')); + await waitFor(() => { + expect(start.useClassic).toHaveBeenCalledTimes(1); + }); + }); + + it('asks before a folder is opened, Cancel first; "Open" says the person agreed', async () => { + const openProject = vi + .fn() + .mockResolvedValueOnce({ status: 'trust-needed', path: '/Users/sam/Downloads/shop', displayPath: '~/Downloads/shop', changed: true }) + .mockResolvedValueOnce({ status: 'opened' }); + const start = fakeStart({ openProject }); + show(start); + fireEvent.click(screen.getByText('Open a folder')); + const dialog = await screen.findByRole('dialog'); + expect(start.chooseFolder).toHaveBeenCalledWith({ title: 'Open a folder' }); + expect(openProject).toHaveBeenLastCalledWith({ path: '/Users/sam/Downloads/shop' }); + expect(dialog.textContent).toContain('Open this folder?'); + expect(dialog.textContent).toContain('~/Downloads/shop'); + expect(dialog.textContent).toContain('This folder’s code changed since you last opened it.'); + const buttons = [...dialog.querySelectorAll('button')].map((button) => button.textContent); + expect(buttons).toEqual(['Cancel', 'Open']); + + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + await waitFor(() => { + expect(openProject).toHaveBeenLastCalledWith({ path: '/Users/sam/Downloads/shop', agreed: true }); + }); + await waitFor(() => { + expect(screen.queryByRole('dialog')).toBeNull(); + }); + }); + + it('opens nothing when the question is cancelled or no folder was picked', async () => { + const openProject = vi.fn().mockResolvedValue({ status: 'trust-needed', path: '/p', displayPath: '/p', changed: false }); + const start = fakeStart({ openProject }); + show(start); + fireEvent.click(screen.getByRole('button', { name: 'Open Juniper Kitchen' })); + await screen.findByRole('dialog'); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => { + expect(screen.queryByRole('dialog')).toBeNull(); + }); + expect(openProject).toHaveBeenCalledTimes(1); + + (start.chooseFolder as ReturnType).mockResolvedValueOnce(null); + fireEvent.click(screen.getByText('Open a folder')); + await waitFor(() => { + expect(start.chooseFolder).toHaveBeenCalled(); + }); + expect(openProject).toHaveBeenCalledTimes(1); + }); + + it('says in a notice why a folder was not opened', async () => { + for (const [status, words] of [ + ['missing', 'This folder was moved or deleted'], + ['not-a-project', 'This folder is not an Adminium project.'], + ['needs-packages', 'This project’s packages are not on this computer yet.'], + ] as const) { + show(fakeStart({ openProject: vi.fn(() => Promise.resolve({ status })) }), { ...STATE, recent: [recentProject()] }); + fireEvent.click(screen.getByRole('button', { name: 'Open Juniper Kitchen' })); + await waitFor(() => { + expect(screen.getByRole('region', { name: 'Notices' }).textContent).toContain(words); + }); + cleanup(); + } + show(fakeStart({ openProject: vi.fn(() => Promise.reject(new Error('INTERNAL: the disk said no'))) }), { ...STATE, recent: [recentProject()] }); + fireEvent.click(screen.getByRole('button', { name: 'Open Juniper Kitchen' })); + await waitFor(() => { + expect(screen.getByRole('region', { name: 'Notices' }).textContent).toContain('the disk said no'); + }); + }); + + it('removes a folder that is gone from the list, and locates one that moved', async () => { + const start = fakeStart(); + show(start); + fireEvent.click(screen.getByRole('button', { name: 'Remove' })); + await waitFor(() => { + expect(screen.queryByText('Old Bakery')).toBeNull(); + }); + expect(start.forgetProject).toHaveBeenCalledWith('/Users/sam/Documents/old-bakery'); + expect(screen.getByRole('region', { name: 'Notices' }).textContent).toContain('Removed from the recent projects'); + }); + + it('says so when the folder picked for a moved project is not it', async () => { + const located = recentProject({ path: '/Users/sam/new-bakery', displayPath: '~/new-bakery', name: 'Old Bakery' }); + const locateProject = vi + .fn() + .mockResolvedValueOnce({ status: 'not-a-project' }) + .mockResolvedValueOnce({ status: 'already-listed' }) + .mockResolvedValueOnce({ status: 'cancelled' }) + .mockResolvedValueOnce({ status: 'located', recent: [located] }); + show(fakeStart({ locateProject })); + const notices = (): string => screen.getByRole('region', { name: 'Notices' }).textContent ?? ''; + fireEvent.click(screen.getByRole('button', { name: 'Locate…' })); + await waitFor(() => { + expect(notices()).toContain('That folder is not an Adminium project.'); + }); + expect(locateProject).toHaveBeenCalledWith({ path: '/Users/sam/Documents/old-bakery', title: 'Where is Old Bakery now?' }); + fireEvent.click(screen.getByRole('button', { name: 'Locate…' })); + await waitFor(() => { + expect(notices()).toContain('That folder is already in the list.'); + }); + fireEvent.click(screen.getByRole('button', { name: 'Locate…' })); + await waitFor(() => { + expect(locateProject).toHaveBeenCalledTimes(3); + }); + fireEvent.click(screen.getByRole('button', { name: 'Locate…' })); + expect(await screen.findByRole('button', { name: 'Open Old Bakery' })).toBeTruthy(); + expect(screen.getByText('~/new-bakery')).toBeTruthy(); + }); +}); + +describe('New app', () => { + const open = (start: DesktopStartApi): void => { + window.location.hash = '#/new'; + show(start); + }; + + it('proposes a folder made from the name, and makes the project there', async () => { + const start = fakeStart(); + open(start); + const where = screen.getByRole('textbox', { name: 'Where to keep it' }) as HTMLInputElement; + expect(where.readOnly).toBe(true); + expect(where.getAttribute('dir')).toBe('ltr'); + await waitFor(() => { + expect(where.value).toBe('~/Adminium'); + }); + const create = screen.getByRole('button', { name: 'Create' }) as HTMLButtonElement; + expect(create.disabled).toBe(true); + + await userEvent.type(screen.getByLabelText('Name'), 'Juniper Kitchen'); + await waitFor(() => { + expect(where.value).toBe('~/Adminium/juniper-kitchen'); + }); + expect(create.disabled).toBe(false); + fireEvent.click(create); + await waitFor(() => { + expect(start.createProject).toHaveBeenCalledWith({ parent: '/Users/sam/Adminium', name: 'Juniper Kitchen' }); + }); + }); + + it('"Change…" asks the system for a folder and the path follows it', async () => { + const start = fakeStart(); + open(start); + await userEvent.type(screen.getByLabelText('Name'), 'Shop'); + fireEvent.click(screen.getByRole('button', { name: 'Change…' })); + await waitFor(() => { + expect((screen.getByRole('textbox', { name: 'Where to keep it' }) as HTMLInputElement).value).toBe('~/Documents/shop'); + }); + expect(start.chooseParent).toHaveBeenCalledWith({ from: '/Users/sam/Adminium', title: 'Where to keep it' }); + // Nothing picked: the folder stays. + (start.chooseParent as ReturnType).mockResolvedValueOnce(null); + fireEvent.click(screen.getByRole('button', { name: 'Change…' })); + await waitFor(() => { + expect(start.chooseParent).toHaveBeenCalledTimes(2); + }); + expect((screen.getByRole('textbox', { name: 'Where to keep it' }) as HTMLInputElement).value).toBe('~/Documents/shop'); + }); + + it('warns about a synced folder and waits for the person’s choice, for that folder only', async () => { + const judgeNewFolder = vi.fn(({ parent }) => + Promise.resolve({ ok: true, path: `${parent}/shop`, displayPath: `${parent}/shop`, warning: 'icloud' }), + ); + const start = fakeStart({ judgeNewFolder }); + open(start); + await userEvent.type(screen.getByLabelText('Name'), 'Shop'); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain('This folder is synced by iCloud Drive.'); + const create = screen.getByRole('button', { name: 'Create' }) as HTMLButtonElement; + expect(create.disabled).toBe(true); + + fireEvent.click(screen.getByRole('button', { name: 'Use it anyway' })); + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeNull(); + }); + expect(create.disabled).toBe(false); + fireEvent.click(create); + await waitFor(() => { + expect(start.createProject).toHaveBeenCalledWith({ parent: '/Users/sam/Adminium', name: 'Shop', acceptWarning: true }); + }); + + // Another folder is another question. + fireEvent.click(screen.getByRole('button', { name: 'Change…' })); + expect(await screen.findByRole('alert')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Choose another folder' })); + await waitFor(() => { + expect(start.chooseParent).toHaveBeenCalledTimes(2); + }); + }); + + it('refuses a folder with the reason, and "Create" stays off', async () => { + const start = fakeStart({ judgeNewFolder: vi.fn(() => Promise.resolve({ ok: false as const, path: '/Users/sam', displayPath: '~', refused: 'home-folder' as const })) }); + open(start); + await userEvent.type(screen.getByLabelText('Name'), 'Shop'); + expect((await screen.findByRole('alert')).textContent).toBe('A project cannot be kept directly in your home folder. Choose or make a folder inside it.'); + expect((screen.getByRole('button', { name: 'Create' }) as HTMLButtonElement).disabled).toBe(true); + }); + + it('shows what main saw when it judged differently at "Create"', async () => { + const judgeNewFolder = vi + .fn() + .mockResolvedValue({ ok: true, path: '/p/shop', displayPath: '/p/shop', warning: null }); + const start = fakeStart({ judgeNewFolder, createProject: vi.fn(() => Promise.resolve({ status: 'refused' as const, refused: 'exists-with-files' as const })) }); + open(start); + await userEvent.type(screen.getByLabelText('Name'), 'Shop'); + const create = screen.getByRole('button', { name: 'Create' }) as HTMLButtonElement; + await waitFor(() => { + expect(create.disabled).toBe(false); + }); + judgeNewFolder.mockResolvedValue({ ok: false, path: '/p/shop', displayPath: '/p/shop', refused: 'exists-with-files' }); + fireEvent.click(create); + expect((await screen.findByRole('alert')).textContent).toContain('A folder with this name is already there and holds files.'); + }); + + it('says why a project could not be made, in the maker’s own last lines', async () => { + const start = fakeStart({ createProject: vi.fn(() => Promise.resolve({ status: 'failed' as const, detail: 'npm error code ENOTFOUND' })) }); + open(start); + await userEvent.type(screen.getByLabelText('Name'), 'Shop'); + const create = screen.getByRole('button', { name: 'Create' }) as HTMLButtonElement; + await waitFor(() => { + expect(create.disabled).toBe(false); + }); + fireEvent.click(create); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain('The project could not be made.'); + expect(alert.textContent).toContain('npm error code ENOTFOUND'); + // And it can be tried again. + expect((screen.getByRole('button', { name: 'Create' }) as HTMLButtonElement).disabled).toBe(false); + }); + + it('says so in a notice when the bridge itself fails', async () => { + const start = fakeStart({ createProject: vi.fn(() => Promise.reject(new Error('UNAVAILABLE: The first screens are not open.'))) }); + open(start); + await userEvent.type(screen.getByLabelText('Name'), 'Shop'); + const create = screen.getByRole('button', { name: 'Create' }) as HTMLButtonElement; + await waitFor(() => { + expect(create.disabled).toBe(false); + }); + fireEvent.click(create); + await waitFor(() => { + expect(screen.getByRole('region', { name: 'Notices' }).textContent).toContain('The first screens are not open.'); + }); + }); + + it('has a sentence for every warning and every refusal main can give', () => { + const t = (_key: string, fallback: string): string => fallback; + const warnings = ['icloud', 'onedrive', 'dropbox', 'googledrive', 'no-links'] as const; + expect(new Set(warnings.map((warning) => warningWords(t, warning))).size).toBe(warnings.length); + const refusals = ['bad-name', 'home-folder', 'system-folder', 'inside-the-app', 'inside-a-project', 'exists-with-files', 'not-absolute'] as const; + const sentences = refusals.map((refused) => refusalWords(t, refused)); + expect(new Set(sentences).size).toBe(refusals.length); + for (const sentence of sentences) expect(sentence).not.toBeNull(); + // An empty name needs no box: the field says it. + expect(refusalWords(t, 'no-name')).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/app/shell/PlainShell.tsx b/apps/desktop/src/renderer/app/shell/PlainShell.tsx new file mode 100644 index 000000000..6dbd8c0f6 --- /dev/null +++ b/apps/desktop/src/renderer/app/shell/PlainShell.tsx @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** + * The plain shell every screen before a project shares: the mark and the + * name, then one centred column. No theme or language control (they follow the + * app's saved choice). + */ +import { ArrowLeft, Hexagon } from 'lucide-react'; +import type { ReactNode } from 'react'; + +export function PlainShell({ children }: { children: ReactNode }): ReactNode { + return ( +
+
+ + Adminium +
+ {/* The screens measure themselves against this area, not the window: a narrow one stacks Start's cards. */} +
+
{children}
+
+
+ ); +} + +/** "Back": the arrow follows the reading direction. */ +export function BackLink({ label, onBack }: { label: string; onBack: () => void }): ReactNode { + return ( + + ); +} diff --git a/apps/desktop/src/renderer/app/start/StartScreen.tsx b/apps/desktop/src/renderer/app/start/StartScreen.tsx new file mode 100644 index 000000000..1753048ae --- /dev/null +++ b/apps/desktop/src/renderer/app/start/StartScreen.tsx @@ -0,0 +1,279 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** + * Start: what the app opens on. Four things a person can do, and under them + * the projects they were last in. + */ +import { useLocale, useT } from '@adminium/i18n/react'; +import { tagForLocale } from '@adminium/i18n'; +import { Database, Folder, FolderOpen, Hammer, MonitorSmartphone, RadioTower, Sparkles, type LucideIcon } from 'lucide-react'; +import { useId, useState, type ReactNode } from 'react'; + +import type { DesktopRecentProject, DesktopStartState } from '../../../preload/api.js'; +import { startApi } from '../bridge.js'; +import { TrustDialog, type TrustQuestion } from './TrustDialog.js'; + +type Say = (title: string, variant?: 'success' | 'error' | 'info') => void; + +/** "yesterday", "3 days ago", "today": the system's own words for how long ago, never ours. */ +export function openedWhen(iso: string, now: Date, tag: string): string { + const then = new Date(iso); + const day = 24 * 60 * 60 * 1000; + const startOf = (date: Date): number => new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); + const days = Math.round((startOf(then) - startOf(now)) / day); + const format = new Intl.RelativeTimeFormat(tag, { numeric: 'auto' }); + if (days > -31) return format.format(Math.min(days, 0), 'day'); + if (days > -365) return format.format(Math.round(days / 30), 'month'); + return format.format(Math.round(days / 365), 'year'); +} + +interface Choice { + key: 'build' | 'open' | 'connect' | 'db'; + icon: LucideIcon; + primary?: boolean; +} + +const CHOICES: readonly Choice[] = [ + { key: 'build', icon: Sparkles, primary: true }, + { key: 'open', icon: FolderOpen }, + { key: 'connect', icon: MonitorSmartphone }, + { key: 'db', icon: Database }, +]; + +function ChoiceCard({ choice, title, line, disabled, onChoose }: { choice: Choice; title: string; line: string; disabled?: boolean; onChoose: () => void }): ReactNode { + const Icon = choice.icon; + return ( + + ); +} + +function RecentRow({ + project, + first, + when, + onOpen, + onLocate, + onRemove, +}: { + project: DesktopRecentProject; + first: boolean; + when: string; + onOpen: () => void; + onLocate: () => void; + onRemove: () => void; +}): ReactNode { + const t = useT(); + const shared = project.state === 'shared'; + const ChipIcon = shared ? RadioTower : Hammer; + return ( +
+ +
+ {project.missing ? ( + {project.name} + ) : ( + + )} +
+ + {project.displayPath} + + + {project.missing ? t('desktop:start.recent.gone', 'This folder was moved or deleted') : when} +
+
+ {project.missing ? ( +
+ + +
+ ) : ( + + + )} +
+ ); +} + +export function StartScreen({ initial, onBuild, say }: { initial: DesktopStartState; onBuild: () => void; say: Say }): ReactNode { + const t = useT(); + const tag = tagForLocale(useLocale()); + const recentHead = useId(); + const [recent, setRecent] = useState(initial.recent); + const [question, setQuestion] = useState(null); + const [opening, setOpening] = useState(false); + const now = new Date(); + + const open = async (path: string, agreed: boolean): Promise => { + setOpening(true); + try { + const result = await startApi().openProject(agreed ? { path, agreed: true } : { path }); + if (result.status === 'trust-needed') { + setQuestion({ path: result.path, displayPath: result.displayPath, changed: result.changed }); + return; + } + setQuestion(null); + if (result.status === 'missing') say(t('desktop:start.recent.gone', 'This folder was moved or deleted'), 'error'); + else if (result.status === 'not-a-project') say(t('desktop:start.open.notAProject', 'This folder is not an Adminium project.'), 'error'); + else if (result.status === 'needs-packages') say(t('desktop:start.open.needsPackages', 'This project’s packages are not on this computer yet.'), 'info'); + // 'opened': main is already taking the window to the project. + } catch (error) { + setQuestion(null); + say(error instanceof Error ? error.message : String(error), 'error'); + } finally { + setOpening(false); + } + }; + + const choose = async (key: Choice['key']): Promise => { + if (key === 'build') { + onBuild(); + return; + } + if (key === 'db') { + await startApi().useClassic(); + return; + } + if (key === 'open') { + const picked = await startApi().chooseFolder({ title: t('desktop:start.choice.open.title', 'Open a folder') }); + if (picked !== null) await open(picked.path, false); + } + }; + + const locate = async (project: DesktopRecentProject): Promise => { + const result = await startApi().locateProject({ path: project.path, title: t('desktop:start.recent.locateTitle', 'Where is {name} now?', { name: project.name }) }); + if (result.status === 'located') setRecent(result.recent); + else if (result.status === 'not-a-project') say(t('desktop:start.recent.notThatProject', 'That folder is not an Adminium project.'), 'error'); + else if (result.status === 'already-listed') say(t('desktop:start.recent.alreadyListed', 'That folder is already in the list.'), 'info'); + }; + + const remove = async (project: DesktopRecentProject): Promise => { + setRecent(await startApi().forgetProject(project.path)); + say(t('desktop:start.recent.removed', 'Removed from the recent projects'), 'success'); + }; + + const words: Record = { + build: [t('desktop:start.choice.build.title', 'Build an app'), t('desktop:start.choice.build.line', 'Describe it, and the Designer builds it on this computer.')], + open: [t('desktop:start.choice.open.title', 'Open a folder'), t('desktop:start.choice.open.line', 'Go on with an app that is already in a folder, or one someone sent you.')], + connect: [t('desktop:start.choice.connect.title', 'Connect to another Adminium'), t('desktop:start.choice.connect.line', 'Use an Adminium that runs on another computer.')], + db: [t('desktop:start.choice.db.title', 'Use my own database'), t('desktop:start.choice.db.line', 'Make screens for a database you already have.')], + }; + + return ( +
+ {initial.firstLaunch ?

{t('desktop:start.welcome', 'Welcome to Adminium.')}

: null} +

{t('desktop:start.heading', 'What would you like to do?')}

+
+ {CHOICES.map((choice) => ( + { + void choose(choice.key); + }} + /> + ))} +
+ + {recent.length === 0 ? null : ( +
+

+ {t('desktop:start.recent.heading', 'Recent projects')} +

+
+ {recent.map((project, index) => ( + { + void open(project.path, false); + }} + onLocate={() => { + void locate(project); + }} + onRemove={() => { + void remove(project); + }} + /> + ))} +
+
+ )} + + { + setQuestion(null); + }} + onOpen={() => { + if (question !== null) void open(question.path, true); + }} + /> +
+ ); +} diff --git a/apps/desktop/src/renderer/app/start/TrustDialog.tsx b/apps/desktop/src/renderer/app/start/TrustDialog.tsx new file mode 100644 index 000000000..b513557ef --- /dev/null +++ b/apps/desktop/src/renderer/app/start/TrustDialog.tsx @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** + * "Open this folder?": asked before anything of a folder is run on this + * computer. Cancel is first and holds the focus, so a stray Return opens + * nothing. + */ +import { useT } from '@adminium/i18n/react'; +import { Modal, ModalFooter, ModalHeader } from '@adminium/ui'; +import { FolderOpen } from 'lucide-react'; +import type { ReactNode } from 'react'; + +export interface TrustQuestion { + path: string; + displayPath: string; + /** Agreed to before, and its code is no longer that code. */ + changed: boolean; +} + +export function TrustDialog({ question, busy, onCancel, onOpen }: { question: TrustQuestion | null; busy: boolean; onCancel: () => void; onOpen: () => void }): ReactNode { + const t = useT(); + return ( + { + if (!open) onCancel(); + }} + className="max-w-[460px] rounded-[16px]" + > + {question === null ? null : ( + <> +
+ +
+ {t('desktop:trust.title', 'Open this folder?')}} + subtitle={ + + + {question.displayPath} + + + {question.changed ? `${t('desktop:trust.changed', 'This folder\u2019s code changed since you last opened it.')} ` : ''} + {t( + 'desktop:trust.body', + 'Opening it runs its code on this computer, with your access to your files. Open only folders you made or that come from someone you trust.', + )} + + + } + /> + + + + + + )} +
+ ); +} diff --git a/apps/desktop/src/renderer/app/styles.css b/apps/desktop/src/renderer/app/styles.css new file mode 100644 index 000000000..077bcc149 --- /dev/null +++ b/apps/desktop/src/renderer/app/styles.css @@ -0,0 +1,29 @@ +/* The app's own pages. Import order per @adminium/tokens/tailwind.css: + tailwindcss → tokens custom properties → tokens theme mapping. */ +@import "tailwindcss"; +@import "@adminium/tokens/index.css"; +@import "@adminium/tokens/tailwind.css"; + +/* Utilities for these pages AND the kit's parts they use: the kit is styled by + its consumer's Tailwind, and node_modules is outside the automatic sources. */ +@source "./**/*.{ts,tsx}"; +@source "../../../node_modules/@adminium/ui/src/**/*.{ts,tsx}"; + +html, +body, +#root { + block-size: 100%; +} + +body { + margin: 0; + background: var(--bg); + color: var(--fg); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; +} + +/* Arrows that point "back" follow the reading direction. */ +[dir="rtl"] .adm-mirror { + transform: scaleX(-1); +} diff --git a/apps/desktop/src/renderer/app/words.ts b/apps/desktop/src/renderer/app/words.ts new file mode 100644 index 000000000..706998246 --- /dev/null +++ b/apps/desktop/src/renderer/app/words.ts @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** + * The words of the app's own pages: the `desktop` namespace, and the kit's own + * (`ui`, which the kit's parts read). Awaited before the first render, so no + * screen is ever drawn in one language and then redrawn in another. + * + * THE LOADER IS THIS FILE'S OWN, and names two namespaces. The package's + * general loader names every namespace of every language, and a bundler that + * sees it ships all of them: the dashboard's whole vocabulary would ride in the + * app beside a page that says fifty things. + */ +import { createI18n, localeFromTag, type BundleLoader, type I18nInstance, type LocaleId } from '@adminium/i18n'; + +type Words = NonNullable>>; + +const LOADERS: Readonly Promise>> = { + 'en-US/desktop': () => import('@adminium/i18n/resources/en-us/desktop'), + 'de-DE/desktop': () => import('@adminium/i18n/resources/de-de/desktop'), + 'de-DE/ui': () => import('@adminium/i18n/resources/de-de/ui'), + 'fr-FR/desktop': () => import('@adminium/i18n/resources/fr-fr/desktop'), + 'fr-FR/ui': () => import('@adminium/i18n/resources/fr-fr/ui'), + 'cs-CZ/desktop': () => import('@adminium/i18n/resources/cs-cz/desktop'), + 'cs-CZ/ui': () => import('@adminium/i18n/resources/cs-cz/ui'), + 'da-DK/desktop': () => import('@adminium/i18n/resources/da-dk/desktop'), + 'da-DK/ui': () => import('@adminium/i18n/resources/da-dk/ui'), + 'zh-CN/desktop': () => import('@adminium/i18n/resources/zh-cn/desktop'), + 'zh-CN/ui': () => import('@adminium/i18n/resources/zh-cn/ui'), + 'zh-TW/desktop': () => import('@adminium/i18n/resources/zh-tw/desktop'), + 'zh-TW/ui': () => import('@adminium/i18n/resources/zh-tw/ui'), + 'ar-EG/desktop': () => import('@adminium/i18n/resources/ar-eg/desktop'), + 'ar-EG/ui': () => import('@adminium/i18n/resources/ar-eg/ui'), +}; + +/** The languages these pages can be drawn in: the ones a loader above names. */ +export const PAGE_LANGUAGES: readonly string[] = [...new Set(Object.keys(LOADERS).map((key) => key.split('/')[0] ?? ''))]; + +/** A namespace's words in a language, or `null`: English is then what is drawn. */ +export async function loadWords(tag: string, namespace: string): Promise { + const loader = LOADERS[`${tag}/${namespace}`]; + return loader === undefined ? null : loader(); +} + +/** The app's saved language, or the system's when none was saved. */ +export function localeFor(saved: string | null, system: string): LocaleId { + return localeFromTag(saved ?? system); +} + +export async function initWords(locale: LocaleId): Promise { + const i18n = await createI18n({ locale, loadBundle: loadWords }); + await i18n.loadNamespaces('desktop'); + return i18n; +} diff --git a/apps/desktop/src/test/app-pages-source.test.ts b/apps/desktop/src/test/app-pages-source.test.ts new file mode 100644 index 000000000..43fb2ffd1 --- /dev/null +++ b/apps/desktop/src/test/app-pages-source.test.ts @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** + * Two things about the app's own pages that only their SOURCE can say. + * + * 1. WHAT THEY MAY IMPORT. They are drawn before any server runs and ride in + * the app's archive: React, the kit, the tokens, the icons, the i18n package + * and the bridge's types. Never the dashboard, the server or a widget + * package: one such import would pull megabytes into a page that says fifty + * things, and the dependency rules do not record a renderer's imports here. + * 2. THEIR WORDS. Every `t('desktop:…', '…')` names a key the English locale + * file has, with the same text byte for byte (the fallback is what a test + * without i18n draws), and the file has no key nothing reads. + */ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +const here = dirname(fileURLToPath(import.meta.url)); +const pages = join(here, '..', 'renderer', 'app'); +const locale = join(here, '..', '..', '..', '..', 'packages', 'i18n', 'locales', 'en-US', 'desktop.json'); + +function sources(dir: string): string[] { + return readdirSync(dir).flatMap((entry) => { + const path = join(dir, entry); + if (statSync(path).isDirectory()) return sources(path); + return /\.(ts|tsx|css)$/.test(entry) ? [path] : []; + }); +} +const files = sources(pages); +const code = files.filter((file) => !file.endsWith('.css') && !/\.test\.tsx?$/.test(file)); + +const ALLOWED = [/^react$/, /^react\/jsx-runtime$/, /^react-dom\/client$/, /^lucide-react$/, /^@adminium\/ui$/, /^@adminium\/tokens(\/.*)?$/, /^@adminium\/i18n(\/react|\/resources\/[a-z-]+\/(desktop|ui))?$/, /^tailwindcss$/]; + +describe('the app’s own pages', () => { + it('exist where the build looks for them', () => { + expect(files.map((file) => relative(pages, file))).toContain('main.tsx'); + expect(readFileSync(join(pages, 'index.html'), 'utf8')).toContain('./main.tsx'); + }); + + it('import only what they may: never the dashboard, the server or a widget package', () => { + const offenders: string[] = []; + for (const file of files) { + const text = readFileSync(file, 'utf8'); + const named = [...text.matchAll(/(?:from\s+|import\s*\(\s*|import\s+|@import\s+|@source\s+)['"]([^'"]+)['"]/g)].map((match) => match[1] ?? ''); + for (const specifier of named) { + if (specifier.startsWith('.')) { + // A relative path may reach the bridge's types and the kit's sources (for the stylesheet), and nothing else outside. + const inside = !relative(pages, join(dirname(file), specifier)).startsWith('..'); + const bridgeTypes = /(^|\/)preload\/api\.js$/.test(specifier); + const kitSources = file.endsWith('.css') && specifier.includes('node_modules/@adminium/ui/src/'); + if (!inside && !bridgeTypes && !kitSources) offenders.push(`${relative(pages, file)} → ${specifier}`); + continue; + } + const test = /\.test\.tsx?$/.test(file); + if (test && /^(vitest|@testing-library\/.*)$/.test(specifier)) continue; + if (!ALLOWED.some((allowed) => allowed.test(specifier))) offenders.push(`${relative(pages, file)} → ${specifier}`); + } + } + expect(offenders).toEqual([]); + }); + + it('say only what the English locale file says, and the file says nothing they do not', () => { + const flat: Record = {}; + const walk = (node: unknown, prefix: string): void => { + if (typeof node === 'string') flat[prefix] = node; + else for (const [key, child] of Object.entries(node as Record)) walk(child, prefix === '' ? key : `${prefix}.${key}`); + }; + walk(JSON.parse(readFileSync(locale, 'utf8')), ''); + + const used = new Map(); + const problems: string[] = []; + for (const file of code) { + const text = readFileSync(file, 'utf8'); + for (const match of text.matchAll(/\bt\(\s*'([\w:.]+)',\s*'((?:[^'\\]|\\.)*)'/g)) { + const key = match[1] ?? ''; + // The fallback as the program holds it: its escapes read. + const fallback = JSON.parse(`"${(match[2] ?? '').replace(/\\'/g, "'").replace(/"/g, '\\"')}"`) as string; + if (!key.startsWith('desktop:')) { + problems.push(`${relative(pages, file)}: ${key} is not a desktop: key`); + continue; + } + used.set(key.slice('desktop:'.length), fallback); + } + // Every call is a literal key with a literal fallback: nothing built from pieces escapes the check above. + const calls = [...text.matchAll(/\bt\(\s*[^\s')]/g)].length; + if (calls > 0) problems.push(`${relative(pages, file)}: a t() call whose key is not a literal`); + } + for (const [key, fallback] of used) { + if (!(key in flat)) problems.push(`missing from desktop.json: ${key}`); + else if (flat[key] !== fallback) problems.push(`${key}: the page says ${JSON.stringify(fallback)}, the file ${JSON.stringify(flat[key])}`); + } + for (const key of Object.keys(flat)) if (!used.has(key)) problems.push(`in desktop.json and read by no page: ${key}`); + expect(problems).toEqual([]); + expect(used.size).toBeGreaterThan(40); + }); +}); diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index b6c0af0d7..86b0bc60c 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -10,7 +10,9 @@ // `electron-vite/node` declares the ?asset / ?nodeWorker / *.node module // shims the main + server bundles may import (packaging uses ?asset&asarUnpack // for the native binaries). - "types": ["node", "electron-vite/node"] + "types": ["node", "electron-vite/node"], + // src/renderer/app: the app's own pages are React. + "jsx": "react-jsx" }, "include": ["src", "electron.vite.config.ts", "vitest.config.ts"] } diff --git a/apps/desktop/vitest.config.ts b/apps/desktop/vitest.config.ts index 4b1703cee..d45ed0e94 100644 --- a/apps/desktop/vitest.config.ts +++ b/apps/desktop/vitest.config.ts @@ -28,6 +28,7 @@ export default defineConfig({ ...workers(), coverage: coverage({ statements: 76, branches: 89 }), environment: 'node', - include: ['src/**/*.test.ts'], + // `.tsx`: the app's own pages (src/renderer/app), each test file naming the DOM it runs in. + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], }, }); diff --git a/apps/e2e/tests-desktop/desktop-start.spec.ts b/apps/e2e/tests-desktop/desktop-start.spec.ts new file mode 100644 index 000000000..bbff808ee --- /dev/null +++ b/apps/e2e/tests-desktop/desktop-start.spec.ts @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** + * The app's own first screens: Start, New app, the question before a folder + * is opened, and the recent list. + * + * These are drawn by the app's own page bundle, from the disk, before any + * server runs. The system's folder picker cannot be driven from a test, so it + * is stood in for in the main process (the picker's ANSWER is the only thing + * replaced; everything that judges the folder is the app's own). + * + * The home folder is a temporary one, so "~/Adminium" is this run's. + */ +import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { expect, test, type ElectronApplication, type Page } from '@playwright/test'; + +import { ProjectHarness } from '../tests/projectHarness.js'; +import { closeDesktop, launchDesktop } from './helpers/launch.js'; + +const FIRST_PORT = process.env['E2E_PORT'] === undefined || process.env['E2E_PORT'] === '' ? 4760 : Number(process.env['E2E_PORT']) + 60; + +let project: ProjectHarness; +let home: string; +let userDataDir: string; +let app: ElectronApplication; +let page: Page; + +test.describe.configure({ mode: 'serial' }); + +const launch = async (): Promise => { + ({ app, userDataDir } = await launchDesktop({ + ...(userDataDir === undefined ? {} : { userDataDir }), + env: { HOME: home, USERPROFILE: home, ADMINIUM_DESKTOP_E2E_PORT: String(FIRST_PORT), ADMINIUM_DISABLE_UPDATES: '1' }, + })); + page = await app.firstWindow(); +}; + +/** The system's folder picker answers with `folder`. */ +const pickerAnswers = (folder: string | null): Promise => + app.evaluate(({ dialog }, picked) => { + dialog.showOpenDialog = (() => Promise.resolve(picked === null ? { canceled: true, filePaths: [] } : { canceled: false, filePaths: [picked] })) as typeof dialog.showOpenDialog; + }, folder); + +const config = (): { version: number; projects: Array<{ path: string; name: string; state: string; trusted: string | null }> } => + JSON.parse(readFileSync(join(userDataDir, 'config.json'), 'utf8')) as ReturnType; + +// eslint-disable-next-line no-empty-pattern -- Playwright reads fixtures from this pattern, and the hook needs none. +test.beforeAll(async ({}, testInfo) => { + testInfo.setTimeout(300_000); + home = realpathSync(mkdtempSync(join(tmpdir(), 'adminium-desktop-home-'))); + project = await ProjectHarness.create({}, ['react', 'react-dom']); + // A model's name, so the Designer's Home has one to show; nothing here asks it anything. + appendFileSync(join(project.root, '.env'), '\nADMINIUM_AI_OLLAMA_BASE_URL=http://localhost:9\nADMINIUM_AI_MODEL=ollama/fake\n'); + await launch(); +}); + +test.afterAll(async () => { + if (app !== undefined) await closeDesktop(app, userDataDir); + await project?.close(); + if (home !== undefined) rmSync(home, { recursive: true, force: true }); +}); + +test('opens on Start, from the disk, with nothing started', async () => { + await expect(page.getByRole('heading', { name: 'What would you like to do?' })).toBeVisible({ timeout: 60_000 }); + expect(page.url()).toMatch(/^file:.*\/renderer\/app\/index\.html/); + // A first launch: no recent project, no classic workspace. + await expect(page.getByText('Welcome to Adminium.')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Recent projects' })).toHaveCount(0); + await expect(page.locator('[data-choice]')).toHaveCount(4); + await expect(page.getByRole('button', { name: /Build an app/ })).toBeVisible(); + // No server, no data folder, no secret: nothing was chosen yet. + expect(existsSync(join(userDataDir, 'data', 'meta.db'))).toBe(false); + expect(existsSync(join(userDataDir, 'config.json'))).toBe(false); +}); + +test('New app proposes a folder of its own under ~/Adminium, and refuses one inside another project', async () => { + await page.getByRole('button', { name: /Build an app/ }).click(); + const name = page.getByLabel('Name'); + await expect(name).toBeFocused(); + const where = page.getByRole('textbox', { name: 'Where to keep it' }); + await expect(where).toHaveValue('~/Adminium'); + await expect(page.getByRole('button', { name: 'Create' })).toBeDisabled(); + + await name.fill('Juniper Kitchen'); + await expect(where).toHaveValue('~/Adminium/juniper-kitchen'); + await expect(page.getByRole('button', { name: 'Create' })).toBeEnabled(); + + // "Change…" to a folder that is a project: main refuses, with the reason. + const existing = join(home, 'Existing'); + mkdirSync(existing); + writeFileSync(join(existing, 'adminium.config.ts'), 'export default {};\n'); + await pickerAnswers(existing); + await page.getByRole('button', { name: 'Change…' }).click(); + await expect(page.getByRole('alert')).toHaveText('This folder is inside another project. Choose a folder outside it.'); + await expect(page.getByRole('button', { name: 'Create' })).toBeDisabled(); + // Nothing was made anywhere. + expect(existsSync(join(home, 'Adminium'))).toBe(false); + expect(existsSync(join(existing, 'juniper-kitchen'))).toBe(false); + + await page.getByRole('button', { name: 'Back' }).click(); + await expect(page.getByRole('heading', { name: 'What would you like to do?' })).toBeVisible(); +}); + +test('a folder is opened only after the question, and Cancel opens nothing', async () => { + await pickerAnswers(project.root); + await page.getByRole('button', { name: /Open a folder/ }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog.getByText('Open this folder?')).toBeVisible(); + await expect(dialog).toContainText(project.root); + await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused(); + await dialog.getByRole('button', { name: 'Cancel' }).click(); + await expect(dialog).toHaveCount(0); + // Not served, not remembered. + expect(existsSync(join(project.root, '.adminium', 'running.json'))).toBe(false); + expect(existsSync(join(userDataDir, 'config.json'))).toBe(false); +}); + +test('"Open" serves the folder in the Designer and remembers what was agreed to', async () => { + test.setTimeout(240_000); + await pickerAnswers(project.root); + await page.getByRole('button', { name: /Open a folder/ }).click(); + // The project gets a window on a cookie jar of its own: the Start window is replaced. + const next = app.waitForEvent('window'); + await page.getByRole('dialog').getByRole('button', { name: 'Open' }).click(); + page = await next; + await expect(page.getByRole('heading', { name: 'What do you want to build?' })).toBeVisible({ timeout: 180_000 }); + expect(new URL(page.url()).hostname).toBe('127.0.0.1'); + expect(app.windows()).toHaveLength(1); + + const saved = config(); + expect(saved.version).toBe(2); + expect(saved.projects).toHaveLength(1); + expect(saved.projects[0]).toMatchObject({ path: project.root, state: 'building' }); + expect(saved.projects[0]?.trusted).toMatch(/^[0-9a-f]{64}$/); + // The first screens' calls are the app's own pages': a project's page is refused them. + const refused = await page.evaluate(async () => { + try { + await (window as unknown as { adminiumDesktop: { start: { state: () => Promise } } }).adminiumDesktop.start.state(); + return 'answered'; + } catch (error) { + return String(error); + } + }); + expect(refused).toContain('UNTRUSTED_SENDER'); +}); + +test('the next launch lists it under Recent projects and opens it without asking again', async () => { + test.setTimeout(240_000); + await closeDesktop(app, undefined); + await launch(); + await expect(page.getByRole('heading', { name: 'Recent projects' })).toBeVisible({ timeout: 60_000 }); + await expect(page.getByText('Welcome to Adminium.')).toHaveCount(0); + const row = page.locator(`[data-recent="${project.root}"]`); + await expect(row).toContainText('Building'); + await expect(row).toContainText('Opened today'); + + const next = app.waitForEvent('window'); + await row.getByRole('button').first().click(); + page = await next; + await expect(page.getByRole('heading', { name: 'What do you want to build?' })).toBeVisible({ timeout: 180_000 }); +}); diff --git a/apps/e2e/tests-desktop/helpers/launch.ts b/apps/e2e/tests-desktop/helpers/launch.ts index c465513e2..1efe04352 100644 --- a/apps/e2e/tests-desktop/helpers/launch.ts +++ b/apps/e2e/tests-desktop/helpers/launch.ts @@ -80,6 +80,20 @@ export async function waitForAppWindow( ): Promise { const page = await app.firstWindow({ timeout: 30_000 }); const timeout = opts.timeout ?? 120_000; + // The app opens on its own first screen. These specs are the classic workspace's: they take its card, as a + // person does. (A launch that names a project, or hands the app a file, never shows the screen.) + const classic = page.locator('[data-choice="db"]'); + const onStart = await Promise.race([ + classic.waitFor({ state: 'visible', timeout }).then( + () => true, + () => false, + ), + page.waitForURL(/^http:\/\/127\.0\.0\.1:\d+\//, { timeout }).then( + () => false, + () => false, + ), + ]); + if (onStart) await classic.click(); try { await page.waitForURL(/^http:\/\/127\.0\.0\.1:\d+\//, { timeout }); } catch (error) { diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 1c2abc92b..23165281c 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -254,6 +254,8 @@ export { // The `adminium` CLI. `runCli` returns an exit code and never touches // `process` — `src/cli/index.ts` is the only module that exits. export { COMMANDS, findCommand, runCli, type RunCliOptions } from './cli/run.js'; +// The database a project made for the Designer starts with (the desktop app makes projects the same way). +export { DESIGN_DATABASE } from './cli/commands/design.js'; export { HOST_DECIDED_ENV, startProject, type StartProjectOptions, type StartedProject } from './cli/start-project.js'; export type { StartBusy } from './cli/commands/start.js'; export { readRunning, runningMessage, type RunningMark } from './project/running.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc2c47388..908e90759 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -230,6 +230,12 @@ importers: '@tailwindcss/vite': specifier: ^4.3.3 version: 4.3.3(vite@7.3.6(@types/node@22.15.3)(jiti@2.7.0)(lightningcss@1.33.0)(yaml@2.9.0)) + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/user-event': + specifier: ^14.6.4 + version: 14.6.4(@testing-library/dom@10.4.1) '@types/better-sqlite3': specifier: ^9.6.0 version: 9.6.0 @@ -257,6 +263,9 @@ importers: eslint: specifier: 'catalog:' version: 9.39.5(jiti@2.7.0) + happy-dom: + specifier: ^18.0.1 + version: 18.0.1 icon-gen: specifier: ^5.0.0 version: 5.0.0 @@ -283,7 +292,7 @@ importers: version: 7.3.6(@types/node@22.15.3)(jiti@2.7.0)(lightningcss@1.33.0)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 3.2.7(@types/debug@4.1.13)(@types/node@22.15.3)(happy-dom@20.11.2)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.33.0)(yaml@2.9.0) + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.15.3)(happy-dom@18.0.1)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.33.0)(yaml@2.9.0) optionalDependencies: mysql2: specifier: ^3.23.3 @@ -15660,6 +15669,50 @@ snapshots: optionalDependencies: vite: 8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) + vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.15.3)(happy-dom@18.0.1)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.33.0)(yaml@2.9.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@22.15.3)(jiti@2.7.0)(lightningcss@1.33.0)(yaml@2.9.0)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@22.15.3)(jiti@2.7.0)(lightningcss@1.33.0)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@22.15.3)(jiti@2.7.0)(lightningcss@1.33.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.13 + '@types/node': 22.15.3 + happy-dom: 18.0.1 + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.15.3)(happy-dom@20.11.2)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.33.0)(yaml@2.9.0): dependencies: '@types/chai': 5.2.3 From 57f460522c5a41800d858e7b1e1302d28e259d90 Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Sat, 10 Oct 2026 09:34:33 +0200 Subject: [PATCH 36/79] feat(desktop): close a project and come back to Start --- apps/desktop/src/main/index.test.ts | 69 +++ apps/desktop/src/main/index.ts | 613 ++++++++++--------- apps/desktop/src/main/ipc.test.ts | 45 ++ apps/desktop/src/main/ipc.ts | 21 + apps/desktop/src/main/project.test.ts | 2 + apps/desktop/src/main/project.ts | 6 +- apps/desktop/src/preload/api.d.ts | 30 + apps/desktop/src/preload/channels.ts | 6 + apps/desktop/src/preload/index.test.ts | 15 + apps/desktop/src/preload/index.ts | 7 + apps/desktop/src/renderer/app/pages.test.tsx | 2 +- apps/e2e/tests-desktop/desktop-start.spec.ts | 21 + 12 files changed, 552 insertions(+), 285 deletions(-) diff --git a/apps/desktop/src/main/index.test.ts b/apps/desktop/src/main/index.test.ts index 71c3e0b19..7bebe26cb 100644 --- a/apps/desktop/src/main/index.test.ts +++ b/apps/desktop/src/main/index.test.ts @@ -1717,6 +1717,75 @@ describe('createDesktopApp opening on Start', () => { } }); + 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(); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 6018bd732..600a8e36c 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -89,7 +89,7 @@ import { createMakeProject, runToEnd } from './make-project.js'; import { carriedNpmDir, provideDesktopPrograms } from './programs.js'; import { firstFreePort, projectPortRange, seamProject, sessionCookieNames, stopBusyWords } from './project.js'; import { realFolderDeps, type FolderDeps } from './projects.js'; -import { createStartService, type StartChoice, type StartDeps, type StartService } from './start.js'; +import { createStartService, displayPathOf, nameFromFolder, type StartChoice, type StartDeps, type StartService } from './start.js'; import { createServerManager, type CreateServerManagerOptions, @@ -397,6 +397,8 @@ export interface DesktopBridgeContext { 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 } | undefined; } /** What the boot sequence knows about itself; `getRuntimeInfo` reads it. */ @@ -594,7 +596,7 @@ export interface DesktopBootDeps { * 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') => Promise) | undefined; + confirmStopBusy?: ((busy: ServerBusy, why: 'quit' | 'close') => Promise) | undefined; } /** What {@link DesktopBootDeps.createBackup} needs from the boot sequence. */ @@ -662,6 +664,8 @@ export function createDesktopApp(deps: DesktopBootDeps): DesktopApp { 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 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 @@ -934,6 +938,17 @@ export function createDesktopApp(deps: DesktopBootDeps): DesktopApp { 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()), + }, }); // The three buttons, graceful shutdown. BOTH before the config steps @@ -1130,299 +1145,334 @@ export function createDesktopApp(deps: DesktopBootDeps): DesktopApp { }); }; - 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. - if (deps.openProject !== undefined) { - await bootProject(loaded.config, deps.openProject); - return; - } - const screen = deps.startScreen; - if (screen !== undefined && windows.showStart !== undefined && 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 }), - onChoice: choose, - }); - startOpen = true; - void windows.showStart?.(); - }); - startOpen = false; - if (choice.kind === 'project') { - await bootProject(config ?? loaded.config, { root: choice.root }); + /* + * 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; } - // "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 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 }), + onChoice: choose, + }); + startOpen = true; + void windows.showStart?.(); + }); + startOpen = false; + if (choice.kind === 'project') { + await bootProject(config ?? loaded.config, { root: choice.root }); + 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 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, + 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 }; + 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 }); - // 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), - }), - ); + // 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(); - // 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(); + // 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').catch(() => true))) return false; + await target.stop().catch(() => undefined); + await startService?.trustNow(root).catch(() => undefined); + updateManager?.dispose(); + updateManager = null; + manager = null; + runtime = null; + windows.useProjectSession?.(null); + void chooseAndBoot(true); + return true; + }; + + await chooseAndBoot(false); }, }; } @@ -2216,8 +2266,8 @@ export function electronBootDeps(): DesktopBootDeps { // 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) => { - const words = stopBusyWords(busy); + confirmStopBusy: async (busy, why) => { + const words = stopBusyWords(busy, why); const parent = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0] ?? null; const options: Electron.MessageBoxOptions = { type: 'warning', @@ -2238,6 +2288,7 @@ export function electronBootDeps(): DesktopBootDeps { 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 diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index e459a3c5d..7e88bf636 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -766,3 +766,48 @@ describe('the first screens’ channels', () => { 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('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 a4f004db9..75dda6f8c 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -51,6 +51,7 @@ import type { DesktopConfigPatch, DesktopDiagnostics, DesktopMenuLabels, + DesktopProjectInfo, DesktopRuntimeInfo, DesktopUpdateCheckResult, DesktopUpdateEvent, @@ -477,6 +478,8 @@ export interface RegisterIpcHandlersOptions { * (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 } | 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. */ @@ -834,6 +837,24 @@ export function registerIpcHandlers(opts: RegisterIpcHandlersOptions): IpcHandle ); 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(); + }); + register( IPC_CHANNELS.startUseClassic, noPayloadSchema, diff --git a/apps/desktop/src/main/project.test.ts b/apps/desktop/src/main/project.test.ts index b2baa6cae..623f26f17 100644 --- a/apps/desktop/src/main/project.test.ts +++ b/apps/desktop/src/main/project.test.ts @@ -85,6 +85,8 @@ describe('stopBusyWords', () => { 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.'); }); }); diff --git a/apps/desktop/src/main/project.ts b/apps/desktop/src/main/project.ts index 9f74e7ec1..14d52c539 100644 --- a/apps/desktop/src/main/project.ts +++ b/apps/desktop/src/main/project.ts @@ -92,7 +92,7 @@ export interface StopBusyWords { } /** What is said before the app ends a project's server that is in the middle of something. */ -export function stopBusyWords(busy: ServerBusy): StopBusyWords { +export function stopBusyWords(busy: ServerBusy, why: 'quit' | 'close' = 'quit'): StopBusyWords { const what: Record = { turn: 'The Designer is in the middle of a turn.', start: 'An app is being added to this project.', @@ -102,8 +102,8 @@ export function stopBusyWords(busy: ServerBusy): StopBusyWords { }; return { title: what[busy.kind] ?? 'This project is being changed.', - detail: 'If you quit now it is stopped where it is. What was already written stays.', - goOn: 'Quit anyway', + detail: why === 'close' ? 'If you close the project now it is stopped where it is. What was already written stays.' : 'If you quit now it is stopped where it is. What was already written stays.', + goOn: why === 'close' ? 'Close anyway' : 'Quit anyway', stay: 'Keep working', }; } diff --git a/apps/desktop/src/preload/api.d.ts b/apps/desktop/src/preload/api.d.ts index ef545f09c..9f0b56f44 100644 --- a/apps/desktop/src/preload/api.d.ts +++ b/apps/desktop/src/preload/api.d.ts @@ -504,6 +504,33 @@ export interface DesktopStartApi { useClassic(): Promise; } +// ─── A project the window holds ────────────────────────────────────────────── + +/** The project folder this window serves. */ +export interface DesktopProjectInfo { + readonly name: string; + /** The folder, as a person reads it (`~/Adminium/shop`). */ + readonly displayPath: string; + /** `design`: being built, on this computer only. `serve`: shared. */ + readonly mode: 'design' | 'serve'; +} + +/** + * What a project's own page asks of the app. Each call names nothing: it acts + * on the project this window holds, so a page cannot point it at another folder. + */ +export interface DesktopProjectApi { + /** `null` in the classic workspace: the window holds no project. */ + info(): Promise; + /** "Show in Finder" / "Show in File Explorer": the project's folder. */ + showInFolder(): Promise; + /** + * "Close project": back to the app's first screen. `false` when the person + * chose to keep working (something was in the middle of running). + */ + close(): Promise; +} + // ─── The API ───────────────────────────────────────────────────────────────── /** @@ -608,6 +635,9 @@ export interface AdminiumDesktopApi { // the first screens (the app's own pages only) readonly start: DesktopStartApi; + + // the project this window holds + readonly project: DesktopProjectApi; } /** diff --git a/apps/desktop/src/preload/channels.ts b/apps/desktop/src/preload/channels.ts index 20c2f2264..802afe2aa 100644 --- a/apps/desktop/src/preload/channels.ts +++ b/apps/desktop/src/preload/channels.ts @@ -64,6 +64,9 @@ export const IPC_CHANNELS = { startForgetProject: 'adminium-desktop:start-forget-project', startLocateProject: 'adminium-desktop:start-locate-project', startUseClassic: 'adminium-desktop:start-use-classic', + projectInfo: 'adminium-desktop:project-info', + projectShowInFolder: 'adminium-desktop:project-show-in-folder', + projectClose: 'adminium-desktop:project-close', updateEvent: 'adminium-desktop:update-event', } as const; @@ -106,6 +109,9 @@ export const INVOKE_CHANNELS = [ IPC_CHANNELS.relaunch, IPC_CHANNELS.showLogs, ...START_CHANNELS, + IPC_CHANNELS.projectInfo, + IPC_CHANNELS.projectShowInFolder, + IPC_CHANNELS.projectClose, ] as const; /** diff --git a/apps/desktop/src/preload/index.test.ts b/apps/desktop/src/preload/index.test.ts index 60ddea610..d2a1e8970 100644 --- a/apps/desktop/src/preload/index.test.ts +++ b/apps/desktop/src/preload/index.test.ts @@ -97,6 +97,7 @@ describe('the exposed surface', () => { 'onUpdateEvent', 'openFile', 'platform', + 'project', 'quitAndInstall', 'readBundledText', 'relaunch', @@ -137,6 +138,20 @@ describe('the exposed surface', () => { expect(ipc.calls.map((call) => call.channel)).toEqual([...START_CHANNELS]); }); + it('exposes the project’s three calls, and none of them takes a folder', async () => { + const ipc = new FakeIpc(); + const { project } = createDesktopApi({ ipc, bootstrap: BOOTSTRAP }); + expect(Object.keys(project).sort()).toEqual(['close', 'info', 'showInFolder']); + await project.info(); + await project.showInFolder(); + await project.close(); + expect(ipc.calls).toEqual([ + { channel: IPC_CHANNELS.projectInfo, args: [] }, + { channel: IPC_CHANNELS.projectShowInFolder, args: [] }, + { channel: IPC_CHANNELS.projectClose, args: [] }, + ]); + }); + it('exposes capabilities as exactly list + invoke', () => { expect(Object.keys(build().capabilities).sort()).toEqual(['invoke', 'list']); }); diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 8f8ae739d..7e0997d00 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -54,6 +54,7 @@ import type { DesktopNewFolderJudgement, DesktopOpenProjectInput, DesktopOpenProjectResult, + DesktopProjectInfo, DesktopRecentProject, DesktopRuntimeInfo, DesktopStartState, @@ -292,6 +293,12 @@ export function createDesktopApi(deps: Pick): unwrap(ipc.invoke(IPC_CHANNELS.startLocateProject, input)), useClassic: (): Promise => unwrap(ipc.invoke(IPC_CHANNELS.startUseClassic)), }, + + project: { + info: (): Promise => unwrap(ipc.invoke(IPC_CHANNELS.projectInfo)), + showInFolder: (): Promise => unwrap(ipc.invoke(IPC_CHANNELS.projectShowInFolder)), + close: (): Promise => unwrap(ipc.invoke(IPC_CHANNELS.projectClose)), + }, }; } diff --git a/apps/desktop/src/renderer/app/pages.test.tsx b/apps/desktop/src/renderer/app/pages.test.tsx index 61cf580b1..7235b439d 100644 --- a/apps/desktop/src/renderer/app/pages.test.tsx +++ b/apps/desktop/src/renderer/app/pages.test.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only // @vitest-environment happy-dom import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { userEvent } from '@testing-library/user-event'; import type { I18nInstance } from '@adminium/i18n'; import { I18nProvider } from '@adminium/i18n/react'; import type { ReactNode } from 'react'; diff --git a/apps/e2e/tests-desktop/desktop-start.spec.ts b/apps/e2e/tests-desktop/desktop-start.spec.ts index bbff808ee..ba5085f72 100644 --- a/apps/e2e/tests-desktop/desktop-start.spec.ts +++ b/apps/e2e/tests-desktop/desktop-start.spec.ts @@ -161,3 +161,24 @@ test('the next launch lists it under Recent projects and opens it without asking page = await next; await expect(page.getByRole('heading', { name: 'What do you want to build?' })).toBeVisible({ timeout: 180_000 }); }); + +test('"Close project" comes back to Start, and the project can be opened again', async () => { + test.setTimeout(240_000); + // The project's own page asks: what it holds, then to be closed. (The dashboard's menu will call the same.) + const info = await page.evaluate(() => (window as unknown as { adminiumDesktop: { project: { info: () => Promise } } }).adminiumDesktop.project.info()); + // Outside the (temporary) home folder, so the path is shown whole. + expect(info).toEqual({ name: 'Demo', displayPath: project.root, mode: 'design' }); + + const next = app.waitForEvent('window'); + void page.evaluate(() => (window as unknown as { adminiumDesktop: { project: { close: () => Promise } } }).adminiumDesktop.project.close()).catch(() => undefined); + page = await next; + await expect(page.getByRole('heading', { name: 'What would you like to do?' })).toBeVisible({ timeout: 60_000 }); + expect(app.windows()).toHaveLength(1); + // Stopped: the folder is free again. + await expect.poll(() => existsSync(join(project.root, '.adminium', 'running.json'))).toBe(false); + + const again = app.waitForEvent('window'); + await page.locator(`[data-recent="${project.root}"]`).getByRole('button').first().click(); + page = await again; + await expect(page.getByRole('heading', { name: 'What do you want to build?' })).toBeVisible({ timeout: 180_000 }); +}); From 83431346d5d90298429395656455496f37ff85e0 Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Sat, 10 Oct 2026 09:43:55 +0200 Subject: [PATCH 37/79] feat(designer): the project's button and Build | Share inside the desktop app --- .../src/designer/build/BuildPage.tsx | 2 + .../designer/parts/DesktopProject.test.tsx | 132 ++++++++++++++++++ .../src/designer/parts/DesktopProject.tsx | 116 +++++++++++++++ apps/dashboard/src/designer/parts/TopBar.tsx | 29 ++-- .../e2e/tests-desktop/desktop-project.spec.ts | 38 +++++ apps/e2e/tests-desktop/desktop-start.spec.ts | 10 +- packages/i18n/locales/ar-EG/.meta.json | 36 +++++ packages/i18n/locales/ar-EG/designer.json | 13 ++ packages/i18n/locales/cs-CZ/.meta.json | 36 +++++ packages/i18n/locales/cs-CZ/designer.json | 13 ++ packages/i18n/locales/da-DK/.meta.json | 36 +++++ packages/i18n/locales/da-DK/designer.json | 13 ++ packages/i18n/locales/de-DE/.meta.json | 36 +++++ packages/i18n/locales/de-DE/designer.json | 13 ++ packages/i18n/locales/en-US/designer.json | 13 ++ packages/i18n/locales/fr-FR/.meta.json | 36 +++++ packages/i18n/locales/fr-FR/designer.json | 13 ++ packages/i18n/locales/zh-CN/.meta.json | 36 +++++ packages/i18n/locales/zh-CN/designer.json | 13 ++ packages/i18n/locales/zh-TW/.meta.json | 36 +++++ packages/i18n/locales/zh-TW/designer.json | 13 ++ packages/i18n/src/a11y-keys.ts | 7 +- packages/i18n/src/resources/ar-eg/designer.ts | 13 ++ packages/i18n/src/resources/cs-cz/designer.ts | 13 ++ packages/i18n/src/resources/da-dk/designer.ts | 13 ++ packages/i18n/src/resources/de-de/designer.ts | 13 ++ packages/i18n/src/resources/en-us/designer.ts | 13 ++ packages/i18n/src/resources/fr-fr/designer.ts | 13 ++ packages/i18n/src/resources/zh-cn/designer.ts | 13 ++ packages/i18n/src/resources/zh-tw/designer.ts | 13 ++ packages/i18n/src/review-status.ts | 14 +- 31 files changed, 788 insertions(+), 20 deletions(-) create mode 100644 apps/dashboard/src/designer/parts/DesktopProject.test.tsx create mode 100644 apps/dashboard/src/designer/parts/DesktopProject.tsx diff --git a/apps/dashboard/src/designer/build/BuildPage.tsx b/apps/dashboard/src/designer/build/BuildPage.tsx index 651d0a2c4..91d6c1b59 100644 --- a/apps/dashboard/src/designer/build/BuildPage.tsx +++ b/apps/dashboard/src/designer/build/BuildPage.tsx @@ -48,6 +48,7 @@ import { UsageLine, } from '../parts/chat.js'; import { TopBar } from '../parts/TopBar.js'; +import { DesktopBuildShare } from '../parts/DesktopProject.js'; import { CHAT_WIDTH, FOLDED_NEED_GUESS, views } from './barLevel.js'; import { LeaveGuard } from './LeaveGuard.js'; import { StyleMenu } from './LookMenu.js'; @@ -525,6 +526,7 @@ export function BuildPage({ sessionId }: { sessionId: string }): ReactNode { onOpen={(id) => void navigate({ to: '/design/$sessionId', params: { sessionId: id } })} /> )} + 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; Share cannot be chosen yet', () => { + 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..b1e11e0cb --- /dev/null +++ b/apps/dashboard/src/designer/parts/DesktopProject.tsx @@ -0,0 +1,116 @@ +// 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'; + +/** 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 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'); +} + +/** The rule, then the project's name as a button with its menu. */ +export function ProjectButton({ project }: { project: DesktopProjectInfo }): ReactNode { + const api = getDesktopApi(); + return ( + <> +
+
+ + + {t('designer:brand', 'Adminium Designer')} + + {project === null ? null : } +
+ {centre === undefined ? null :
{centre}
} +
{end} {dashboardLink ? ( ) : null} , ) : null} - {rows + {rows || !preview.canOpenTab ? null : tip( t('designer:preview.newTab', 'Open in a new tab'), @@ -317,7 +317,7 @@ export function WorkBar({ , )} {level === 0 ? null : live ? ( - preview.setSide(value as PreviewSide)} seen={seen} sees={sees} onSees={setSees} onNewTab={preview.openTab} /> + preview.setSide(value as PreviewSide)} seen={seen} sees={sees} onSees={setSees} {...(preview.canOpenTab ? { onNewTab: preview.openTab } : {})} /> ) : ( )} diff --git a/apps/dashboard/src/designer/build/buildPage.stories.tsx b/apps/dashboard/src/designer/build/buildPage.stories.tsx index d7a92fbb2..c0351ea5c 100644 --- a/apps/dashboard/src/designer/build/buildPage.stories.tsx +++ b/apps/dashboard/src/designer/build/buildPage.stories.tsx @@ -59,6 +59,7 @@ function previewOf(sides: readonly PreviewSide[], side: PreviewSide, seenAs: rea ticketError: null, running: false, openTab: noop, + canOpenTab: true, }; } diff --git a/apps/dashboard/src/designer/build/usePreview.ts b/apps/dashboard/src/designer/build/usePreview.ts index 6e05c2ea2..5cde8b9da 100644 --- a/apps/dashboard/src/designer/build/usePreview.ts +++ b/apps/dashboard/src/designer/build/usePreview.ts @@ -21,6 +21,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { api, ApiError } from '../../app/api.js'; import { systemInfoQuery } from '../../app/capabilities.js'; +import { getDesktopApi } from '../../lib/desktop-runtime.js'; import { createRealtimeClient } from '../../app/ws.js'; import type { InstalledApp } from '../../studio/apps/appsApi.js'; import { designerApi, type DesignerSession } from '../api.js'; @@ -74,6 +75,8 @@ export interface PreviewModel { /** Whether the last turn is still running: only then is anybody waiting to see the page. */ running: boolean; openTab: () => void; + /** False where there is nowhere to open it: the dashboard's own pages, inside the desktop app (its browser holds no sign-in). */ + canOpenTab: boolean; } export function usePreview(session: DesignerSession, turns: readonly TurnView[]): PreviewModel { @@ -255,7 +258,28 @@ export function usePreview(session: DesignerSession, turns: readonly TurnView[]) }, [setPath], ); + // Inside the desktop app there are no tabs: "a new tab" is the person's own browser, and the app hands it the + // address. It is asked for with the address already in hand, because the app refuses a window and reads only that. + const inApp = getDesktopApi() !== undefined; + const canOpenTab = !(inApp && side === 'dashboard'); + const openInBrowser = (): void => { + if (side === 'customer' && origin !== null) { + window.open(`${origin}${to}`, '_blank', 'noopener'); + return; + } + if (side !== 'staff') return; + void designerApi + .previewTicket(session.id, `/a/${session.appKey}${path === '/' ? '' : path}`) + .then((reply) => { + window.open(reply.url, '_blank', 'noopener'); + }) + .catch(() => undefined); + }; const openTab = (): void => { + if (inApp) { + openInBrowser(); + return; + } // Opened now, filled when the ticket arrives: a window opened after a wait is a blocked pop-up. const tab = window.open('', '_blank'); if (tab === null) return; @@ -308,5 +332,6 @@ export function usePreview(session: DesignerSession, turns: readonly TurnView[]) ticketError: ticket.isError && side !== 'dashboard' ? ticket.error.message : null, running, openTab, + canOpenTab, }; } diff --git a/apps/dashboard/src/designer/build/workBar.test.tsx b/apps/dashboard/src/designer/build/workBar.test.tsx index 51166455b..80ba03be3 100644 --- a/apps/dashboard/src/designer/build/workBar.test.tsx +++ b/apps/dashboard/src/designer/build/workBar.test.tsx @@ -54,6 +54,7 @@ function model(over: Partial = {}): PreviewModel { ticketError: null, running: false, openTab: vi.fn(), + canOpenTab: true, ...over, }; } @@ -166,6 +167,17 @@ describe('the work bar', () => { expect(preview.openTab).toHaveBeenCalled(); }); + it('offers no new tab where there is nowhere to open one (the dashboard’s pages, inside the desktop app)', async () => { + bar(4, model({ side: 'dashboard', canOpenTab: false })); + await userEvent.click(screen.getByRole('button', { name: 'More' })); + expect(within(await screen.findByRole('menu', { name: 'More' })).queryByRole('menuitem', { name: 'Open in a new tab' })).toBeNull(); + }); + + it('and no button for it on the wide bar either', () => { + bar(0, model({ side: 'dashboard', canOpenTab: false })); + expect(parts().newTab).toBe(false); + }); + it('moves through the sides with the arrow keys, the chosen one the row’s one tab stop', async () => { const preview = model(); bar(0, preview); diff --git a/apps/desktop/src/main/window.test.ts b/apps/desktop/src/main/window.test.ts index 42c6aaac3..e62602a75 100644 --- a/apps/desktop/src/main/window.test.ts +++ b/apps/desktop/src/main/window.test.ts @@ -23,6 +23,7 @@ import { crashRenderScript, decideFrameNavigation, decideNavigation, + decideNewWindow, isPermissionAllowed, jsonForScript, originOf, @@ -491,6 +492,28 @@ const html = (name: string): string => */ const markup = (name: string): string => html(name).replace(//g, ''); +describe('a new window', () => { + const APP = 'http://127.0.0.1:52341'; + const PREVIEW = 'http://localhost:52341'; + + it('hands a page of the preview to the system’s browser: "Open in a new tab" on the build page', () => { + expect(decideNewWindow(`${PREVIEW}/apps/shop/customer/menu`, APP, PREVIEW)).toEqual({ action: 'external', url: `${PREVIEW}/apps/shop/customer/menu` }); + expect(decideNewWindow(`${PREVIEW}/designer-preview/enter?ticket=abc&to=%2Fa%2Fshop`, APP, PREVIEW)).toMatchObject({ action: 'external' }); + }); + + it('hands over nothing that only looks like the preview, and nothing when no project is being built', () => { + for (const target of ['http://localhost:9999/x', 'http://localhost.evil.com:52341/x', `blob:${PREVIEW}/1b2c`, 'about:blank', '', 'file:///etc/passwd', 'javascript:alert(1)']) { + expect(decideNewWindow(target, APP, PREVIEW).action, target).toBe('deny'); + } + expect(decideNewWindow(`${PREVIEW}/apps/shop/customer`, APP, null)).toMatchObject({ action: 'deny' }); + }); + + it('never opens the app’s own pages anywhere else, and still sends https to the browser', () => { + expect(decideNewWindow(`${APP}/tables`, APP, PREVIEW)).toMatchObject({ action: 'deny' }); + expect(decideNewWindow('https://docs.adminium.dev/', APP, PREVIEW)).toEqual({ action: 'external', url: 'https://docs.adminium.dev/' }); + }); +}); + describe('boot.html / crash.html (offline guarantee)', () => { it.each(['boot.html', 'crash.html'])('%s references no remote origin', (name) => { // "fully functional with the network cable unplugged, forever". These two diff --git a/apps/desktop/src/main/window.ts b/apps/desktop/src/main/window.ts index 18e0b0241..cd0980238 100644 --- a/apps/desktop/src/main/window.ts +++ b/apps/desktop/src/main/window.ts @@ -164,6 +164,32 @@ export function decideNavigation(target: string, appOrigin: string | null): Navi return { action: 'deny', reason: `blocked ${url.protocol} navigation to ${url.host || target}` }; } +/** + * The rule for a NEW window (`window.open`, `target="_blank"`). None is ever + * made; the question is only whether the address is handed to the system's + * browser. + * + * `https:` goes there, as {@link decideNavigation} says. So does a page on the + * Designer's preview origin: "Open in a new tab" on the build page means the + * person's own browser, where the screen being built is seen as a visitor will + * see it. That origin is `http://localhost:` and is this app's own + * server, so the only thing handed over is an address of ours: the scheme and + * the origin are both compared, as everywhere in this file. + * + * The app's own origin (`http://127.0.0.1:`) is NOT handed over: the + * dashboard there is signed in by this window's own session, and a browser + * with no session would only show a sign-in page nobody holds a password for. + */ +export function decideNewWindow(target: string, appOrigin: string | null, previewOrigin: string | null): NavigationDecision { + const preview = previewOrigin === null ? null : safeParse(previewOrigin); + const url = safeParse(target); + if (preview !== null && url !== null && url.protocol === 'http:' && url.protocol === preview.protocol && url.origin === preview.origin) { + return { action: 'external', url: url.toString() }; + } + const decision = decideNavigation(target, appOrigin); + return decision.action === 'allow' ? { action: 'deny', reason: 'the app’s own pages open in this window only' } : decision; +} + /** * The rule for a frame that is NOT the main one (`will-frame-navigate`, and * `will-redirect` on a subframe): it may load the app's own origin (a hosted @@ -699,7 +725,7 @@ export function createWindowManager(opts: CreateWindowManagerOptions): DesktopWi // window created here would be a NEW BrowserWindow this manager never // configured, carrying none of the posture above and none of these handlers. created.webContents.setWindowOpenHandler(({ url }) => { - const decision = decideNavigation(url, appOrigin); + const decision = decideNewWindow(url, appOrigin, previewOrigin); if (decision.action === 'external') void shell.openExternal(decision.url); return { action: 'deny' }; }); From 46e8490ae1902c5984123da9b8b586c1f17120bd Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Sat, 10 Oct 2026 15:14:59 +0200 Subject: [PATCH 48/79] feat(designer): a one-use link opens the dashboard in the system's browser --- apps/dashboard/src/designer/api.ts | 2 + .../src/designer/build/usePreview.ts | 15 ++++- apps/desktop/src/main/window.test.ts | 7 ++- apps/desktop/src/main/window.ts | 16 +++-- apps/server/src/designer/design-links.ts | 58 +++++++++++++++++++ apps/server/src/routes/auth/design-session.ts | 47 ++++++++++++++- apps/server/test/design-links.test.ts | 37 ++++++++++++ apps/server/test/design-mode.test.ts | 38 ++++++++++++ 8 files changed, 210 insertions(+), 10 deletions(-) create mode 100644 apps/server/src/designer/design-links.ts create mode 100644 apps/server/test/design-links.test.ts diff --git a/apps/dashboard/src/designer/api.ts b/apps/dashboard/src/designer/api.ts index 060e1ecd9..cebf7f100 100644 --- a/apps/dashboard/src/designer/api.ts +++ b/apps/dashboard/src/designer/api.ts @@ -403,6 +403,8 @@ export const designerApi = { /** One of the app's files a person may open, whole, with the hash it has now. */ fileContent: (id: string, path: string) => api.get<{ path: string; content: string; hash: string }>(`${BASE}/sessions/${id}/files/content?path=${encodeURIComponent(path)}`), previewTicket: (id: string, to: string) => api.post<{ url: string; origin: string; seenAs?: string[] }>(`${BASE}/sessions/${id}/preview-ticket`, { to }), + /** A one-use link that signs this project's local owner in from the system's browser, landing on `to` (a path). */ + browserLink: (to: string) => api.post<{ data: { url: string } }>('/auth/design-link', { to }), setOwnerPassword: (input: { email: string; password: string }) => api.post<{ email: string }>(`${BASE}/owner-password`, input), createSession: (input: { appKey?: string; name?: string; /** The session's title until the Designer names the app. */ title?: string; /** A style picked at the start. */ style?: string; target: DesignerTarget; connectionId: string; model: string; text?: string; /** With `text`: this page will show the preview and say what it sees. */ sees?: boolean }) => api.post<{ session: DesignerSession; turn: number | null }>(`${BASE}/sessions`, input), diff --git a/apps/dashboard/src/designer/build/usePreview.ts b/apps/dashboard/src/designer/build/usePreview.ts index 5cde8b9da..ea48bdfbe 100644 --- a/apps/dashboard/src/designer/build/usePreview.ts +++ b/apps/dashboard/src/designer/build/usePreview.ts @@ -75,7 +75,7 @@ export interface PreviewModel { /** Whether the last turn is still running: only then is anybody waiting to see the page. */ running: boolean; openTab: () => void; - /** False where there is nowhere to open it: the dashboard's own pages, inside the desktop app (its browser holds no sign-in). */ + /** False where there is nowhere to open it. */ canOpenTab: boolean; } @@ -261,12 +261,23 @@ export function usePreview(session: DesignerSession, turns: readonly TurnView[]) // Inside the desktop app there are no tabs: "a new tab" is the person's own browser, and the app hands it the // address. It is asked for with the address already in hand, because the app refuses a window and reads only that. const inApp = getDesktopApi() !== undefined; - const canOpenTab = !(inApp && side === 'dashboard'); + const canOpenTab = true; const openInBrowser = (): void => { if (side === 'customer' && origin !== null) { window.open(`${origin}${to}`, '_blank', 'noopener'); return; } + // The dashboard is signed into by this window's own session, which the system's browser does not hold: the owner is + // given a link that signs them in there, once, and lands on the page shown here. + if (side === 'dashboard') { + void designerApi + .browserLink(path) + .then((reply) => { + window.open(reply.data.url, '_blank', 'noopener'); + }) + .catch(() => undefined); + return; + } if (side !== 'staff') return; void designerApi .previewTicket(session.id, `/a/${session.appKey}${path === '/' ? '' : path}`) diff --git a/apps/desktop/src/main/window.test.ts b/apps/desktop/src/main/window.test.ts index e62602a75..b2f9f03f4 100644 --- a/apps/desktop/src/main/window.test.ts +++ b/apps/desktop/src/main/window.test.ts @@ -508,8 +508,13 @@ describe('a new window', () => { expect(decideNewWindow(`${PREVIEW}/apps/shop/customer`, APP, null)).toMatchObject({ action: 'deny' }); }); - it('never opens the app’s own pages anywhere else, and still sends https to the browser', () => { + it('never opens the app’s own pages anywhere else, but for a one-use sign-in link; and still sends https to the browser', () => { expect(decideNewWindow(`${APP}/tables`, APP, PREVIEW)).toMatchObject({ action: 'deny' }); + const link = `${APP}/tables#designToken=${'a'.repeat(64)}`; + expect(decideNewWindow(link, APP, PREVIEW)).toEqual({ action: 'external', url: link }); + for (const near of [`${APP}/tables#designToken=short`, `${APP}/tables#other=${'a'.repeat(64)}`, `http://127.0.0.1:9/tables#designToken=${'a'.repeat(64)}`]) { + expect(decideNewWindow(near, APP, PREVIEW), near).toMatchObject({ action: 'deny' }); + } expect(decideNewWindow('https://docs.adminium.dev/', APP, PREVIEW)).toEqual({ action: 'external', url: 'https://docs.adminium.dev/' }); }); }); diff --git a/apps/desktop/src/main/window.ts b/apps/desktop/src/main/window.ts index cd0980238..71347d32d 100644 --- a/apps/desktop/src/main/window.ts +++ b/apps/desktop/src/main/window.ts @@ -164,6 +164,9 @@ export function decideNavigation(target: string, appOrigin: string | null): Navi return { action: 'deny', reason: `blocked ${url.protocol} navigation to ${url.host || target}` }; } +/** A one-use sign-in token after `#`, as the server mints it. */ +const SIGN_IN_HASH = /^#designToken=[0-9a-f]{64}$/; + /** * The rule for a NEW window (`window.open`, `target="_blank"`). None is ever * made; the question is only whether the address is handed to the system's @@ -176,9 +179,12 @@ export function decideNavigation(target: string, appOrigin: string | null): Navi * server, so the only thing handed over is an address of ours: the scheme and * the origin are both compared, as everywhere in this file. * - * The app's own origin (`http://127.0.0.1:`) is NOT handed over: the - * dashboard there is signed in by this window's own session, and a browser - * with no session would only show a sign-in page nobody holds a password for. + * The app's own origin (`http://127.0.0.1:`) is handed over in ONE shape + * only: with a one-use sign-in token after `#` (`#designToken=<64 hex>`), which + * the server mints for the signed-in owner (`POST /auth/design-link`). The + * dashboard there is signed in by this window's own session; without that + * token the system's browser would show a sign-in page nobody holds a password + * for, so a bare address of ours is never sent there. */ export function decideNewWindow(target: string, appOrigin: string | null, previewOrigin: string | null): NavigationDecision { const preview = previewOrigin === null ? null : safeParse(previewOrigin); @@ -187,7 +193,9 @@ export function decideNewWindow(target: string, appOrigin: string | null, previe return { action: 'external', url: url.toString() }; } const decision = decideNavigation(target, appOrigin); - return decision.action === 'allow' ? { action: 'deny', reason: 'the app’s own pages open in this window only' } : decision; + if (decision.action !== 'allow') return decision; + if (url !== null && SIGN_IN_HASH.test(url.hash)) return { action: 'external', url: url.toString() }; + return { action: 'deny', reason: 'the app’s own pages open in this window only' }; } /** diff --git a/apps/server/src/designer/design-links.ts b/apps/server/src/designer/design-links.ts new file mode 100644 index 000000000..863589cc3 --- /dev/null +++ b/apps/server/src/designer/design-links.ts @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** + * One-use links that sign the project's local owner in from another browser + * on this machine. + * + * The link `adminium design` prints, and the one the desktop app opens its + * window with, is this run's single token, and the window spends it. A person + * who then asks for the dashboard "in a new tab" from inside the desktop app + * is sent to the system's browser, which holds no session. So the owner, + * already signed in, may ask for one more link: a fresh token, good for a + * minute, spent on first use, taken by the same door + * (`routes/auth/design-session.ts`) under every one of its other gates. + * + * Held in memory only, a few at a time: a server that runs long does not + * collect them, and a restart forgets them all. + */ +import { randomBytes } from 'node:crypto'; + +import { bootTokenMatches } from '../auth/desktop-session.js'; + +/** How long a link asked for can be opened: long enough for a browser to start. */ +export const DESIGN_BROWSER_LINK_MS = 60_000; +const HELD_MOST = 5; + +export interface DesignLinks { + /** A new token, good once and for a minute. */ + issue(): string; + /** Whether `candidate` is a token that was issued, is not too old and was never used. It is spent by this call. */ + claim(candidate: string): boolean; +} + +export function createDesignLinks(opts: { now?: () => number; token?: () => string } = {}): DesignLinks { + const now = opts.now ?? Date.now; + const make = opts.token ?? (() => randomBytes(32).toString('hex')); + let held: { token: string; until: number }[] = []; + const live = (): void => { + held = held.filter((link) => link.until >= now()); + }; + return { + issue() { + live(); + const token = make(); + held = [...held, { token, until: now() + DESIGN_BROWSER_LINK_MS }].slice(-HELD_MOST); + return token; + }, + claim(candidate) { + live(); + // Every held one is compared, in constant time each: which one matched is not told by how long it took. + let found = -1; + held.forEach((link, index) => { + if (bootTokenMatches(candidate, link.token)) found = index; + }); + if (found === -1) return false; + held = held.filter((_, index) => index !== found); + return true; + }, + }; +} diff --git a/apps/server/src/routes/auth/design-session.ts b/apps/server/src/routes/auth/design-session.ts index de1bd126a..45d5b7c91 100644 --- a/apps/server/src/routes/auth/design-session.ts +++ b/apps/server/src/routes/auth/design-session.ts @@ -18,7 +18,13 @@ * 4. TOKEN this run's token, compared in constant time and spent on first use * 5. OWNER the local owner exists and still has no password * - * It is origin-checked, not token-checked for CSRF: the page that exchanges + * `POST /api/v1/auth/design-link` is how that owner, already signed in, asks + * for one more link: for the system's browser, when the desktop app's window + * has spent the run's own token. Under gates 1 to 3 and 5, and only for the + * local owner's own session. What it mints is taken by the door above, once, + * within a minute (`designer/design-links.ts`). + * + * The door is origin-checked, not token-checked for CSRF: the page that exchanges * the link has no session yet, and a stale cookie from another project on * this machine must not turn the exchange into a 403. */ @@ -29,6 +35,7 @@ import { z } from 'zod'; import { auditAuth } from '../../auth/audit.js'; import { createBootTokenGuard, isLoopbackPeer } from '../../auth/desktop-session.js'; import { createSession, setSessionCookie } from '../../auth/sessions.js'; +import { createDesignLinks, type DesignLinks } from '../../designer/design-links.js'; import { hostRole } from '../../designer/design-mode.js'; import { AppError } from '../../errors.js'; import { toUserView } from './handlers.js'; @@ -41,6 +48,8 @@ export interface DesignSessionRoutesDeps { token: string; now?: () => number; port: number; + /** The links the owner asked for after the run's own was spent; its own when omitted. */ + links?: DesignLinks; } /** A design session lasts a working day. */ @@ -51,12 +60,17 @@ export const designSessionBody = z.object({ designToken: z.string().regex(/^[0-9 /** How long the link `design` prints can be opened. */ export const DESIGN_LINK_MS = 15 * 60_000; +/** Where a link asked for lands: a page of this server, by its path. Never another origin (`//host`, a scheme). */ +export const designLinkBody = z.strictObject({ to: z.string().max(400).regex(/^\/(?!\/)[^\\#\s]*$/, 'must be a path of this server') }); +export const designLinkReply = z.object({ data: z.object({ url: z.string() }) }); + const spent = (): AppError => new AppError(401, 'INVALID_CREDENTIALS', 'This link has been used.', { reason: 'DESIGN_LINK_USED' }); export function designSessionRoutes(deps: DesignSessionRoutesDeps): FastifyPluginAsyncZod { const { meta } = deps; return async (app) => { const guard = createBootTokenGuard(deps.token); + const links = deps.links ?? createDesignLinks(deps.now === undefined ? {} : { now: deps.now }); // A link nobody opened does not stay good for as long as the server runs: the command's arguments are readable by other users of the machine. const goodUntil = (deps.now ?? Date.now)() + DESIGN_LINK_MS; @@ -72,12 +86,14 @@ export function designSessionRoutes(deps: DesignSessionRoutesDeps): FastifyPlugi await auditAuth(meta, request, { action: 'design_session_rejected', actorId: null, actorLabel: 'design-link' }); throw new AppError(403, 'FORBIDDEN', 'The Designer is signed into from its own address on this machine only.'); } - if ((deps.now ?? Date.now)() > goodUntil) { + // A link the owner asked for a moment ago has its own minute and its own single use. + const asked = links.claim(request.body.designToken); + if (!asked && (deps.now ?? Date.now)() > goodUntil) { await auditAuth(meta, request, { action: 'design_session_failed', actorId: null, actorLabel: 'design-link' }); throw new AppError(401, 'INVALID_CREDENTIALS', 'This link is too old. Run `adminium design` again for a new one.', { reason: 'DESIGN_LINK_OLD' }); } // Spent before anything else is looked at: a failure after this costs a restart, never a second try. - if (guard.claim(request.body.designToken) !== 'ok') { + if (!asked && guard.claim(request.body.designToken) !== 'ok') { await auditAuth(meta, request, { action: 'design_session_failed', actorId: null, actorLabel: 'design-link' }); throw spent(); } @@ -103,5 +119,30 @@ export function designSessionRoutes(deps: DesignSessionRoutesDeps): FastifyPlugi return { data: { user: toUserView(fresh) } }; }, ); + + app.post( + '/auth/design-link', + { + preHandler: [app.requireMeta, app.requireAuth], + schema: { body: designLinkBody, response: { 200: designLinkReply } }, + }, + async (request) => { + if (hostRole(request.headers.host, deps.port) !== 'designer' || !isLoopbackPeer(request.raw.socket)) { + throw new AppError(403, 'FORBIDDEN', 'The Designer is signed into from its own address on this machine only.'); + } + // Only the owner the link would sign in may ask for it, and only while the link is how that owner signs in. + const ownerId = await settingsRepo(meta).get('designer.localOwnerId'); + const owner = ownerId === null ? null : await usersRepo(meta).findById(ownerId); + if (owner === null || request.user === null || request.user.id !== owner.id) { + throw new AppError(403, 'FORBIDDEN', 'Only this project’s local owner can ask for a sign-in link.'); + } + if (owner.passwordHash !== null || owner.status !== 'active') { + throw new AppError(409, 'CONFLICT', 'This project’s owner signs in with their password.', { reason: 'OWNER_HAS_PASSWORD' }); + } + await auditAuth(meta, request, { action: 'design_link_issued', actorId: owner.id, actorLabel: owner.name }); + // After `#`, as the first link is: no request carries it and no log holds it. + return { data: { url: `http://127.0.0.1:${String(deps.port)}${request.body.to}#designToken=${links.issue()}` } }; + }, + ); }; } diff --git a/apps/server/test/design-links.test.ts b/apps/server/test/design-links.test.ts new file mode 100644 index 000000000..68d165134 --- /dev/null +++ b/apps/server/test/design-links.test.ts @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { describe, expect, it } from 'vitest'; + +import { createDesignLinks, DESIGN_BROWSER_LINK_MS } from '../src/designer/design-links.js'; + +describe('the links an owner asks for', () => { + it('are 64 hex characters, good once, and each its own', () => { + const links = createDesignLinks(); + const one = links.issue(); + const two = links.issue(); + expect(one).toMatch(/^[0-9a-f]{64}$/); + expect(two).not.toBe(one); + expect(links.claim('f'.repeat(64))).toBe(false); + expect(links.claim(two)).toBe(true); + expect(links.claim(two)).toBe(false); + // A wrong guess and another link's use spent nothing of this one. + expect(links.claim(one)).toBe(true); + }); + + it('are good for a minute and no longer', () => { + let clock = 1000; + const links = createDesignLinks({ now: () => clock }); + const early = links.issue(); + clock += DESIGN_BROWSER_LINK_MS; + expect(links.claim(early)).toBe(true); + const late = links.issue(); + clock += DESIGN_BROWSER_LINK_MS + 1; + expect(links.claim(late)).toBe(false); + }); + + it('holds a few at a time: the oldest give way', () => { + let n = 0; + const links = createDesignLinks({ token: () => String((n += 1)).padStart(64, '0') }); + const all = Array.from({ length: 7 }, () => links.issue()); + expect(all.map((token) => links.claim(token))).toEqual([false, false, true, true, true, true, true]); + }); +}); diff --git a/apps/server/test/design-mode.test.ts b/apps/server/test/design-mode.test.ts index a3fa1b906..566ca307b 100644 --- a/apps/server/test/design-mode.test.ts +++ b/apps/server/test/design-mode.test.ts @@ -144,6 +144,44 @@ describe('a server running adminium design', () => { expect((await exchange(app)).statusCode).toBe(200); }); + it('gives the signed-in owner one more link for another browser: good once, landing where it was asked to', async () => { + const { app } = await server(); + const owner = String((await exchange(app)).headers['set-cookie']).split(';')[0] ?? ''; + const csrf = ((await app.inject({ method: 'GET', url: '/api/v1/bootstrap', headers: { host: DESIGNER, cookie: owner } })).json() as { data: { csrfToken: string } }).data.csrfToken; + const ask = (to: string, headers: Record = { cookie: owner, 'x-adminium-csrf': csrf }, host = DESIGNER) => + app.inject({ method: 'POST', url: '/api/v1/auth/design-link', headers: { host, origin: `http://${host}`, ...headers }, payload: { to } }); + + const asked = await ask('/tables'); + expect(asked.statusCode, asked.body).toBe(200); + const url = (asked.json() as { data: { url: string } }).data.url; + expect(url).toMatch(new RegExp(`^http://127\\.0\\.0\\.1:${String(PORT)}/tables#designToken=[0-9a-f]{64}$`)); + const token = url.split('#designToken=')[1] ?? ''; + // The run's own link is spent; this one signs the same owner in, with no cookie, and only once. + expect((await exchange(app)).statusCode).toBe(401); + const signedIn = await exchange(app, DESIGNER, token); + expect(signedIn.statusCode, signedIn.body).toBe(200); + expect(signedIn.json()).toMatchObject({ data: { user: { email: LOCAL_OWNER_EMAIL } } }); + expect((await exchange(app, DESIGNER, token)).statusCode).toBe(401); + + // Nobody but that owner's own session is given one, and never on the preview's name or without the CSRF token. + expect((await ask('/', {})).statusCode).toBe(401); + expect((await ask('/', { cookie: owner })).statusCode).toBe(403); + expect((await ask('/', undefined, PREVIEW)).statusCode).toBeGreaterThanOrEqual(400); + // It lands on a page of this server and nowhere else. + for (const to of ['//evil.example/x', 'https://evil.example', 'tables', '/a#designToken=x', '/a b']) expect((await ask(to)).statusCode, to).toBe(422); + }); + + it('gives no link for another browser once the owner has a password', async () => { + const { app, meta } = await server(); + const owner = String((await exchange(app)).headers['set-cookie']).split(';')[0] ?? ''; + const csrf = ((await app.inject({ method: 'GET', url: '/api/v1/bootstrap', headers: { host: DESIGNER, cookie: owner } })).json() as { data: { csrfToken: string } }).data.csrfToken; + const ownerId = (await settingsRepo(meta).get('designer.localOwnerId')) as string; + await usersRepo(meta).updatePassword(ownerId, await hashPassword('a-long-enough-test-password-1!')); + const res = await app.inject({ method: 'POST', url: '/api/v1/auth/design-link', headers: { host: DESIGNER, origin: `http://${DESIGNER}`, cookie: owner, 'x-adminium-csrf': csrf }, payload: { to: '/' } }); + // Refused one way or the other: a password change ends the session it was asked with, or the route says why. + expect([401, 409]).toContain(res.statusCode); + }); + it('never signs in an owner who has a password, and has no link at all then', async () => { const { app } = await server({ owner: 'password' }); expect((await exchange(app)).statusCode).toBe(404); From 5d55a83f2b777c0e35dbd4cebfd785ca1a9a2ab1 Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Sat, 10 Oct 2026 15:14:59 +0200 Subject: [PATCH 49/79] feat(desktop): a recent project opens from its whole row, in the Designer or its dashboard --- apps/desktop/src/main/index.test.ts | 3 + apps/desktop/src/main/index.ts | 16 +++-- apps/desktop/src/main/ipc.ts | 4 +- apps/desktop/src/main/start.test.ts | 10 +++ apps/desktop/src/main/start.ts | 4 +- apps/desktop/src/preload/api.d.ts | 2 + apps/desktop/src/renderer/app/pages.test.tsx | 31 +++++++++ .../src/renderer/app/start/StartScreen.tsx | 63 +++++++++++++++---- packages/i18n/locales/ar-EG/.meta.json | 16 +++++ packages/i18n/locales/ar-EG/desktop.json | 4 ++ packages/i18n/locales/cs-CZ/.meta.json | 16 +++++ packages/i18n/locales/cs-CZ/desktop.json | 4 ++ packages/i18n/locales/da-DK/.meta.json | 16 +++++ packages/i18n/locales/da-DK/desktop.json | 4 ++ packages/i18n/locales/de-DE/.meta.json | 16 +++++ packages/i18n/locales/de-DE/desktop.json | 4 ++ packages/i18n/locales/en-US/desktop.json | 4 ++ packages/i18n/locales/fr-FR/.meta.json | 16 +++++ packages/i18n/locales/fr-FR/desktop.json | 4 ++ packages/i18n/locales/zh-CN/.meta.json | 16 +++++ packages/i18n/locales/zh-CN/desktop.json | 4 ++ packages/i18n/locales/zh-TW/.meta.json | 16 +++++ packages/i18n/locales/zh-TW/desktop.json | 4 ++ packages/i18n/src/a11y-keys.ts | 4 +- packages/i18n/src/resources/ar-eg/desktop.ts | 4 ++ packages/i18n/src/resources/cs-cz/desktop.ts | 4 ++ packages/i18n/src/resources/da-dk/desktop.ts | 4 ++ packages/i18n/src/resources/de-de/desktop.ts | 4 ++ packages/i18n/src/resources/en-us/desktop.ts | 4 ++ packages/i18n/src/resources/fr-fr/desktop.ts | 4 ++ packages/i18n/src/resources/zh-cn/desktop.ts | 4 ++ packages/i18n/src/resources/zh-tw/desktop.ts | 4 ++ packages/i18n/src/review-status.ts | 14 ++--- 33 files changed, 299 insertions(+), 28 deletions(-) diff --git a/apps/desktop/src/main/index.test.ts b/apps/desktop/src/main/index.test.ts index c06cc0896..2a6729eac 100644 --- a/apps/desktop/src/main/index.test.ts +++ b/apps/desktop/src/main/index.test.ts @@ -1606,6 +1606,9 @@ describe('projectUrl', () => { 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: 'ab' })).toBe('http://127.0.0.1:4712/'); + // "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/'); }); }); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 2ee1ccf98..c64bcbeb5 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -188,10 +188,12 @@ export const CLASSIC_ONLY_SETTING = 'This setting belongs to the classic workspa * while that owner has no password: the same door `adminium design` opens in a * browser. Shared: the dashboard's front door. */ -export function projectUrl(opts: { port: number; mode: 'design' | 'serve'; token: string | null }): string { +export function projectUrl(opts: { port: number; mode: 'design' | 'serve'; token: string | null; land?: 'dashboard' | undefined }): string { const origin = `http://127.0.0.1:${String(opts.port)}`; if (opts.mode === 'serve') return `${origin}/`; - return opts.token === null ? `${origin}/design` : `${origin}/design#designToken=${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.land === 'dashboard' ? '/' : '/design'; + return opts.token === null ? `${origin}${page}` : `${origin}${page}#designToken=${opts.token}`; } export function appUrl(opts: AppUrlOptions): string { @@ -1079,7 +1081,9 @@ export function createDesktopApp(deps: DesktopBootDeps): DesktopApp { * 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 }): Promise => { + 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; const pickPort = deps.pickProjectPort; if (pickPort === undefined) { await windows.showCrash({ reason: 'This build cannot open a project folder.', canRestart: false }); @@ -1115,7 +1119,9 @@ export function createDesktopApp(deps: DesktopBootDeps): DesktopApp { const show = (ready: { host: string; port: number }): Promise => { const mode = projectManager.project?.mode ?? 'design'; - return windows.loadApp(projectUrl({ port: ready.port, mode, token: projectManager.bootToken }), { preview: mode === 'design' }); + const url = projectUrl({ port: ready.port, mode, token: projectManager.bootToken, land }); + land = undefined; + return windows.loadApp(url, { preview: mode === 'design' }); }; const started = projectManager.start(); await windows.showBoot(); @@ -1189,7 +1195,7 @@ export function createDesktopApp(deps: DesktopBootDeps): DesktopApp { }); startOpen = false; if (choice.kind === 'project') { - await bootProject(config ?? loaded.config, { root: choice.root }); + 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 diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 00178b100..80ee654e7 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -218,7 +218,7 @@ const startNewFolderSchema = z.strictObject({ parent: absolutePathSchema, name: 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() }); +const startOpenProjectSchema = z.strictObject({ path: absolutePathSchema, agreed: z.boolean().optional(), land: z.enum(['designer', 'dashboard']).optional() }); const startLocateProjectSchema = z.strictObject({ path: absolutePathSchema, title: dialogTitleSchema }); const capabilityInvokeSchema = z.strictObject({ @@ -833,7 +833,7 @@ export function registerIpcHandlers(opts: RegisterIpcHandlersOptions): IpcHandle register( IPC_CHANNELS.startOpenProject, startOpenProjectSchema, - (input) => start().openProject({ path: input.path, ...(input.agreed === undefined ? {} : { agreed: input.agreed }) }), + (input) => start().openProject({ path: input.path, ...(input.agreed === undefined ? {} : { agreed: input.agreed }), ...(input.land === undefined ? {} : { land: input.land }) }), ownPage, ); register(IPC_CHANNELS.startForgetProject, absolutePathSchema, (path) => start().forgetProject(path), ownPage); diff --git a/apps/desktop/src/main/start.test.ts b/apps/desktop/src/main/start.test.ts index 7d578c527..0c7d415f3 100644 --- a/apps/desktop/src/main/start.test.ts +++ b/apps/desktop/src/main/start.test.ts @@ -289,6 +289,16 @@ describe('opening a folder', () => { 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) }); diff --git a/apps/desktop/src/main/start.ts b/apps/desktop/src/main/start.ts index 81e4b2530..d5363d0d5 100644 --- a/apps/desktop/src/main/start.ts +++ b/apps/desktop/src/main/start.ts @@ -121,7 +121,7 @@ export function projectFingerprint(root: string, fs: FingerprintFs = realFingerp // ─── 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 }; +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 }; @@ -274,7 +274,7 @@ export function createStartService(deps: StartDeps): StartService { // Agreed to (now, or before and unchanged). Nothing of the folder has been started up to this line. await deps.saveConfig(rememberProject(config, { path: root, name: known?.name ?? nameFromFolder(root), trusted: code }, now())); if (!hasPackages(root)) return { status: 'needs-packages' }; - deps.onChoice({ kind: 'project', root }); + deps.onChoice({ kind: 'project', root, ...(input.land === 'dashboard' ? { land: 'dashboard' as const } : {}) }); return { status: 'opened' }; }, diff --git a/apps/desktop/src/preload/api.d.ts b/apps/desktop/src/preload/api.d.ts index 8d429d8c7..985d9a3b8 100644 --- a/apps/desktop/src/preload/api.d.ts +++ b/apps/desktop/src/preload/api.d.ts @@ -467,6 +467,8 @@ export interface DesktopOpenProjectInput { readonly path: string; /** The person answered "Open" to the question about running this folder's code. */ readonly agreed?: boolean | undefined; + /** Where the window lands once the project is up: the Designer (the default), or the project's dashboard. */ + readonly land?: 'designer' | 'dashboard' | undefined; } export type DesktopOpenProjectResult = diff --git a/apps/desktop/src/renderer/app/pages.test.tsx b/apps/desktop/src/renderer/app/pages.test.tsx index f52c52abb..af081e401 100644 --- a/apps/desktop/src/renderer/app/pages.test.tsx +++ b/apps/desktop/src/renderer/app/pages.test.tsx @@ -196,6 +196,37 @@ describe('Start', () => { }); }); + it('opens a recent project from its whole row and its two buttons: the Designer, or its dashboard', async () => { + const start = fakeStart(); + show(start); + const row = document.querySelector('[data-recent]') as HTMLElement; + const path = row.dataset.recent ?? ''; + fireEvent.click(row); + fireEvent.click(screen.getByRole('button', { name: 'Open Juniper Kitchen in the Designer' })); + await waitFor(() => { + expect(start.openProject).toHaveBeenCalledTimes(2); + }); + expect(start.openProject).toHaveBeenNthCalledWith(1, { path }); + expect(start.openProject).toHaveBeenNthCalledWith(2, { path }); + // One click, one opening: a button inside the row does not also open the row. + fireEvent.click(screen.getByRole('button', { name: 'Open the dashboard of Juniper Kitchen' })); + await waitFor(() => { + expect(start.openProject).toHaveBeenCalledTimes(3); + }); + expect(start.openProject).toHaveBeenLastCalledWith({ path, land: 'dashboard' }); + }); + + it('keeps where it was to land across the question about the folder’s code', async () => { + const openProject = vi.fn().mockResolvedValue({ status: 'trust-needed', path: '/p', displayPath: '/p', changed: false }); + show(fakeStart({ openProject })); + fireEvent.click(screen.getByRole('button', { name: 'Open the dashboard of Juniper Kitchen' })); + await screen.findByRole('dialog'); + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + await waitFor(() => { + expect(openProject).toHaveBeenLastCalledWith({ path: '/p', agreed: true, land: 'dashboard' }); + }); + }); + it('opens nothing when the question is cancelled or no folder was picked', async () => { const openProject = vi.fn().mockResolvedValue({ status: 'trust-needed', path: '/p', displayPath: '/p', changed: false }); const start = fakeStart({ openProject }); diff --git a/apps/desktop/src/renderer/app/start/StartScreen.tsx b/apps/desktop/src/renderer/app/start/StartScreen.tsx index 1753048ae..c27bfca2b 100644 --- a/apps/desktop/src/renderer/app/start/StartScreen.tsx +++ b/apps/desktop/src/renderer/app/start/StartScreen.tsx @@ -6,7 +6,7 @@ import { useLocale, useT } from '@adminium/i18n/react'; import { tagForLocale } from '@adminium/i18n'; import { Database, Folder, FolderOpen, Hammer, MonitorSmartphone, RadioTower, Sparkles, type LucideIcon } from 'lucide-react'; -import { useId, useState, type ReactNode } from 'react'; +import { useId, useRef, useState, type ReactNode } from 'react'; import type { DesktopRecentProject, DesktopStartState } from '../../../preload/api.js'; import { startApi } from '../bridge.js'; @@ -79,6 +79,7 @@ function RecentRow({ first, when, onOpen, + onDashboard, onLocate, onRemove, }: { @@ -86,6 +87,7 @@ function RecentRow({ first: boolean; when: string; onOpen: () => void; + onDashboard: () => void; onLocate: () => void; onRemove: () => void; }): ReactNode { @@ -93,7 +95,13 @@ function RecentRow({ const shared = project.state === 'shared'; const ChipIcon = shared ? RadioTower : Hammer; return ( -
+
) : ( - - +
+ + + + +
)}
); @@ -162,10 +197,13 @@ export function StartScreen({ initial, onBuild, say }: { initial: DesktopStartSt const [opening, setOpening] = useState(false); const now = new Date(); - const open = async (path: string, agreed: boolean): Promise => { + // Where the project asked about is to land, kept across the question about its code. + const landing = useRef<'designer' | 'dashboard'>('designer'); + const open = async (path: string, agreed: boolean, land: 'designer' | 'dashboard' = agreed ? landing.current : 'designer'): Promise => { + landing.current = land; setOpening(true); try { - const result = await startApi().openProject(agreed ? { path, agreed: true } : { path }); + const result = await startApi().openProject({ path, ...(agreed ? { agreed: true } : {}), ...(land === 'dashboard' ? { land } : {}) }); if (result.status === 'trust-needed') { setQuestion({ path: result.path, displayPath: result.displayPath, changed: result.changed }); return; @@ -252,6 +290,9 @@ export function StartScreen({ initial, onBuild, say }: { initial: DesktopStartSt onOpen={() => { void open(project.path, false); }} + onDashboard={() => { + void open(project.path, false, 'dashboard'); + }} onLocate={() => { void locate(project); }} diff --git a/packages/i18n/locales/ar-EG/.meta.json b/packages/i18n/locales/ar-EG/.meta.json index 9f05e4f02..30698dea1 100644 --- a/packages/i18n/locales/ar-EG/.meta.json +++ b/packages/i18n/locales/ar-EG/.meta.json @@ -11107,6 +11107,22 @@ "status": "mt", "srcHash": "7bac7999cae0" }, + "desktop.start.recent.openDashboard": { + "status": "mt", + "srcHash": "7ad1caed59ac" + }, + "desktop.start.recent.openDashboardOf": { + "status": "mt", + "srcHash": "2df859514a3a" + }, + "desktop.start.recent.openDesigner": { + "status": "mt", + "srcHash": "b7f9c2dcf50d" + }, + "desktop.start.recent.openDesignerOf": { + "status": "mt", + "srcHash": "e7470692c2b2" + }, "desktop.start.recent.opened": { "status": "mt", "srcHash": "a94c51ab08c7" diff --git a/packages/i18n/locales/ar-EG/desktop.json b/packages/i18n/locales/ar-EG/desktop.json index 0343693a4..524b4e6f7 100644 --- a/packages/i18n/locales/ar-EG/desktop.json +++ b/packages/i18n/locales/ar-EG/desktop.json @@ -69,6 +69,10 @@ "locateTitle": "أين يوجد {name} الآن؟", "notThatProject": "هذا المجلد ليس مشروع Adminium.", "open": "فتح {name}", + "openDashboard": "فتح لوحة التحكم", + "openDashboardOf": "فتح لوحة تحكم {name}", + "openDesigner": "فتح في Designer", + "openDesignerOf": "فتح {name} في Designer", "opened": "فُتح {when}", "remove": "إزالة", "removed": "تمت الإزالة من المشاريع الأخيرة", diff --git a/packages/i18n/locales/cs-CZ/.meta.json b/packages/i18n/locales/cs-CZ/.meta.json index a37d5ba68..c0b4ae4e0 100644 --- a/packages/i18n/locales/cs-CZ/.meta.json +++ b/packages/i18n/locales/cs-CZ/.meta.json @@ -11107,6 +11107,22 @@ "status": "mt", "srcHash": "7bac7999cae0" }, + "desktop.start.recent.openDashboard": { + "status": "mt", + "srcHash": "7ad1caed59ac" + }, + "desktop.start.recent.openDashboardOf": { + "status": "mt", + "srcHash": "2df859514a3a" + }, + "desktop.start.recent.openDesigner": { + "status": "mt", + "srcHash": "b7f9c2dcf50d" + }, + "desktop.start.recent.openDesignerOf": { + "status": "mt", + "srcHash": "e7470692c2b2" + }, "desktop.start.recent.opened": { "status": "mt", "srcHash": "a94c51ab08c7" diff --git a/packages/i18n/locales/cs-CZ/desktop.json b/packages/i18n/locales/cs-CZ/desktop.json index ebc514e5b..0da019d0e 100644 --- a/packages/i18n/locales/cs-CZ/desktop.json +++ b/packages/i18n/locales/cs-CZ/desktop.json @@ -69,6 +69,10 @@ "locateTitle": "Kde je teď {name}?", "notThatProject": "Tato složka není projekt Adminia.", "open": "Otevřít {name}", + "openDashboard": "Otevřít dashboard", + "openDashboardOf": "Otevřít dashboard projektu {name}", + "openDesigner": "Otevřít v Designeru", + "openDesignerOf": "Otevřít {name} v Designeru", "opened": "Otevřeno {when}", "remove": "Odebrat", "removed": "Odebráno z nedávných projektů", diff --git a/packages/i18n/locales/da-DK/.meta.json b/packages/i18n/locales/da-DK/.meta.json index df898b38b..427b17f50 100644 --- a/packages/i18n/locales/da-DK/.meta.json +++ b/packages/i18n/locales/da-DK/.meta.json @@ -11107,6 +11107,22 @@ "status": "mt", "srcHash": "7bac7999cae0" }, + "desktop.start.recent.openDashboard": { + "status": "mt", + "srcHash": "7ad1caed59ac" + }, + "desktop.start.recent.openDashboardOf": { + "status": "mt", + "srcHash": "2df859514a3a" + }, + "desktop.start.recent.openDesigner": { + "status": "mt", + "srcHash": "b7f9c2dcf50d" + }, + "desktop.start.recent.openDesignerOf": { + "status": "mt", + "srcHash": "e7470692c2b2" + }, "desktop.start.recent.opened": { "status": "mt", "srcHash": "a94c51ab08c7" diff --git a/packages/i18n/locales/da-DK/desktop.json b/packages/i18n/locales/da-DK/desktop.json index 2324d6e73..1be8c8104 100644 --- a/packages/i18n/locales/da-DK/desktop.json +++ b/packages/i18n/locales/da-DK/desktop.json @@ -69,6 +69,10 @@ "locateTitle": "Hvor ligger {name} nu?", "notThatProject": "Den mappe er ikke et Adminium-projekt.", "open": "Åbn {name}", + "openDashboard": "Åbn dashboard", + "openDashboardOf": "Åbn dashboardet for {name}", + "openDesigner": "Åbn i Designer", + "openDesignerOf": "Åbn {name} i Designer", "opened": "Åbnet {when}", "remove": "Fjern", "removed": "Fjernet fra de seneste projekter", diff --git a/packages/i18n/locales/de-DE/.meta.json b/packages/i18n/locales/de-DE/.meta.json index f2e0a8c5f..0fa0dd327 100644 --- a/packages/i18n/locales/de-DE/.meta.json +++ b/packages/i18n/locales/de-DE/.meta.json @@ -11107,6 +11107,22 @@ "status": "mt", "srcHash": "7bac7999cae0" }, + "desktop.start.recent.openDashboard": { + "status": "mt", + "srcHash": "7ad1caed59ac" + }, + "desktop.start.recent.openDashboardOf": { + "status": "mt", + "srcHash": "2df859514a3a" + }, + "desktop.start.recent.openDesigner": { + "status": "mt", + "srcHash": "b7f9c2dcf50d" + }, + "desktop.start.recent.openDesignerOf": { + "status": "mt", + "srcHash": "e7470692c2b2" + }, "desktop.start.recent.opened": { "status": "mt", "srcHash": "a94c51ab08c7" diff --git a/packages/i18n/locales/de-DE/desktop.json b/packages/i18n/locales/de-DE/desktop.json index 1f18511cb..4ea0181a8 100644 --- a/packages/i18n/locales/de-DE/desktop.json +++ b/packages/i18n/locales/de-DE/desktop.json @@ -69,6 +69,10 @@ "locateTitle": "Wo liegt {name} jetzt?", "notThatProject": "Dieser Ordner ist kein Adminium-Projekt.", "open": "{name} öffnen", + "openDashboard": "Dashboard öffnen", + "openDashboardOf": "Dashboard von {name} öffnen", + "openDesigner": "Im Designer öffnen", + "openDesignerOf": "{name} im Designer öffnen", "opened": "Geöffnet: {when}", "remove": "Entfernen", "removed": "Aus den zuletzt verwendeten Projekten entfernt", diff --git a/packages/i18n/locales/en-US/desktop.json b/packages/i18n/locales/en-US/desktop.json index d2b5f0792..fc0f73bff 100644 --- a/packages/i18n/locales/en-US/desktop.json +++ b/packages/i18n/locales/en-US/desktop.json @@ -69,6 +69,10 @@ "locateTitle": "Where is {name} now?", "notThatProject": "That folder is not an Adminium project.", "open": "Open {name}", + "openDashboard": "Open dashboard", + "openDashboardOf": "Open the dashboard of {name}", + "openDesigner": "Open in Designer", + "openDesignerOf": "Open {name} in the Designer", "opened": "Opened {when}", "remove": "Remove", "removed": "Removed from the recent projects", diff --git a/packages/i18n/locales/fr-FR/.meta.json b/packages/i18n/locales/fr-FR/.meta.json index 17f4a9c65..dbd25535f 100644 --- a/packages/i18n/locales/fr-FR/.meta.json +++ b/packages/i18n/locales/fr-FR/.meta.json @@ -11107,6 +11107,22 @@ "status": "mt", "srcHash": "7bac7999cae0" }, + "desktop.start.recent.openDashboard": { + "status": "mt", + "srcHash": "7ad1caed59ac" + }, + "desktop.start.recent.openDashboardOf": { + "status": "mt", + "srcHash": "2df859514a3a" + }, + "desktop.start.recent.openDesigner": { + "status": "mt", + "srcHash": "b7f9c2dcf50d" + }, + "desktop.start.recent.openDesignerOf": { + "status": "mt", + "srcHash": "e7470692c2b2" + }, "desktop.start.recent.opened": { "status": "mt", "srcHash": "a94c51ab08c7" diff --git a/packages/i18n/locales/fr-FR/desktop.json b/packages/i18n/locales/fr-FR/desktop.json index 0ceced5d6..981d81a70 100644 --- a/packages/i18n/locales/fr-FR/desktop.json +++ b/packages/i18n/locales/fr-FR/desktop.json @@ -69,6 +69,10 @@ "locateTitle": "Où se trouve {name} maintenant ?", "notThatProject": "Ce dossier n’est pas un projet Adminium.", "open": "Ouvrir {name}", + "openDashboard": "Ouvrir le tableau de bord", + "openDashboardOf": "Ouvrir le tableau de bord de {name}", + "openDesigner": "Ouvrir dans Designer", + "openDesignerOf": "Ouvrir {name} dans Designer", "opened": "Ouvert {when}", "remove": "Retirer", "removed": "Retiré des projets récents", diff --git a/packages/i18n/locales/zh-CN/.meta.json b/packages/i18n/locales/zh-CN/.meta.json index 2a805943d..8856ac999 100644 --- a/packages/i18n/locales/zh-CN/.meta.json +++ b/packages/i18n/locales/zh-CN/.meta.json @@ -11107,6 +11107,22 @@ "status": "mt", "srcHash": "7bac7999cae0" }, + "desktop.start.recent.openDashboard": { + "status": "mt", + "srcHash": "7ad1caed59ac" + }, + "desktop.start.recent.openDashboardOf": { + "status": "mt", + "srcHash": "2df859514a3a" + }, + "desktop.start.recent.openDesigner": { + "status": "mt", + "srcHash": "b7f9c2dcf50d" + }, + "desktop.start.recent.openDesignerOf": { + "status": "mt", + "srcHash": "e7470692c2b2" + }, "desktop.start.recent.opened": { "status": "mt", "srcHash": "a94c51ab08c7" diff --git a/packages/i18n/locales/zh-CN/desktop.json b/packages/i18n/locales/zh-CN/desktop.json index 63208955c..ecc1352a7 100644 --- a/packages/i18n/locales/zh-CN/desktop.json +++ b/packages/i18n/locales/zh-CN/desktop.json @@ -69,6 +69,10 @@ "locateTitle": "{name} 现在在哪里?", "notThatProject": "该文件夹不是 Adminium 项目。", "open": "打开 {name}", + "openDashboard": "打开仪表板", + "openDashboardOf": "打开 {name} 的仪表板", + "openDesigner": "在 Designer 中打开", + "openDesignerOf": "在 Designer 中打开 {name}", "opened": "打开时间:{when}", "remove": "移除", "removed": "已从最近的项目中移除", diff --git a/packages/i18n/locales/zh-TW/.meta.json b/packages/i18n/locales/zh-TW/.meta.json index 0c7789ad7..afbac2818 100644 --- a/packages/i18n/locales/zh-TW/.meta.json +++ b/packages/i18n/locales/zh-TW/.meta.json @@ -11107,6 +11107,22 @@ "status": "mt", "srcHash": "7bac7999cae0" }, + "desktop.start.recent.openDashboard": { + "status": "mt", + "srcHash": "7ad1caed59ac" + }, + "desktop.start.recent.openDashboardOf": { + "status": "mt", + "srcHash": "2df859514a3a" + }, + "desktop.start.recent.openDesigner": { + "status": "mt", + "srcHash": "b7f9c2dcf50d" + }, + "desktop.start.recent.openDesignerOf": { + "status": "mt", + "srcHash": "e7470692c2b2" + }, "desktop.start.recent.opened": { "status": "mt", "srcHash": "a94c51ab08c7" diff --git a/packages/i18n/locales/zh-TW/desktop.json b/packages/i18n/locales/zh-TW/desktop.json index e2b7096df..05a9e3074 100644 --- a/packages/i18n/locales/zh-TW/desktop.json +++ b/packages/i18n/locales/zh-TW/desktop.json @@ -69,6 +69,10 @@ "locateTitle": "{name} 現在在哪裡?", "notThatProject": "該資料夾不是 Adminium 專案。", "open": "開啟 {name}", + "openDashboard": "開啟儀表板", + "openDashboardOf": "開啟 {name} 的儀表板", + "openDesigner": "在 Designer 中開啟", + "openDesignerOf": "在 Designer 中開啟 {name}", "opened": "開啟時間:{when}", "remove": "移除", "removed": "已從最近的專案中移除", diff --git a/packages/i18n/src/a11y-keys.ts b/packages/i18n/src/a11y-keys.ts index a32bb1970..f0ed3d543 100644 --- a/packages/i18n/src/a11y-keys.ts +++ b/packages/i18n/src/a11y-keys.ts @@ -11,7 +11,7 @@ * pnpm --filter @adminium/i18n gen:a11y-keys */ -/** `namespace:key` — 2788 entries. */ +/** `namespace:key` — 2790 entries. */ export const A11Y_CRITICAL_KEYS: ReadonlySet = new Set([ 'addOns:failed.title', 'addOns:noBundle.title', @@ -919,6 +919,8 @@ export const A11Y_CRITICAL_KEYS: ReadonlySet = new Set([ 'desktop:start.choice.open.title', 'desktop:start.recent.locateTitle', 'desktop:start.recent.open', + 'desktop:start.recent.openDashboardOf', + 'desktop:start.recent.openDesignerOf', 'desktop:toast.dismiss', 'desktop:toast.region', 'desktop:trust.body', diff --git a/packages/i18n/src/resources/ar-eg/desktop.ts b/packages/i18n/src/resources/ar-eg/desktop.ts index 900b28881..34e704ad9 100644 --- a/packages/i18n/src/resources/ar-eg/desktop.ts +++ b/packages/i18n/src/resources/ar-eg/desktop.ts @@ -79,6 +79,10 @@ export default { "locateTitle": "أين يوجد {name} الآن؟", "notThatProject": "هذا المجلد ليس مشروع Adminium.", "open": "فتح {name}", + "openDashboard": "فتح لوحة التحكم", + "openDashboardOf": "فتح لوحة تحكم {name}", + "openDesigner": "فتح في Designer", + "openDesignerOf": "فتح {name} في Designer", "opened": "فُتح {when}", "remove": "إزالة", "removed": "تمت الإزالة من المشاريع الأخيرة", diff --git a/packages/i18n/src/resources/cs-cz/desktop.ts b/packages/i18n/src/resources/cs-cz/desktop.ts index cffa076f1..bf6adbd6f 100644 --- a/packages/i18n/src/resources/cs-cz/desktop.ts +++ b/packages/i18n/src/resources/cs-cz/desktop.ts @@ -79,6 +79,10 @@ export default { "locateTitle": "Kde je teď {name}?", "notThatProject": "Tato složka není projekt Adminia.", "open": "Otevřít {name}", + "openDashboard": "Otevřít dashboard", + "openDashboardOf": "Otevřít dashboard projektu {name}", + "openDesigner": "Otevřít v Designeru", + "openDesignerOf": "Otevřít {name} v Designeru", "opened": "Otevřeno {when}", "remove": "Odebrat", "removed": "Odebráno z nedávných projektů", diff --git a/packages/i18n/src/resources/da-dk/desktop.ts b/packages/i18n/src/resources/da-dk/desktop.ts index b140120e8..31f8589eb 100644 --- a/packages/i18n/src/resources/da-dk/desktop.ts +++ b/packages/i18n/src/resources/da-dk/desktop.ts @@ -79,6 +79,10 @@ export default { "locateTitle": "Hvor ligger {name} nu?", "notThatProject": "Den mappe er ikke et Adminium-projekt.", "open": "Åbn {name}", + "openDashboard": "Åbn dashboard", + "openDashboardOf": "Åbn dashboardet for {name}", + "openDesigner": "Åbn i Designer", + "openDesignerOf": "Åbn {name} i Designer", "opened": "Åbnet {when}", "remove": "Fjern", "removed": "Fjernet fra de seneste projekter", diff --git a/packages/i18n/src/resources/de-de/desktop.ts b/packages/i18n/src/resources/de-de/desktop.ts index 0c5e1c6a7..92bb82394 100644 --- a/packages/i18n/src/resources/de-de/desktop.ts +++ b/packages/i18n/src/resources/de-de/desktop.ts @@ -79,6 +79,10 @@ export default { "locateTitle": "Wo liegt {name} jetzt?", "notThatProject": "Dieser Ordner ist kein Adminium-Projekt.", "open": "{name} öffnen", + "openDashboard": "Dashboard öffnen", + "openDashboardOf": "Dashboard von {name} öffnen", + "openDesigner": "Im Designer öffnen", + "openDesignerOf": "{name} im Designer öffnen", "opened": "Geöffnet: {when}", "remove": "Entfernen", "removed": "Aus den zuletzt verwendeten Projekten entfernt", diff --git a/packages/i18n/src/resources/en-us/desktop.ts b/packages/i18n/src/resources/en-us/desktop.ts index eb6a20ae6..7b914fdb2 100644 --- a/packages/i18n/src/resources/en-us/desktop.ts +++ b/packages/i18n/src/resources/en-us/desktop.ts @@ -79,6 +79,10 @@ export default { "locateTitle": "Where is {name} now?", "notThatProject": "That folder is not an Adminium project.", "open": "Open {name}", + "openDashboard": "Open dashboard", + "openDashboardOf": "Open the dashboard of {name}", + "openDesigner": "Open in Designer", + "openDesignerOf": "Open {name} in the Designer", "opened": "Opened {when}", "remove": "Remove", "removed": "Removed from the recent projects", diff --git a/packages/i18n/src/resources/fr-fr/desktop.ts b/packages/i18n/src/resources/fr-fr/desktop.ts index 08dc8f97c..1e5ba68a0 100644 --- a/packages/i18n/src/resources/fr-fr/desktop.ts +++ b/packages/i18n/src/resources/fr-fr/desktop.ts @@ -79,6 +79,10 @@ export default { "locateTitle": "Où se trouve {name} maintenant ?", "notThatProject": "Ce dossier n’est pas un projet Adminium.", "open": "Ouvrir {name}", + "openDashboard": "Ouvrir le tableau de bord", + "openDashboardOf": "Ouvrir le tableau de bord de {name}", + "openDesigner": "Ouvrir dans Designer", + "openDesignerOf": "Ouvrir {name} dans Designer", "opened": "Ouvert {when}", "remove": "Retirer", "removed": "Retiré des projets récents", diff --git a/packages/i18n/src/resources/zh-cn/desktop.ts b/packages/i18n/src/resources/zh-cn/desktop.ts index 20b4a5b33..c7c0c4c2c 100644 --- a/packages/i18n/src/resources/zh-cn/desktop.ts +++ b/packages/i18n/src/resources/zh-cn/desktop.ts @@ -79,6 +79,10 @@ export default { "locateTitle": "{name} 现在在哪里?", "notThatProject": "该文件夹不是 Adminium 项目。", "open": "打开 {name}", + "openDashboard": "打开仪表板", + "openDashboardOf": "打开 {name} 的仪表板", + "openDesigner": "在 Designer 中打开", + "openDesignerOf": "在 Designer 中打开 {name}", "opened": "打开时间:{when}", "remove": "移除", "removed": "已从最近的项目中移除", diff --git a/packages/i18n/src/resources/zh-tw/desktop.ts b/packages/i18n/src/resources/zh-tw/desktop.ts index c5cdfb753..5783f9f59 100644 --- a/packages/i18n/src/resources/zh-tw/desktop.ts +++ b/packages/i18n/src/resources/zh-tw/desktop.ts @@ -79,6 +79,10 @@ export default { "locateTitle": "{name} 現在在哪裡?", "notThatProject": "該資料夾不是 Adminium 專案。", "open": "開啟 {name}", + "openDashboard": "開啟儀表板", + "openDashboardOf": "開啟 {name} 的儀表板", + "openDesigner": "在 Designer 中開啟", + "openDesignerOf": "在 Designer 中開啟 {name}", "opened": "開啟時間:{when}", "remove": "移除", "removed": "已從最近的專案中移除", diff --git a/packages/i18n/src/review-status.ts b/packages/i18n/src/review-status.ts index 130449c87..c39926310 100644 --- a/packages/i18n/src/review-status.ts +++ b/packages/i18n/src/review-status.ts @@ -46,13 +46,13 @@ export interface LocaleReview extends ReviewCoverage { /** GENERATED — see the module docblock. `en_US` is the source and never tracked. */ export const REVIEW_STATUS: Record = { - de_DE: { tracked: 8145, reviewed: 0, mt: 8068, outdated: 77, shipReady: false }, - fr_FR: { tracked: 8145, reviewed: 0, mt: 8068, outdated: 77, shipReady: false }, - cs_CZ: { tracked: 8145, reviewed: 0, mt: 8068, outdated: 77, shipReady: false }, - da_DK: { tracked: 8145, reviewed: 0, mt: 8068, outdated: 77, shipReady: false }, - zh_CN: { tracked: 8145, reviewed: 0, mt: 8068, outdated: 77, shipReady: false }, - zh_TW: { tracked: 8145, reviewed: 0, mt: 8068, outdated: 77, shipReady: false }, - ar_EG: { tracked: 8145, reviewed: 0, mt: 8068, outdated: 77, shipReady: false }, + de_DE: { tracked: 8149, reviewed: 0, mt: 8072, outdated: 77, shipReady: false }, + fr_FR: { tracked: 8149, reviewed: 0, mt: 8072, outdated: 77, shipReady: false }, + cs_CZ: { tracked: 8149, reviewed: 0, mt: 8072, outdated: 77, shipReady: false }, + da_DK: { tracked: 8149, reviewed: 0, mt: 8072, outdated: 77, shipReady: false }, + zh_CN: { tracked: 8149, reviewed: 0, mt: 8072, outdated: 77, shipReady: false }, + zh_TW: { tracked: 8149, reviewed: 0, mt: 8072, outdated: 77, shipReady: false }, + ar_EG: { tracked: 8149, reviewed: 0, mt: 8072, outdated: 77, shipReady: false }, }; /** From bd8a0f8d57bf0bb7047a4551e6257a2dcba463f0 Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Sat, 10 Oct 2026 15:26:44 +0200 Subject: [PATCH 50/79] feat(desktop): offer to get the packages of a project that has none, then open it --- apps/desktop/src/main/index.ts | 23 ++-- apps/desktop/src/main/ipc.test.ts | 2 + apps/desktop/src/main/ipc.ts | 2 + apps/desktop/src/main/make-project.test.ts | 21 +++- apps/desktop/src/main/make-project.ts | 22 ++++ apps/desktop/src/main/start.test.ts | 32 +++++- apps/desktop/src/main/start.ts | 36 +++++- apps/desktop/src/preload/api.d.ts | 14 ++- apps/desktop/src/preload/channels.ts | 2 + apps/desktop/src/preload/index.test.ts | 5 +- apps/desktop/src/preload/index.ts | 3 + apps/desktop/src/renderer/app/pages.test.tsx | 27 ++++- .../src/renderer/app/start/PackagesDialog.tsx | 103 ++++++++++++++++++ .../src/renderer/app/start/StartScreen.tsx | 47 +++++++- packages/i18n/locales/ar-EG/.meta.json | 32 +++++- packages/i18n/locales/ar-EG/desktop.json | 10 +- packages/i18n/locales/cs-CZ/.meta.json | 32 +++++- packages/i18n/locales/cs-CZ/desktop.json | 10 +- packages/i18n/locales/da-DK/.meta.json | 32 +++++- packages/i18n/locales/da-DK/desktop.json | 10 +- packages/i18n/locales/de-DE/.meta.json | 32 +++++- packages/i18n/locales/de-DE/desktop.json | 10 +- packages/i18n/locales/en-US/desktop.json | 10 +- packages/i18n/locales/fr-FR/.meta.json | 32 +++++- packages/i18n/locales/fr-FR/desktop.json | 10 +- packages/i18n/locales/zh-CN/.meta.json | 32 +++++- packages/i18n/locales/zh-CN/desktop.json | 10 +- packages/i18n/locales/zh-TW/.meta.json | 32 +++++- packages/i18n/locales/zh-TW/desktop.json | 10 +- packages/i18n/src/a11y-keys.ts | 7 +- packages/i18n/src/resources/ar-eg/desktop.ts | 10 +- packages/i18n/src/resources/cs-cz/desktop.ts | 10 +- packages/i18n/src/resources/da-dk/desktop.ts | 10 +- packages/i18n/src/resources/de-de/desktop.ts | 10 +- packages/i18n/src/resources/en-us/desktop.ts | 10 +- packages/i18n/src/resources/fr-fr/desktop.ts | 10 +- packages/i18n/src/resources/zh-cn/desktop.ts | 10 +- packages/i18n/src/resources/zh-tw/desktop.ts | 10 +- packages/i18n/src/review-status.ts | 14 +-- 39 files changed, 674 insertions(+), 70 deletions(-) create mode 100644 apps/desktop/src/renderer/app/start/PackagesDialog.tsx diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index c64bcbeb5..88e53d0a6 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -85,7 +85,7 @@ import { EPHEMERAL_PORT, generateBootToken, LOOPBACK_HOST } from '../server/env. import { LAN_PORT_IN_USE, registerIpcHandlers, type DesktopRuntimeSnapshot } from './ipc.js'; import { findGit, removeUnfinishedGit, runProgram } from './git.js'; import { createDesktopLogging } from './logging.js'; -import { createMakeProject, runToEnd } from './make-project.js'; +import { createInstallPackages, createMakeProject, runToEnd, type MakeProjectDeps } from './make-project.js'; import { carriedNpmDir, provideDesktopPrograms } from './programs.js'; import { firstFreePort, projectPortRange, seamProject, sessionCookieNames, stopBusyWords } from './project.js'; import { realFolderDeps, type FolderDeps } from './projects.js'; @@ -572,6 +572,7 @@ export interface DesktopBootDeps { readonly folder: FolderDeps; readonly chooseDirectory: StartDeps['chooseDirectory']; readonly makeProject?: StartDeps['makeProject']; + readonly installPackages?: StartDeps['installPackages']; /** Whether the classic workspace was ever set up in this data folder. */ readonly classicUsed: (config: DesktopConfig) => boolean; } @@ -1185,6 +1186,7 @@ export function createDesktopApp(deps: DesktopBootDeps): DesktopApp { classicUsed: () => classicUsed, chooseDirectory: screen.chooseDirectory, ...(screen.makeProject === undefined ? {} : { makeProject: screen.makeProject }), + ...(screen.installPackages === undefined ? {} : { installPackages: screen.installPackages }), onChoice: choose, }); startOpen = true; @@ -2032,6 +2034,15 @@ export function electronBootDeps(): DesktopBootDeps { }), ); }; + /** 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 @@ -2256,14 +2267,8 @@ export function electronBootDeps(): DesktopBootDeps { startScreen: { folder: realFolderDeps(app.isPackaged ? dirname(process.resourcesPath) : resolve(dirname(fileURLToPath(import.meta.url)), '..', '..')), chooseDirectory: (opts) => dialogs.chooseDirectory(opts), - makeProject: createMakeProject({ - 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, - }), + makeProject: createMakeProject(engineCli), + installPackages: createInstallPackages(engineCli), classicUsed: (loaded) => existsSync(join(loaded.dataDir, 'meta.db')), }, projectEnv: process.env, diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index ede5cb6f8..cc368363d 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -682,6 +682,7 @@ describe('the first screens’ channels', () => { 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: 'opened' as const })), forgetProject: vi.fn(() => Promise.resolve([])), locateProject: vi.fn(() => Promise.resolve({ status: 'cancelled' as const })), useClassic: vi.fn(), @@ -696,6 +697,7 @@ describe('the first screens’ channels', () => { [IPC_CHANNELS.startMakeProgress]: undefined, [IPC_CHANNELS.startChooseFolder]: { title: 'Open a folder' }, [IPC_CHANNELS.startOpenProject]: { path: '/home/ava/Adminium/shop', agreed: true }, + [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, diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 80ee654e7..50863e5e7 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -219,6 +219,7 @@ const startChooseParentSchema = z.strictObject({ from: absolutePathSchema, title 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() }); +const startGetPackagesSchema = z.strictObject({ path: absolutePathSchema, land: z.enum(['designer', 'dashboard']).optional() }); const startLocateProjectSchema = z.strictObject({ path: absolutePathSchema, title: dialogTitleSchema }); const capabilityInvokeSchema = z.strictObject({ @@ -836,6 +837,7 @@ export function registerIpcHandlers(opts: RegisterIpcHandlersOptions): IpcHandle (input) => start().openProject({ path: input.path, ...(input.agreed === undefined ? {} : { agreed: input.agreed }), ...(input.land === undefined ? {} : { land: input.land }) }), ownPage, ); + register(IPC_CHANNELS.startGetPackages, startGetPackagesSchema, (input) => start().getPackages({ path: input.path, ...(input.land === undefined ? {} : { land: input.land }) }), 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 ───────────────────────────────────────── diff --git a/apps/desktop/src/main/make-project.test.ts b/apps/desktop/src/main/make-project.test.ts index 90c73ebe2..d46890113 100644 --- a/apps/desktop/src/main/make-project.test.ts +++ b/apps/desktop/src/main/make-project.test.ts @@ -7,7 +7,7 @@ import { join } from 'node:path'; import { DESIGN_DATABASE } from '@adminium/server'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { INSTALLING_LINE, MAKE_PROJECT_TIMEOUT_MS, NEW_PROJECT_DATABASE, createMakeProject, lastLines, runToEnd, type MakeProjectDeps, type RanProgram } from './make-project.js'; +import { INSTALLING_LINE, MAKE_PROJECT_TIMEOUT_MS, NEW_PROJECT_DATABASE, createInstallPackages, createMakeProject, lastLines, runToEnd, type MakeProjectDeps, type RanProgram } from './make-project.js'; let home: string; beforeEach(() => { @@ -33,6 +33,25 @@ describe('the database a new project starts with', () => { }); }); +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'); diff --git a/apps/desktop/src/main/make-project.ts b/apps/desktop/src/main/make-project.ts index 0b68b04a0..01e8522b4 100644 --- a/apps/desktop/src/main/make-project.ts +++ b/apps/desktop/src/main/make-project.ts @@ -135,6 +135,28 @@ export function createMakeProject( }; } +/** + * 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 }; + }; +} + /** {@link MakeProjectDeps.run} on a real machine. */ export async function runToEnd( command: string, diff --git a/apps/desktop/src/main/start.test.ts b/apps/desktop/src/main/start.test.ts index 0c7d415f3..fb8bfc31e 100644 --- a/apps/desktop/src/main/start.test.ts +++ b/apps/desktop/src/main/start.test.ts @@ -326,11 +326,41 @@ describe('opening a folder', () => { 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' }); + 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 }); + await expect(start.getPackages({ path: shop, land: 'dashboard' })).resolves.toEqual({ status: 'opened' }); + expect(installPackages).toHaveBeenCalledWith({ root: shop }); + 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')); diff --git a/apps/desktop/src/main/start.ts b/apps/desktop/src/main/start.ts index d5363d0d5..2109d73c1 100644 --- a/apps/desktop/src/main/start.ts +++ b/apps/desktop/src/main/start.ts @@ -25,6 +25,7 @@ import { join, sep } from 'node:path'; import type { DesktopCreateProjectInput, DesktopCreateProjectResult, + DesktopGetPackagesResult, DesktopLocateProjectResult, DesktopMakeProgress, DesktopMakeStep, @@ -140,6 +141,8 @@ export interface StartDeps { */ 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; /** The real path of a folder that exists. */ @@ -156,6 +159,7 @@ export interface StartService { 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; forgetProject(path: string): Promise; locateProject(input: { path: string; title: string }): Promise; useClassic(): void; @@ -273,7 +277,37 @@ export function createStartService(deps: StartDeps): StartService { } // Agreed to (now, or before and unchanged). Nothing of the folder has been started up to this line. await deps.saveConfig(rememberProject(config, { path: root, name: known?.name ?? nameFromFolder(root), trusted: code }, now())); - if (!hasPackages(root)) return { status: 'needs-packages' }; + if (!hasPackages(root)) return { status: 'needs-packages', path: root, displayPath: shown(root) }; + deps.onChoice({ kind: 'project', root, ...(input.land === 'dashboard' ? { land: 'dashboard' as const } : {}) }); + return { status: 'opened' }; + }, + + async getPackages(input) { + if (!deps.folder.exists(input.path)) return { status: 'missing' }; + let root: string; + try { + root = real(input.path); + } catch { + return { status: 'missing' }; + } + if (!isProjectFolder(root, deps.folder.exists)) return { status: 'not-a-project' }; + // Only into a folder the person agreed to open, as its code is now: `openProject` is where that is asked. + const known = deps.readConfig().projects.find((entry) => entry.path === root) ?? null; + if (known === null || known.trusted === null || known.trusted !== fingerprint(root)) return { status: 'trust-needed' }; + if (!hasPackages(root)) { + if (deps.installPackages === undefined) return { status: 'failed', detail: CANNOT_MAKE_PROJECT }; + if (making) return { status: 'failed', detail: 'Packages are already being fetched.' }; + making = true; + try { + const got = await deps.installPackages({ root }); + if (!got.ok) return { status: 'failed', detail: got.detail }; + } finally { + making = false; + } + // The lockfile an install writes is the project's own change, not one to ask about on the next opening. + const config = deps.readConfig(); + await deps.saveConfig({ ...config, projects: config.projects.map((entry) => (entry.path === root ? { ...entry, trusted: fingerprint(root) } : entry)) }); + } deps.onChoice({ kind: 'project', root, ...(input.land === 'dashboard' ? { land: 'dashboard' as const } : {}) }); return { status: 'opened' }; }, diff --git a/apps/desktop/src/preload/api.d.ts b/apps/desktop/src/preload/api.d.ts index 985d9a3b8..6352c2417 100644 --- a/apps/desktop/src/preload/api.d.ts +++ b/apps/desktop/src/preload/api.d.ts @@ -477,7 +477,17 @@ export type DesktopOpenProjectResult = | { readonly status: 'trust-needed'; readonly path: string; readonly displayPath: string; readonly changed: boolean } | { readonly status: 'missing' } | { readonly status: 'not-a-project' } - | { readonly status: 'needs-packages' }; + /** Agreed to, and what it is built with is not on this computer: ask, then call `getPackages`. */ + | { readonly status: 'needs-packages'; readonly path: string; readonly displayPath: string }; + +/** `getPackages`: the packages were fetched and the project is opening, or why not. */ +export type DesktopGetPackagesResult = + | { readonly status: 'opened' } + | { readonly status: 'failed'; readonly detail: string } + | { readonly status: 'missing' } + | { readonly status: 'not-a-project' } + /** Not a folder the person agreed to open, or its code changed since: `openProject` asks first. */ + | { readonly status: 'trust-needed' }; export type DesktopLocateProjectResult = | { readonly status: 'located'; readonly recent: readonly DesktopRecentProject[] } @@ -514,6 +524,8 @@ export interface DesktopStartApi { /** The system's folder picker, for "Open a folder". `null` on cancel. */ chooseFolder(input: { readonly title: string }): Promise<{ readonly path: string; readonly displayPath: string } | null>; openProject(input: DesktopOpenProjectInput): Promise; + /** After `needs-packages` and a yes: fetch what the project is built with, then open it. Takes minutes on a slow line. */ + getPackages(input: { readonly path: string; readonly land?: 'designer' | 'dashboard' | undefined }): Promise; forgetProject(path: string): Promise; /** "Locate…": the system's folder picker, then the entry moves there. */ locateProject(input: { readonly path: string; readonly title: string }): Promise; diff --git a/apps/desktop/src/preload/channels.ts b/apps/desktop/src/preload/channels.ts index 87c48d831..42f5b18ab 100644 --- a/apps/desktop/src/preload/channels.ts +++ b/apps/desktop/src/preload/channels.ts @@ -62,6 +62,7 @@ export const IPC_CHANNELS = { startMakeProgress: 'adminium-desktop:start-make-progress', startChooseFolder: 'adminium-desktop:start-choose-folder', startOpenProject: 'adminium-desktop:start-open-project', + startGetPackages: 'adminium-desktop:start-get-packages', startForgetProject: 'adminium-desktop:start-forget-project', startLocateProject: 'adminium-desktop:start-locate-project', startUseClassic: 'adminium-desktop:start-use-classic', @@ -86,6 +87,7 @@ export const START_CHANNELS = [ IPC_CHANNELS.startMakeProgress, IPC_CHANNELS.startChooseFolder, IPC_CHANNELS.startOpenProject, + IPC_CHANNELS.startGetPackages, IPC_CHANNELS.startForgetProject, IPC_CHANNELS.startLocateProject, IPC_CHANNELS.startUseClassic, diff --git a/apps/desktop/src/preload/index.test.ts b/apps/desktop/src/preload/index.test.ts index 35ed5200a..ff8cf9f7e 100644 --- a/apps/desktop/src/preload/index.test.ts +++ b/apps/desktop/src/preload/index.test.ts @@ -122,10 +122,10 @@ describe('the exposed surface', () => { expect(Object.keys(build()).sort()).toEqual(SECTION_4_KEYS); }); - it('exposes the first screens as exactly their ten calls, each over its own channel', async () => { + it('exposes the first screens as exactly their eleven calls, each over its own channel', async () => { const ipc = new FakeIpc(); const { start } = createDesktopApi({ ipc, bootstrap: BOOTSTRAP }); - expect(Object.keys(start).sort()).toEqual(['chooseFolder', 'chooseParent', 'createProject', 'forgetProject', 'judgeNewFolder', 'locateProject', 'makeProgress', 'openProject', 'state', 'useClassic']); + expect(Object.keys(start).sort()).toEqual(['chooseFolder', 'chooseParent', 'createProject', 'forgetProject', 'getPackages', 'judgeNewFolder', 'locateProject', 'makeProgress', 'openProject', 'state', 'useClassic']); await start.state(); await start.judgeNewFolder({ parent: '/p', name: 'n' }); await start.chooseParent({ from: '/p', title: 't' }); @@ -133,6 +133,7 @@ describe('the exposed surface', () => { await start.makeProgress(); await start.chooseFolder({ title: 't' }); await start.openProject({ path: '/p' }); + await start.getPackages({ path: '/p' }); await start.forgetProject('/p'); await start.locateProject({ path: '/p', title: 't' }); await start.useClassic(); diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 58ae689fd..790327448 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -46,6 +46,7 @@ import type { DesktopConfigPatch, DesktopCreateProjectInput, DesktopCreateProjectResult, + DesktopGetPackagesResult, DesktopMakeProgress, DesktopDiagnostics, DesktopErrorCode, @@ -289,6 +290,8 @@ export function createDesktopApi(deps: Pick): unwrap(ipc.invoke(IPC_CHANNELS.startChooseFolder, input)), openProject: (input: DesktopOpenProjectInput): Promise => unwrap(ipc.invoke(IPC_CHANNELS.startOpenProject, input)), + getPackages: (input: { readonly path: string; readonly land?: 'designer' | 'dashboard' | undefined }): Promise => + unwrap(ipc.invoke(IPC_CHANNELS.startGetPackages, input)), forgetProject: (path: string): Promise => unwrap(ipc.invoke(IPC_CHANNELS.startForgetProject, path)), locateProject: (input: { readonly path: string; readonly title: string }): Promise => diff --git a/apps/desktop/src/renderer/app/pages.test.tsx b/apps/desktop/src/renderer/app/pages.test.tsx index af081e401..00a6da688 100644 --- a/apps/desktop/src/renderer/app/pages.test.tsx +++ b/apps/desktop/src/renderer/app/pages.test.tsx @@ -46,6 +46,7 @@ function fakeStart(over: Partial = {}): DesktopStartApi { chooseParent: vi.fn(() => Promise.resolve('/Users/sam/Documents')), createProject: vi.fn(() => Promise.resolve({ status: 'created' as const, path: '/Users/sam/Adminium/shop' })), makeProgress: vi.fn(() => Promise.resolve({ step: null, since: 0 })), + getPackages: vi.fn(() => Promise.resolve({ status: 'opened' as const })), chooseFolder: vi.fn(() => Promise.resolve<{ path: string; displayPath: string } | null>({ path: '/Users/sam/Downloads/shop', displayPath: '~/Downloads/shop' })), openProject: vi.fn(() => Promise.resolve({ status: 'opened' as const })), forgetProject: vi.fn(() => Promise.resolve([recentProject()])), @@ -247,11 +248,35 @@ describe('Start', () => { expect(openProject).toHaveBeenCalledTimes(1); }); + it('offers to get the packages of a project that has none, waits with a clock, and says why when they did not come', async () => { + let finish: (value: { status: 'failed'; detail: string } | { status: 'opened' }) => void = () => undefined; + const getPackages = vi.fn(() => new Promise((done) => (finish = done))); + const start = fakeStart({ openProject: vi.fn(() => Promise.resolve({ status: 'needs-packages' as const, path: '/p/shop', displayPath: '~/shop' })), getPackages }); + show(start, { ...STATE, recent: [recentProject()] }); + fireEvent.click(screen.getByRole('button', { name: 'Open the dashboard of Juniper Kitchen' })); + const dialog = await screen.findByRole('dialog'); + expect(dialog.textContent).toContain('Get this project’s packages?'); + expect(dialog.textContent).toContain('~/shop'); + // Asked, not done unasked. + expect(getPackages).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Get them and open' })); + expect(getPackages).toHaveBeenCalledWith({ path: '/p/shop', land: 'dashboard' }); + expect((await screen.findByRole('status')).textContent).toMatch(/Getting the packages\s*0:0\d/); + expect((screen.getByRole('button', { name: 'Not now' }) as HTMLButtonElement).disabled).toBe(true); + finish({ status: 'failed', detail: 'npm error code ENOTFOUND' }); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain('The packages could not be fetched.'); + expect(alert.textContent).toContain('npm error code ENOTFOUND'); + // And it can be tried again, or left. + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + expect(getPackages).toHaveBeenCalledTimes(2); + finish({ status: 'opened' }); + }); + it('says in a notice why a folder was not opened', async () => { for (const [status, words] of [ ['missing', 'This folder was moved or deleted'], ['not-a-project', 'This folder is not an Adminium project.'], - ['needs-packages', 'This project’s packages are not on this computer yet.'], ] as const) { show(fakeStart({ openProject: vi.fn(() => Promise.resolve({ status })) }), { ...STATE, recent: [recentProject()] }); fireEvent.click(screen.getByRole('button', { name: 'Open Juniper Kitchen' })); diff --git a/apps/desktop/src/renderer/app/start/PackagesDialog.tsx b/apps/desktop/src/renderer/app/start/PackagesDialog.tsx new file mode 100644 index 000000000..538565e1e --- /dev/null +++ b/apps/desktop/src/renderer/app/start/PackagesDialog.tsx @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** + * A project whose packages are not on this computer (one copied from another + * computer, or whose `node_modules` was deleted): the offer to get them, the + * wait while they come, and why when they did not. + * + * It is asked, not done unasked: the fetch is a download of minutes, and the + * person opened a folder, not a download. + */ +import { useT } from '@adminium/i18n/react'; +import { Modal, ModalFooter, ModalHeader } from '@adminium/ui'; +import { LoaderCircle, PackageOpen } from 'lucide-react'; +import type { ReactNode } from 'react'; + +import { elapsedWords } from '../new/NewProjectScreen.js'; + +export interface PackagesQuestion { + path: string; + displayPath: string; + land: 'designer' | 'dashboard'; + /** When the fetch began (the page's clock); `null` while it is still only asked. */ + since: number | null; + /** The last lines of a fetch that failed. */ + failure: string | null; +} + +export function PackagesDialog({ question, now, onCancel, onGet }: { question: PackagesQuestion | null; now: number; onCancel: () => void; onGet: () => void }): ReactNode { + const t = useT(); + const working = question !== null && question.since !== null; + return ( + { + // Once it runs it runs to its end: closing the question would leave a fetch nobody is told the end of. + if (!open && !working) onCancel(); + }} + className="max-w-[460px] rounded-[16px]" + > + {question === null ? null : ( + <> +
+ +
+ {t('desktop:packages.title', 'Get this project’s packages?')}
} + subtitle={ + + + {question.displayPath} + + + {t( + 'desktop:packages.body', + 'What this project is built with is not on this computer yet. Adminium can download it now and then open the project. It takes a few minutes on a slow connection.', + )} + + {working ? ( + + + ) : null} + {question.failure === null ? null : ( + + {t('desktop:packages.failed', 'The packages could not be fetched.')} + + {question.failure} + + + )} + + } + /> + + + + + + )} + + ); +} diff --git a/apps/desktop/src/renderer/app/start/StartScreen.tsx b/apps/desktop/src/renderer/app/start/StartScreen.tsx index c27bfca2b..7a3cd37dd 100644 --- a/apps/desktop/src/renderer/app/start/StartScreen.tsx +++ b/apps/desktop/src/renderer/app/start/StartScreen.tsx @@ -6,10 +6,11 @@ import { useLocale, useT } from '@adminium/i18n/react'; import { tagForLocale } from '@adminium/i18n'; import { Database, Folder, FolderOpen, Hammer, MonitorSmartphone, RadioTower, Sparkles, type LucideIcon } from 'lucide-react'; -import { useId, useRef, useState, type ReactNode } from 'react'; +import { useEffect, useId, useRef, useState, type ReactNode } from 'react'; import type { DesktopRecentProject, DesktopStartState } from '../../../preload/api.js'; import { startApi } from '../bridge.js'; +import { PackagesDialog, type PackagesQuestion } from './PackagesDialog.js'; import { TrustDialog, type TrustQuestion } from './TrustDialog.js'; type Say = (title: string, variant?: 'success' | 'error' | 'info') => void; @@ -211,7 +212,7 @@ export function StartScreen({ initial, onBuild, say }: { initial: DesktopStartSt setQuestion(null); if (result.status === 'missing') say(t('desktop:start.recent.gone', 'This folder was moved or deleted'), 'error'); else if (result.status === 'not-a-project') say(t('desktop:start.open.notAProject', 'This folder is not an Adminium project.'), 'error'); - else if (result.status === 'needs-packages') say(t('desktop:start.open.needsPackages', 'This project’s packages are not on this computer yet.'), 'info'); + else if (result.status === 'needs-packages') setPackages({ path: result.path, displayPath: result.displayPath, land, since: null, failure: null }); // 'opened': main is already taking the window to the project. } catch (error) { setQuestion(null); @@ -221,6 +222,37 @@ export function StartScreen({ initial, onBuild, say }: { initial: DesktopStartSt } }; + // The packages of a project that is there without them: asked for, fetched with a clock on the wait, then it opens. + const [packages, setPackages] = useState(null); + const [tick, setTick] = useState(() => Date.now()); + const fetching = packages !== null && packages.since !== null; + useEffect(() => { + if (!fetching) return; + const timer = setInterval(() => setTick(Date.now()), 1000); + return () => clearInterval(timer); + }, [fetching]); + const getPackages = async (): Promise => { + if (packages === null) return; + const asked = packages; + setTick(Date.now()); + setPackages({ ...asked, since: Date.now(), failure: null }); + try { + const result = await startApi().getPackages({ path: asked.path, ...(asked.land === 'dashboard' ? { land: asked.land } : {}) }); + // 'opened': main is already taking the window to the project; the wait stays shown until this page is gone. + if (result.status === 'opened') return; + if (result.status === 'failed') { + setPackages({ ...asked, since: null, failure: result.detail }); + return; + } + setPackages(null); + // The folder changed under the question: asked again from the start, which says what is wrong with it now. + await open(asked.path, false, asked.land); + } catch (error) { + setPackages(null); + say(error instanceof Error ? error.message : String(error), 'error'); + } + }; + const choose = async (key: Choice['key']): Promise => { if (key === 'build') { onBuild(); @@ -305,6 +337,17 @@ export function StartScreen({ initial, onBuild, say }: { initial: DesktopStartSt )} + { + setPackages(null); + }} + onGet={() => { + void getPackages(); + }} + /> + = new Set([ 'addOns:failed.title', 'addOns:noBundle.title', @@ -916,6 +916,11 @@ export const A11Y_CRITICAL_KEYS: ReadonlySet = new Set([ 'desktop:new.back', 'desktop:new.step.label', 'desktop:new.where', + 'desktop:packages.body', + 'desktop:packages.cancel', + 'desktop:packages.failed', + 'desktop:packages.title', + 'desktop:packages.working', 'desktop:start.choice.open.title', 'desktop:start.recent.locateTitle', 'desktop:start.recent.open', diff --git a/packages/i18n/src/resources/ar-eg/desktop.ts b/packages/i18n/src/resources/ar-eg/desktop.ts index 34e704ad9..aea64cc95 100644 --- a/packages/i18n/src/resources/ar-eg/desktop.ts +++ b/packages/i18n/src/resources/ar-eg/desktop.ts @@ -46,6 +46,15 @@ export default { }, "where": "مكان الحفظ" }, + "packages": { + "again": "حاول مرة أخرى", + "body": "ما يُبنى به هذا المشروع غير موجود على هذا الكمبيوتر بعد. يستطيع Adminium تنزيله الآن ثم فتح المشروع. يستغرق ذلك بضع دقائق مع اتصال بطيء.", + "cancel": "ليس الآن", + "failed": "تعذّر جلب الحزم.", + "get": "اجلبها وافتح", + "title": "هل تريد جلب حزم هذا المشروع؟", + "working": "جارٍ جلب الحزم" + }, "start": { "choice": { "build": { @@ -67,7 +76,6 @@ export default { }, "heading": "ماذا تريد أن تفعل؟", "open": { - "needsPackages": "حزم هذا المشروع ليست على هذا الحاسوب بعد.", "notAProject": "هذا المجلد ليس مشروع Adminium." }, "recent": { diff --git a/packages/i18n/src/resources/cs-cz/desktop.ts b/packages/i18n/src/resources/cs-cz/desktop.ts index bf6adbd6f..3409ce580 100644 --- a/packages/i18n/src/resources/cs-cz/desktop.ts +++ b/packages/i18n/src/resources/cs-cz/desktop.ts @@ -46,6 +46,15 @@ export default { }, "where": "Kam ji uložit" }, + "packages": { + "again": "Zkusit znovu", + "body": "To, z čeho je tento projekt postaven, v tomto počítači ještě není. Adminium to může teď stáhnout a pak projekt otevřít. Na pomalém připojení to trvá pár minut.", + "cancel": "Teď ne", + "failed": "Balíčky se nepodařilo stáhnout.", + "get": "Stáhnout a otevřít", + "title": "Stáhnout balíčky tohoto projektu?", + "working": "Stahují se balíčky" + }, "start": { "choice": { "build": { @@ -67,7 +76,6 @@ export default { }, "heading": "Co chcete udělat?", "open": { - "needsPackages": "Balíčky tohoto projektu na tomto počítači ještě nejsou.", "notAProject": "Tato složka není projekt Adminia." }, "recent": { diff --git a/packages/i18n/src/resources/da-dk/desktop.ts b/packages/i18n/src/resources/da-dk/desktop.ts index 31f8589eb..18c25ed57 100644 --- a/packages/i18n/src/resources/da-dk/desktop.ts +++ b/packages/i18n/src/resources/da-dk/desktop.ts @@ -46,6 +46,15 @@ export default { }, "where": "Hvor den skal ligge" }, + "packages": { + "again": "Prøv igen", + "body": "Det, dette projekt er bygget med, findes ikke på denne computer endnu. Adminium kan hente det nu og derefter åbne projektet. Det tager et par minutter på en langsom forbindelse.", + "cancel": "Ikke nu", + "failed": "Pakkerne kunne ikke hentes.", + "get": "Hent dem og åbn", + "title": "Hent projektets pakker?", + "working": "Henter pakkerne" + }, "start": { "choice": { "build": { @@ -67,7 +76,6 @@ export default { }, "heading": "Hvad vil du gerne gøre?", "open": { - "needsPackages": "Dette projekts pakker er endnu ikke på denne computer.", "notAProject": "Denne mappe er ikke et Adminium-projekt." }, "recent": { diff --git a/packages/i18n/src/resources/de-de/desktop.ts b/packages/i18n/src/resources/de-de/desktop.ts index 92bb82394..e4eb6351b 100644 --- a/packages/i18n/src/resources/de-de/desktop.ts +++ b/packages/i18n/src/resources/de-de/desktop.ts @@ -46,6 +46,15 @@ export default { }, "where": "Speicherort" }, + "packages": { + "again": "Erneut versuchen", + "body": "Die Bausteine dieses Projekts sind noch nicht auf diesem Computer. Adminium kann sie jetzt herunterladen und das Projekt danach öffnen. Bei langsamer Verbindung dauert das ein paar Minuten.", + "cancel": "Nicht jetzt", + "failed": "Die Pakete konnten nicht geladen werden.", + "get": "Laden und öffnen", + "title": "Pakete dieses Projekts laden?", + "working": "Pakete werden geladen" + }, "start": { "choice": { "build": { @@ -67,7 +76,6 @@ export default { }, "heading": "Was möchten Sie tun?", "open": { - "needsPackages": "Die Pakete dieses Projekts sind noch nicht auf diesem Computer.", "notAProject": "Dieser Ordner ist kein Adminium-Projekt." }, "recent": { diff --git a/packages/i18n/src/resources/en-us/desktop.ts b/packages/i18n/src/resources/en-us/desktop.ts index 7b914fdb2..4e9d05d5b 100644 --- a/packages/i18n/src/resources/en-us/desktop.ts +++ b/packages/i18n/src/resources/en-us/desktop.ts @@ -46,6 +46,15 @@ export default { }, "where": "Where to keep it" }, + "packages": { + "again": "Try again", + "body": "What this project is built with is not on this computer yet. Adminium can download it now and then open the project. It takes a few minutes on a slow connection.", + "cancel": "Not now", + "failed": "The packages could not be fetched.", + "get": "Get them and open", + "title": "Get this project’s packages?", + "working": "Getting the packages" + }, "start": { "choice": { "build": { @@ -67,7 +76,6 @@ export default { }, "heading": "What would you like to do?", "open": { - "needsPackages": "This project’s packages are not on this computer yet.", "notAProject": "This folder is not an Adminium project." }, "recent": { diff --git a/packages/i18n/src/resources/fr-fr/desktop.ts b/packages/i18n/src/resources/fr-fr/desktop.ts index 1e5ba68a0..66d9714bb 100644 --- a/packages/i18n/src/resources/fr-fr/desktop.ts +++ b/packages/i18n/src/resources/fr-fr/desktop.ts @@ -46,6 +46,15 @@ export default { }, "where": "Emplacement" }, + "packages": { + "again": "Réessayer", + "body": "Ce avec quoi ce projet est construit n’est pas encore sur cet ordinateur. Adminium peut le télécharger maintenant, puis ouvrir le projet. Cela prend quelques minutes avec une connexion lente.", + "cancel": "Pas maintenant", + "failed": "Les paquets n’ont pas pu être récupérés.", + "get": "Récupérer et ouvrir", + "title": "Récupérer les paquets de ce projet ?", + "working": "Récupération des paquets" + }, "start": { "choice": { "build": { @@ -67,7 +76,6 @@ export default { }, "heading": "Que souhaitez-vous faire ?", "open": { - "needsPackages": "Les paquets de ce projet ne sont pas encore sur cet ordinateur.", "notAProject": "Ce dossier n’est pas un projet Adminium." }, "recent": { diff --git a/packages/i18n/src/resources/zh-cn/desktop.ts b/packages/i18n/src/resources/zh-cn/desktop.ts index c7c0c4c2c..b1ca48d73 100644 --- a/packages/i18n/src/resources/zh-cn/desktop.ts +++ b/packages/i18n/src/resources/zh-cn/desktop.ts @@ -46,6 +46,15 @@ export default { }, "where": "保存位置" }, + "packages": { + "again": "重试", + "body": "构建此项目所需的内容尚未在这台电脑上。Adminium 可以现在下载,然后打开项目。网络较慢时需要几分钟。", + "cancel": "暂不", + "failed": "无法获取软件包。", + "get": "获取并打开", + "title": "获取此项目的软件包?", + "working": "正在获取软件包" + }, "start": { "choice": { "build": { @@ -67,7 +76,6 @@ export default { }, "heading": "你想做什么?", "open": { - "needsPackages": "这台电脑上还没有此项目的软件包。", "notAProject": "此文件夹不是 Adminium 项目。" }, "recent": { diff --git a/packages/i18n/src/resources/zh-tw/desktop.ts b/packages/i18n/src/resources/zh-tw/desktop.ts index 5783f9f59..c54f735c4 100644 --- a/packages/i18n/src/resources/zh-tw/desktop.ts +++ b/packages/i18n/src/resources/zh-tw/desktop.ts @@ -46,6 +46,15 @@ export default { }, "where": "儲存位置" }, + "packages": { + "again": "重試", + "body": "建置此專案所需的內容尚未在這台電腦上。Adminium 可以現在下載,然後開啟專案。網路較慢時需要幾分鐘。", + "cancel": "暫不", + "failed": "無法取得套件。", + "get": "取得並開啟", + "title": "取得此專案的套件?", + "working": "正在取得套件" + }, "start": { "choice": { "build": { @@ -67,7 +76,6 @@ export default { }, "heading": "你想做什麼?", "open": { - "needsPackages": "這台電腦上還沒有此專案的套件。", "notAProject": "此資料夾不是 Adminium 專案。" }, "recent": { diff --git a/packages/i18n/src/review-status.ts b/packages/i18n/src/review-status.ts index c39926310..ed461c6d0 100644 --- a/packages/i18n/src/review-status.ts +++ b/packages/i18n/src/review-status.ts @@ -46,13 +46,13 @@ export interface LocaleReview extends ReviewCoverage { /** GENERATED — see the module docblock. `en_US` is the source and never tracked. */ export const REVIEW_STATUS: Record = { - de_DE: { tracked: 8149, reviewed: 0, mt: 8072, outdated: 77, shipReady: false }, - fr_FR: { tracked: 8149, reviewed: 0, mt: 8072, outdated: 77, shipReady: false }, - cs_CZ: { tracked: 8149, reviewed: 0, mt: 8072, outdated: 77, shipReady: false }, - da_DK: { tracked: 8149, reviewed: 0, mt: 8072, outdated: 77, shipReady: false }, - zh_CN: { tracked: 8149, reviewed: 0, mt: 8072, outdated: 77, shipReady: false }, - zh_TW: { tracked: 8149, reviewed: 0, mt: 8072, outdated: 77, shipReady: false }, - ar_EG: { tracked: 8149, reviewed: 0, mt: 8072, outdated: 77, shipReady: false }, + de_DE: { tracked: 8155, reviewed: 0, mt: 8078, outdated: 77, shipReady: false }, + fr_FR: { tracked: 8155, reviewed: 0, mt: 8078, outdated: 77, shipReady: false }, + cs_CZ: { tracked: 8155, reviewed: 0, mt: 8078, outdated: 77, shipReady: false }, + da_DK: { tracked: 8155, reviewed: 0, mt: 8078, outdated: 77, shipReady: false }, + zh_CN: { tracked: 8155, reviewed: 0, mt: 8078, outdated: 77, shipReady: false }, + zh_TW: { tracked: 8155, reviewed: 0, mt: 8078, outdated: 77, shipReady: false }, + ar_EG: { tracked: 8155, reviewed: 0, mt: 8078, outdated: 77, shipReady: false }, }; /** From 547a0da065d578e6882b81a64aa096b2b62d89a1 Mon Sep 17 00:00:00 2001 From: Mohamed Sofi Date: Sat, 10 Oct 2026 15:53:41 +0200 Subject: [PATCH 51/79] feat(desktop): offer to keep versions where there is no git, and turn them on without a restart --- .../src/designer/build/BuildPage.tsx | 9 + .../src/designer/build/SessionTitle.tsx | 29 +- .../src/designer/home/DesignerHome.tsx | 7 + .../designer/parts/DesktopVersions.test.tsx | 193 +++++++++++++ .../src/designer/parts/DesktopVersions.tsx | 272 ++++++++++++++++++ apps/desktop/src/main/config.test.ts | 6 +- apps/desktop/src/main/config.ts | 8 + apps/desktop/src/main/git.test.ts | 18 ++ apps/desktop/src/main/git.ts | 10 +- apps/desktop/src/main/index.test.ts | 42 ++- apps/desktop/src/main/index.ts | 107 ++++++- apps/desktop/src/main/ipc.test.ts | 34 ++- apps/desktop/src/main/ipc.ts | 24 +- apps/desktop/src/main/project.test.ts | 31 +- apps/desktop/src/main/project.ts | 29 ++ apps/desktop/src/main/server-manager.test.ts | 28 ++ apps/desktop/src/main/server-manager.ts | 23 +- apps/desktop/src/main/versions-offer.test.ts | 169 +++++++++++ apps/desktop/src/main/versions-offer.ts | 153 ++++++++++ apps/desktop/src/preload/api.d.ts | 37 +++ apps/desktop/src/preload/channels.ts | 17 ++ apps/desktop/src/preload/index.test.ts | 16 +- apps/desktop/src/preload/index.ts | 9 + apps/desktop/src/server/index.ts | 8 + apps/desktop/src/server/project.test.ts | 19 ++ apps/desktop/src/server/protocol.ts | 3 + .../tests-desktop/desktop-versions.spec.ts | 171 +++++++++++ apps/server/src/compose.ts | 3 +- apps/server/src/designer/versions.ts | 29 +- apps/server/test/designer-versions.test.ts | 17 ++ packages/i18n/locales/ar-EG/.meta.json | 84 ++++++ packages/i18n/locales/ar-EG/designer.json | 25 ++ packages/i18n/locales/cs-CZ/.meta.json | 84 ++++++ packages/i18n/locales/cs-CZ/designer.json | 25 ++ packages/i18n/locales/da-DK/.meta.json | 84 ++++++ packages/i18n/locales/da-DK/designer.json | 25 ++ packages/i18n/locales/de-DE/.meta.json | 84 ++++++ packages/i18n/locales/de-DE/designer.json | 25 ++ packages/i18n/locales/en-US/designer.json | 25 ++ packages/i18n/locales/fr-FR/.meta.json | 84 ++++++ packages/i18n/locales/fr-FR/designer.json | 25 ++ packages/i18n/locales/zh-CN/.meta.json | 84 ++++++ packages/i18n/locales/zh-CN/designer.json | 25 ++ packages/i18n/locales/zh-TW/.meta.json | 84 ++++++ packages/i18n/locales/zh-TW/designer.json | 25 ++ packages/i18n/src/a11y-keys.ts | 5 +- packages/i18n/src/resources/ar-eg/designer.ts | 25 ++ packages/i18n/src/resources/cs-cz/designer.ts | 25 ++ packages/i18n/src/resources/da-dk/designer.ts | 25 ++ packages/i18n/src/resources/de-de/designer.ts | 25 ++ packages/i18n/src/resources/en-us/designer.ts | 25 ++ packages/i18n/src/resources/fr-fr/designer.ts | 25 ++ packages/i18n/src/resources/zh-cn/designer.ts | 25 ++ packages/i18n/src/resources/zh-tw/designer.ts | 25 ++ packages/i18n/src/review-status.ts | 14 +- 55 files changed, 2489 insertions(+), 39 deletions(-) create mode 100644 apps/dashboard/src/designer/parts/DesktopVersions.test.tsx create mode 100644 apps/dashboard/src/designer/parts/DesktopVersions.tsx create mode 100644 apps/desktop/src/main/versions-offer.test.ts create mode 100644 apps/desktop/src/main/versions-offer.ts create mode 100644 apps/e2e/tests-desktop/desktop-versions.spec.ts diff --git a/apps/dashboard/src/designer/build/BuildPage.tsx b/apps/dashboard/src/designer/build/BuildPage.tsx index 91d6c1b59..f72b297bb 100644 --- a/apps/dashboard/src/designer/build/BuildPage.tsx +++ b/apps/dashboard/src/designer/build/BuildPage.tsx @@ -47,6 +47,7 @@ import { StoppedNote, UsageLine, } from '../parts/chat.js'; +import { useDesktopVersions, VersionsOfferDialog } from '../parts/DesktopVersions.js'; import { TopBar } from '../parts/TopBar.js'; import { DesktopBuildShare } from '../parts/DesktopProject.js'; import { CHAT_WIDTH, FOLDED_NEED_GUESS, views } from './barLevel.js'; @@ -92,6 +93,12 @@ export function BuildPage({ sessionId }: { sessionId: string }): ReactNode { const session = useQuery(sessionQuery(sessionId)); const apps = useQuery({ ...yourAppsQuery(), enabled: session.data !== undefined }); const versions = useQuery({ ...versionsQuery(sessionId), enabled: session.data !== undefined }); + // Inside the desktop app on a computer with no git: versions can be turned on without leaving the page. + const desktopVersions = useDesktopVersions(() => { + void queryClient.invalidateQueries({ queryKey: designerKeys.versions(sessionId) }); + toasts.push({ variant: 'success', title: t('designer:versionsOffer.nowOn', 'Versions are on'), description: t('designer:versionsOffer.nowOnNext', 'Your next change is kept as a version.') }); + }); + const [offeringVersions, setOfferingVersions] = useState(false); const { events, loaded } = useSessionEvents(sessionId); const turns = useMemo(() => foldTurns(events), [events]); const working = isWorking(turns); @@ -499,6 +506,7 @@ export function BuildPage({ sessionId }: { sessionId: string }): ReactNode { return (
+ {desktopVersions === null ? null : } restore.mutate({ n: version.n, record: true })} + onTurnVersionsOn={desktopVersions === null || desktopVersions.state.on ? undefined : () => setOfferingVersions(true)} /> ) } diff --git a/apps/dashboard/src/designer/build/SessionTitle.tsx b/apps/dashboard/src/designer/build/SessionTitle.tsx index 20c94d077..99b100bd7 100644 --- a/apps/dashboard/src/designer/build/SessionTitle.tsx +++ b/apps/dashboard/src/designer/build/SessionTitle.tsx @@ -23,6 +23,7 @@ import { import { getI18nInstance, t } from '../../i18n/t.js'; import type { DesignerVersion } from '../api.js'; import { editedWhen } from '../home/YourApps.js'; +import { VERSIONS_PILL } from '../parts/DesktopVersions.js'; import { MENU_HEADING } from './MoreMenu.js'; import { versionLabel, versionName } from './versionName.js'; @@ -63,6 +64,7 @@ export function SessionTitle({ current, onGoBack, busy, + onTurnVersionsOn, }: { title: string; onRename: (title: string) => void; @@ -71,6 +73,8 @@ export function SessionTitle({ current: DesignerVersion | null; onGoBack: (version: DesignerVersion) => void; busy: boolean; + /** Inside the desktop app, on a computer with no git: versions can be turned on from here. */ + onTurnVersionsOn?: (() => void) | undefined; }): ReactNode { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(title); @@ -130,7 +134,30 @@ export function SessionTitle({ )} - {!available ? ( + {!available && onTurnVersionsOn !== undefined ? ( + + + + + {t('designer:versions.title', 'Versions')} +
+ + {t('designer:versionsOffer.offHere', 'Versions are off on this computer.')} + } className={`${VERSIONS_PILL} w-auto [&_svg]:size-3.5`}> + {t('designer:versionsOffer.turnOn', 'Turn versions on')} + +
+
+
+ ) : !available ? (