feat: add a desktop build of the webmail that no server build can switch on

This commit is contained in:
Linus Rath
2026-11-03 06:32:27 +01:00
parent bad156eaed
commit a30f989c57
18 changed files with 440 additions and 5 deletions
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env node
// The webmail build the desktop app embeds: `npm run build:desktop`. It's the
// regular standalone build with NEXT_PUBLIC_BULWARK_DESKTOP compiled in, using
// webpack (a third of Turbopack's server size, and its hashed external links
// don't relocate). No other build sets the flag: repos/desktop's
// scripts/fetch-webmail.mjs refuses a tree without it.
import { spawnSync } from 'node:child_process';
const env = { ...process.env, NEXT_PUBLIC_BULWARK_DESKTOP: '1', NEXT_TELEMETRY_DISABLED: '1' };
const isWindows = process.platform === 'win32';
for (const [command, ...args] of [
['node', 'scripts/build-pst-worker.mjs'],
['node', 'scripts/build-store-worker.mjs'],
['node', 'scripts/build-plugin-sandbox.mjs'],
['npx', 'next', 'build', '--webpack'],
['node', 'scripts/standalone-guard.mjs'],
]) {
const run = spawnSync(command, args, { env, stdio: 'inherit', shell: isWindows });
if (run.status !== 0) process.exit(run.status ?? 1);
}
+12
View File
@@ -0,0 +1,12 @@
// The value next.config.ts inlines as NEXT_PUBLIC_BULWARK_DESKTOP.
//
// Always defined, so every build compiles `process.env.NEXT_PUBLIC_BULWARK_DESKTOP`
// to a literal. Left undefined, Next would leave the read in the server bundle,
// and setting the variable when starting a server build would turn the flag on.
/** The marker scripts/standalone-guard.mjs looks for in a non-desktop build. */
export const DESKTOP_TRUST_MARKER = 'bulwark.desktop.trustedJmapOrigins';
export function desktopBuildFlag(env = process.env) {
return env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' ? '1' : '';
}
+25 -2
View File
@@ -18,6 +18,7 @@
import { existsSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { DESKTOP_TRUST_MARKER, desktopBuildFlag } from './desktop-build-flag.mjs';
/** Top-level project entries the running server needs. Traces drop the rest. */
const TRACE_ALLOWED = new Set(['node_modules', '.next', 'public', 'locales', 'package.json', 'VERSION']);
@@ -141,10 +142,32 @@ export function removeCopiedEnvFiles(standaloneDir) {
return removed;
}
/**
* Server bundles that still hold the desktop app's trust path. A server build
* compiles it away (lib/desktop/flag.ts); finding it means the flag leaked into
* this build or stopped being inlined, and a visitor could reach private hosts.
*/
export function findDesktopTrustPath(standaloneDir) {
const found = [];
const visit = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name !== 'node_modules') visit(full);
} else if (entry.name.endsWith('.js') && readFileSync(full, 'utf8').includes(DESKTOP_TRUST_MARKER)) {
found.push(`${toPosix(path.relative(standaloneDir, full))} (desktop trust path in a server build)`);
}
}
};
visit(standaloneDir);
return found.sort();
}
/** Cleans and checks a standalone tree. Returns what is still wrong with it. */
export function guardStandalone(standaloneDir) {
export function guardStandalone(standaloneDir, { desktopBuild = desktopBuildFlag() === '1' } = {}) {
removeCopiedEnvFiles(standaloneDir);
return findForbidden(standaloneDir);
const forbidden = findForbidden(standaloneDir);
return desktopBuild ? forbidden : [...forbidden, ...findDesktopTrustPath(standaloneDir)];
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {