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
@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useMemo, useCallback, useId, useRef } from 'react';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import QRCode from 'qrcode';
@@ -1569,8 +1570,12 @@ export function AccountSecuritySettings() {
<SettingsSection id="devices" title={t('title')} description={t('description')}>
{!IS_LITE ? (
<div className="space-y-6">
<LinkDeviceSection />
<div className="border-t border-border" />
{!IS_DESKTOP_BUILD && (
<>
<LinkDeviceSection />
<div className="border-t border-border" />
</>
)}
<div className="text-sm text-muted-foreground" dangerouslySetInnerHTML={{ __html: sanitizeI18nHtml(t.raw('not_available')) }} />
</div>
) : (
@@ -1610,7 +1615,7 @@ export function AccountSecuritySettings() {
description={withoutPassword ? t('description') : t('sections.devices_description')}
>
<div className="space-y-6">
{!IS_LITE && (
{!IS_LITE && !IS_DESKTOP_BUILD && (
<>
<LinkDeviceSection />
<div className="border-t border-border" />
+27
View File
@@ -31,6 +31,33 @@ afterEach(() => {
while (temps.length) rmSync(temps.pop()!, { recursive: true, force: true });
});
describe('the desktop trust path', () => {
it('fails a server build that still holds it', () => {
const dir = tempDir();
put(dir, 'server.js');
put(dir, '.next/server/chunks/123.js', 'holder[Symbol.for("bulwark.desktop.trustedJmapOrigins")]');
expect(guardStandalone(dir, { desktopBuild: false })).toEqual(['.next/server/chunks/123.js (desktop trust path in a server build)']);
});
it('passes the desktop build itself', () => {
const dir = tempDir();
put(dir, 'server.js');
put(dir, '.next/server/chunks/123.js', 'holder[Symbol.for("bulwark.desktop.trustedJmapOrigins")]');
expect(guardStandalone(dir, { desktopBuild: true })).toEqual([]);
});
it('fails the command line check on a server build', () => {
const dir = tempDir();
put(dir, 'server.js');
put(dir, '.next/server/chunks/123.js', 'bulwark.desktop.trustedJmapOrigins');
const env = { ...process.env };
delete env.NEXT_PUBLIC_BULWARK_DESKTOP;
const run = spawnSync(process.execPath, [SCRIPT, dir], { encoding: 'utf8', env });
expect(run.status).toBe(1);
expect(run.stderr).toContain('desktop trust path');
});
});
describe('standalone guard check', () => {
it('fails the build on a planted signing key', () => {
const dir = tempDir();
+5
View File
@@ -1,3 +1,5 @@
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
/**
* Utilities for multi-account support:
* - Account ID generation
@@ -136,6 +138,9 @@ export function isHttp2Available(): boolean {
* once HTTP/2+ is observed, otherwise returns {@link MAX_ACCOUNTS_HTTP1}.
*/
export function getMaxAccounts(): number {
// The cap is for the push streams sharing one http/1.1 origin. The embedded
// server speaks http/1.1, but each account streams from its own mail server.
if (IS_DESKTOP_BUILD) return MAX_ACCOUNT_SLOTS;
return isHttp2Available() ? MAX_ACCOUNT_SLOTS : MAX_ACCOUNTS_HTTP1;
}
+6
View File
@@ -1,6 +1,9 @@
/**
* Multi-server JMAP support: schema, parsing, lookup, and redaction helpers.
*/
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import { isDesktopTrustedServer } from '@/lib/desktop/trusted-servers';
export interface JmapServerOAuthConfig {
clientId?: string;
@@ -223,6 +226,9 @@ export function resolveTrustedJmapUrl(
}
const matched = servers.find((s) => normalizeUrl(s.url) === target);
if (matched) return matched.url;
// A server the user approved in the desktop app. Compiled out of every other
// build, so nothing at runtime can open this path on a server.
if (IS_DESKTOP_BUILD && isDesktopTrustedServer(requestedUrl)) return trimUrl(requestedUrl);
// No match - caller decides whether to honor the request anyway (e.g. when
// allowCustomJmapEndpoint is enabled).
return null;
+112
View File
@@ -0,0 +1,112 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { build } from 'esbuild';
import { desktopBuildFlag } from '../../../scripts/desktop-build-flag.mjs';
const ROOT = path.resolve(__dirname, '../../..');
const TRUST_KEY = Symbol.for('bulwark.desktop.trustedJmapOrigins');
const PRIVATE_SERVER = 'http://192.168.1.5:8080';
type Holder = Record<symbol, ReadonlySet<string> | undefined>;
function trust(...origins: string[]) {
(globalThis as unknown as Holder)[TRUST_KEY] = new Set(origins);
}
async function loadResolver() {
vi.resetModules();
return (await import('@/lib/admin/jmap-servers')).resolveTrustedJmapUrl;
}
beforeEach(() => {
delete (globalThis as unknown as Holder)[TRUST_KEY];
});
afterEach(() => {
vi.unstubAllEnvs();
delete (globalThis as unknown as Holder)[TRUST_KEY];
});
describe('the trusted set, server build', () => {
it('refuses a private URL even when the set holds it', async () => {
trust('http://192.168.1.5:8080');
const resolve = await loadResolver();
expect(resolve(PRIVATE_SERVER, '', [])).toBeNull();
});
});
describe('the trusted set, desktop build', () => {
beforeEach(() => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
});
it('accepts a server main approved', async () => {
trust('http://192.168.1.5:8080');
const resolve = await loadResolver();
expect(resolve(`${PRIVATE_SERVER}/`, '', [])).toBe(PRIVATE_SERVER);
});
it('does not trust everything: another private server stays out', async () => {
trust('http://192.168.1.5:8080');
const resolve = await loadResolver();
expect(resolve('http://192.168.1.6:8080', '', [])).toBeNull();
expect(resolve('http://192.168.1.5:9999', '', [])).toBeNull();
expect(resolve('http://169.254.169.254', '', [])).toBeNull();
});
it('trusts nothing before main has sent a set', async () => {
const resolve = await loadResolver();
expect(resolve(PRIVATE_SERVER, '', [])).toBeNull();
});
it('ignores a URL with credentials in it', async () => {
trust('http://192.168.1.5:8080');
const resolve = await loadResolver();
expect(resolve('http://a:b@192.168.1.5:8080', '', [])).toBeNull();
});
});
describe('the flag is fixed at build time', () => {
/** Compiles lib/desktop/flag.ts the way next.config.ts `env` does and runs it. */
async function flagOfBuild(buildEnv: NodeJS.ProcessEnv, runtimeEnv: NodeJS.ProcessEnv): Promise<boolean> {
const result = await build({
entryPoints: [path.join(ROOT, 'lib/desktop/flag.ts')],
bundle: true,
write: false,
format: 'cjs',
platform: 'node',
define: { 'process.env.NEXT_PUBLIC_BULWARK_DESKTOP': JSON.stringify(desktopBuildFlag(buildEnv)) },
});
const run = new Function('module', 'exports', 'process', result.outputFiles[0].text);
const module = { exports: {} as { IS_DESKTOP_BUILD?: boolean } };
run(module, module.exports, { env: runtimeEnv });
return module.exports.IS_DESKTOP_BUILD === true;
}
it('a runtime env var cannot switch a server build on', async () => {
expect(await flagOfBuild({}, { NEXT_PUBLIC_BULWARK_DESKTOP: '1' })).toBe(false);
});
it('a desktop build is on without any runtime env var', async () => {
expect(await flagOfBuild({ NEXT_PUBLIC_BULWARK_DESKTOP: '1' }, {})).toBe(true);
});
it('next.config.ts always inlines the flag, so an unset one is no runtime read', () => {
const config = readFileSync(path.join(ROOT, 'next.config.ts'), 'utf8');
expect(config).toMatch(/NEXT_PUBLIC_BULWARK_DESKTOP: desktopBuildFlag\(\)/);
expect(desktopBuildFlag({})).toBe('');
});
it('flag.ts reads the variable in the one form the compiler replaces', () => {
const source = readFileSync(path.join(ROOT, 'lib/desktop/flag.ts'), 'utf8');
expect(source).toContain("process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1'");
});
it('no image build sets it', () => {
for (const file of ['Dockerfile', 'Dockerfile.lite']) {
expect(readFileSync(path.join(ROOT, file), 'utf8')).not.toContain('NEXT_PUBLIC_BULWARK_DESKTOP');
}
});
});
+10
View File
@@ -0,0 +1,10 @@
/**
* Routes with no meaning in the desktop build: pairing a phone and the mobile
* hand-off both hand out `https://app.bulwark.localhost`, which nothing else
* can reach, and the app has no use for impersonation. proxy.ts answers 404.
*/
const BLOCKED = /^\/api\/auth\/(pair|handoff|impersonate)(\/|$)/;
export function isDesktopBlockedRoute(pathname: string): boolean {
return BLOCKED.test(pathname);
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Finds the desktop app's bridge. Everything here is feature detected: in a
* browser there's no bridge, and a newer bridge version than this code knows
* is treated as absent rather than guessed at.
*/
import { DESKTOP_BRIDGE_VERSION, type BulwarkDesktopBridge } from './types';
export function getDesktopBridge(): BulwarkDesktopBridge | null {
if (typeof window === 'undefined') return null;
const bridge = window.bulwarkDesktop;
if (!bridge || typeof bridge !== 'object') return null;
if (typeof bridge.version !== 'number' || bridge.version > DESKTOP_BRIDGE_VERSION) return null;
return bridge;
}
/** True in any window of the desktop app, standalone or on an instance. */
export function isDesktopApp(): boolean {
return getDesktopBridge() !== null;
}
+17
View File
@@ -0,0 +1,17 @@
/**
* The desktop app's own webmail build (`npm run build:desktop`).
*
* A build-time constant, inlined through next.config.ts `env` into client and
* server bundles alike, like `IS_LITE`. A server admin can't switch it on: in
* any other build the value is compiled in as empty, and the bundler drops
* the branches. Never read this flag through a runtime environment variable.
*
* What it unlocks, and only in that build:
* - login routes accept the servers the user approved in the app
* (`isDesktopTrustedServer`, the list comes from the Electron main process)
* - sign-in through the system browser for any server
* - a per-slot record of the upstream for refresh tokens
* - a login budget per mail server rather than per client address
* - pairing and hand-off routes are gone
*/
export const IS_DESKTOP_BUILD = process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1';
+33
View File
@@ -0,0 +1,33 @@
/**
* The mail servers the user approved in the desktop app.
*
* The Electron main process owns this list and sends it to the embedded server
* at start and on every change (src/server/host.ts in the desktop repo sets it
* on globalThis, which all of Next's bundles share). Nothing in the renderer
* or the environment can write it, so script in a page can't aim the login
* routes at the LAN.
*
* Callers guard this with IS_DESKTOP_BUILD, so a server build compiles the
* whole thing away. scripts/standalone-guard.mjs checks that it did.
*/
type TrustedOrigins = ReadonlySet<string>;
function originOf(url: string): string | null {
try {
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
if (parsed.username || parsed.password) return null;
return parsed.origin;
} catch {
return null;
}
}
export function isDesktopTrustedServer(url: string | null | undefined): boolean {
if (!url) return false;
const origin = originOf(url);
if (!origin) return false;
const holder = globalThis as unknown as Record<symbol, TrustedOrigins | undefined>;
return holder[Symbol.for('bulwark.desktop.trustedJmapOrigins')]?.has(origin) === true;
}
+117
View File
@@ -0,0 +1,117 @@
/**
* The desktop app's preload bridge as the webmail sees it. Feature checks ask
* `window.bulwarkDesktop?.<area>`; in a browser the object isn't there.
*
* The shell side lives in the desktop repo (src/shared/bridge.ts), a copy of
* this contract. Add a member here and there together. A breaking change bumps
* `version`; adding a member doesn't.
*/
export const DESKTOP_BRIDGE_VERSION = 1;
export type DesktopWindowType = 'main' | 'message' | 'composer' | 'settings';
export interface DesktopWindowInfo {
id: number;
type: DesktopWindowType;
title: string;
focused: boolean;
}
export interface DesktopWindowsBridge {
/**
* Opens a pop-out window itself. The name is the window's identity: opening
* the same name again focuses the window that has it. Left out, the page uses
* window.open with a features string, which the shell's window-open handler
* sees too.
*/
open?: (url: string, options: { name: string; width: number; height: number }) => void;
/** One window per message: opening it again focuses it. */
openMessage?: (target: { url: string; emailId: string }) => void;
openComposer?: (target: { url: string; name?: string }) => void;
/** Focuses a window from `list()`, or the main window with no id. */
focus?: (id?: number) => void;
list?: () => Promise<DesktopWindowInfo[]>;
/** Fires when a window opens, closes, retitles or takes the focus. Returns the unsubscribe. */
onChanged?: (listener: (windows: DesktopWindowInfo[]) => void) => () => void;
}
export interface DesktopThemeBridge {
/** The page's colours, for title bar buttons and the first frame of new windows. */
set: (theme: { dark: boolean; background: string; foreground?: string }) => void;
/** Fires when the OS switches between light and dark. Returns the unsubscribe. */
onSystemChange?: (listener: (system: { dark: boolean; highContrast: boolean }) => void) => () => void;
}
export interface DesktopMenuBridge {
/**
* What the app menu shows as enabled or checked, by command id. The shell
* keeps the last state per window and applies it when that window has focus.
*/
setState: (state: Record<string, boolean | { enabled?: boolean; checked?: boolean }>) => void;
}
export interface DesktopShellBridge {
/** http, https and mailto only. */
openExternal: (url: string) => void;
}
export type DesktopServerPrepareResult =
| { ok: true; approved: boolean }
| { ok: false; reason: 'declined' | 'invalid' };
export interface DesktopServersBridge {
/**
* Call before signing in to a server. Asks the user first when it is on the
* LAN or plain http, and lets the app reach it. Standalone mode only.
*/
prepare: (serverUrl: string) => Promise<DesktopServerPrepareResult>;
/** The origins a session names, for the CORS headers the shell adds for them. */
noteSession: (
serverUrl: string,
urls: { apiUrl?: string; downloadUrl?: string; uploadUrl?: string; eventSourceUrl?: string },
) => void;
}
export type DesktopBrowserSignInResult =
| { ok: true; code: string; state: string }
| { ok: false; error: string };
export interface DesktopAuthBridge {
/** Starts a short-lived loopback listener for one sign-in. */
beginBrowserSignIn: () => Promise<{ flowId: string; redirectUri: string }>;
/** Opens the system browser on `authorizeUrl` and waits for the redirect. */
completeBrowserSignIn: (flowId: string, authorizeUrl: string) => Promise<DesktopBrowserSignInResult>;
cancelBrowserSignIn: (flowId: string) => void;
}
export type DesktopPluginInstallResult =
| { ok: true; id: string; version: string }
| { ok: false; error: string; code?: string };
export interface DesktopPluginsBridge {
/**
* Installs from the directory through the local admin API, with its signature
* and hash checks. The renderer never holds an admin session. Standalone only.
*/
install: (slug: string, version: string) => Promise<DesktopPluginInstallResult>;
}
export interface BulwarkDesktopBridge {
version: number;
platform: 'win32' | 'darwin' | 'linux';
mode?: 'standalone' | 'instance';
windows?: DesktopWindowsBridge;
theme?: DesktopThemeBridge;
menu?: DesktopMenuBridge;
shell?: DesktopShellBridge;
servers?: DesktopServersBridge;
auth?: DesktopAuthBridge;
plugins?: DesktopPluginsBridge;
}
declare global {
interface Window {
bulwarkDesktop?: BulwarkDesktopBridge;
}
}
+12
View File
@@ -1,3 +1,5 @@
import { getDesktopBridge } from '@/lib/desktop/bridge';
/**
* Keeps <meta name="theme-color"> in step with the active theme.
*
@@ -66,6 +68,7 @@ export function syncThemeColorMeta(): void {
const run = () => {
scheduled = false;
writeMeta();
tellDesktopShell();
};
if (typeof requestAnimationFrame === 'function') {
@@ -75,6 +78,15 @@ export function syncThemeColorMeta(): void {
}
}
/** The desktop app's title bar buttons and new windows' first frame follow the page. */
function tellDesktopShell(): void {
const theme = getDesktopBridge()?.theme;
if (!theme) return;
const background = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]')?.content;
if (!background) return;
theme.set({ dark: document.documentElement.classList.contains('dark'), background });
}
/** Turn syncing on and take a first reading. Called by <ThemeColorSync />. */
export function enableThemeColorSync(): void {
enabled = true;
+3
View File
@@ -5,6 +5,7 @@
// register endpoint we hit on the relay.
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { isDesktopApp } from '@/lib/desktop/bridge';
import type { AccountInfo, EmailPushConfig, Mailbox } from '@/lib/jmap/types';
import { isJmapSetError, jmapErrorType } from '@/lib/jmap/jmap-error';
import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays';
@@ -443,6 +444,8 @@ export function isWebPushSupported(): boolean {
// No service worker is registered in the static Lite build (see
// components/service-worker-registration.tsx), so push cannot be enabled.
if (IS_LITE) return false;
// Electron has the API, but subscribing always fails: it has no push service.
if (isDesktopApp()) return false;
return (
'serviceWorker' in navigator &&
'PushManager' in window &&
+4
View File
@@ -5,6 +5,7 @@ import { createHash } from "crypto";
import { readFileSync } from "fs";
import { join } from "path";
import { pruneTraces } from "./scripts/standalone-guard.mjs";
import { desktopBuildFlag } from "./scripts/desktop-build-flag.mjs";
// Prefer an explicit build arg (passed in by CI / Docker, where .git is
// excluded from the build context) and fall back to `git rev-parse` for
@@ -193,6 +194,9 @@ const nextConfig: NextConfig = {
NEXT_PUBLIC_BASE_PATH: basePath,
NEXT_PUBLIC_DEV_MOCK_JMAP: process.env.DEV_MOCK_JMAP ?? "",
NEXT_PUBLIC_BULWARK_LITE: isLite ? "1" : "",
// Always a literal, so setting the variable when starting a server build
// does nothing (lib/desktop/flag.ts).
NEXT_PUBLIC_BULWARK_DESKTOP: desktopBuildFlag(),
},
};
+1
View File
@@ -25,6 +25,7 @@
"dev": "node scripts/build-pst-worker.mjs && node scripts/build-store-worker.mjs && node scripts/build-plugin-sandbox.mjs --watch -- next dev --turbopack",
"build": "node scripts/build-pst-worker.mjs && node scripts/build-store-worker.mjs && node scripts/build-plugin-sandbox.mjs && next build --turbopack && node scripts/standalone-guard.mjs",
"build:lite": "node scripts/lite/build.mjs",
"build:desktop": "node scripts/build-desktop.mjs",
"lite:serve": "node scripts/lite/serve.mjs out 4173",
"test:lite-smoke": "playwright test -c scripts/lite/smoke/playwright.config.ts",
"test:lite-smoke:stalwart": "playwright test -c scripts/lite/smoke/playwright.stalwart.config.ts",
+6
View File
@@ -16,6 +16,8 @@ import { getWopiEditorOrigins } from "./lib/wopi/discovery";
import { detectSetupState } from "./lib/setup/state";
import { isServedOverHttps } from "./lib/security/request-protocol";
import { bootGateResponse } from "./lib/shared-store/boot-gate";
import { IS_DESKTOP_BUILD } from "./lib/desktop/flag";
import { isDesktopBlockedRoute } from "./lib/desktop/blocked-routes";
const intlMiddleware = createIntlMiddleware(routing);
@@ -268,6 +270,10 @@ export async function proxy(request: NextRequest) {
return NextResponse.json({ error: "Cross-origin request rejected" }, { status: 403 });
}
if (IS_DESKTOP_BUILD && isDesktopBlockedRoute(pathname)) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (PROXY_SKIP_PATTERN.test(pathname) || isStaticAssetPath(pathname)) {
// No page CSP here, but what comes back can still be a document: the
// not-found shell for a missing /foo.html or an unknown /api route. It
+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) {