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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions scripts/build-desktop-artifact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,9 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
"@clerk/electron-passkeys": "0.0.3",
"@crowecawcaw/xa11y": "0.13.0",
"@napi-rs/keyring": "^1.3.0",
// #11720: dbus-next stays external so the lazy portal chunks never
// re-require the main-process entry.
"dbus-next": "0.10.2",
"ffi-rs": "1.3.2",
"playwright-core": "1.60.0",
},
Expand Down Expand Up @@ -739,6 +742,10 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
desktopDependencies: {
"@napi-rs/keyring": "1.3.0",
"playwright-core": "1.60.0",
// Regression coverage for #11720: dbus-next must stay external (and
// therefore staged) so the lazy portal chunks never re-require the
// main-process entry.
"dbus-next": "0.10.2",
},
arch: "arm64",
fffNodeVersion: "0.9.4",
Expand All @@ -749,6 +756,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
"node-pty": "1.1.0",
"@napi-rs/keyring": "1.3.0",
"playwright-core": "1.60.0",
"dbus-next": "0.10.2",
"@ff-labs/fff-bin-darwin-arm64": "0.9.4",
},
);
Expand Down
50 changes: 46 additions & 4 deletions scripts/build-desktop-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ import {
selectCliRuntimeExternalDependencies,
} from "./lib/cli-external-packages.ts";
import { loadRepoEnv } from "./lib/public-config.ts";
import { selectDesktopRuntimeExternalDependencies } from "./lib/desktop-external-packages.ts";
import {
findInlinedDesktopExternalPackages,
selectDesktopRuntimeExternalDependencies,
} from "./lib/desktop-external-packages.ts";
import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts";

import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
Expand Down Expand Up @@ -617,6 +620,15 @@ export class InlinedExternalPackageError extends Schema.TaggedError<InlinedExter
}
}

export class DesktopInlinedExternalPackageError extends Schema.TaggedError<DesktopInlinedExternalPackageError>()(
"DesktopInlinedExternalPackageError",
{ packages: Schema.Array(Schema.String) },
) {
override get message(): string {
return `The desktop main-process bundle inlined packages that must stay external: ${this.packages.join(", ")}. An inlined dbus-next makes the lazy PortalCaptureShortcut/NiriCaptureShortcut chunk require("./main.cjs"), which re-evaluates top-level runMain after Electron is ready and exits every Linux Wayland launch (#11720). Check the deps.neverBundle wiring in apps/desktop/vite.config.ts and DESKTOP_RUNTIME_EXTERNAL_PREFIXES in scripts/lib/desktop-external-packages.ts.`;
}
}

export class MissingDesktopBuildInputError extends Schema.TaggedError<MissingDesktopBuildInputError>()(
"MissingDesktopBuildInputError",
{
Expand Down Expand Up @@ -2516,9 +2528,9 @@ function validateBundledClientAssets(clientDir: string) {

// The main-process bundle inlines every JS dependency (see
// apps/desktop/vite.config.ts), so the packaged app only installs the packages
// that bundle leaves external: native addons and playwright-core. Everything
// else already lives inside dist-electron and would only duplicate what the
// server bundle carries too.
// that bundle leaves external: native addons, playwright-core, and dbus-next.
// Everything else already lives inside dist-electron and would only duplicate
// what the server bundle carries too.
export function resolveDesktopRuntimeDependencies(
dependencies: Record<string, string> | undefined,
catalog: Record<string, string>,
Expand Down Expand Up @@ -3510,6 +3522,36 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
}
}

// Same assert for the desktop main-process bundle, against the desktop
// externals list: the server scan above uses the CLI predicate, which does
// not know dbus-next, so an inlined dbus-next passed packaging silently and
// broke every Linux Wayland launch (#11720).
{
const chunkNames = (yield* fs.readDirectory(distDirs.desktopDist)).filter((entry) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium scripts/build-desktop-artifact.ts:3530

The assertion skips .cjs bundles in subdirectories, so an inline desktop-runtime external in workers such as electron/WindowsForegroundFocusWorker.cjs or snapShot/*.cjs passes validation and a broken packaged worker can ship. fs.readDirectory(distDirs.desktopDist) only returns direct entries; traverse dist-electron recursively or otherwise scan every emitted bundle file.

🤖 Copy this AI Prompt to have your agent fix this:
In file @scripts/build-desktop-artifact.ts around line 3530:

The assertion skips `.cjs` bundles in subdirectories, so an inline desktop-runtime external in workers such as `electron/WindowsForegroundFocusWorker.cjs` or `snapShot/*.cjs` passes validation and a broken packaged worker can ship. `fs.readDirectory(distDirs.desktopDist)` only returns direct entries; traverse `dist-electron` recursively or otherwise scan every emitted bundle file.

entry.endsWith(".cjs"),
);
let totalRegions = 0;
const inlined = new Set<string>();
for (const chunkName of chunkNames) {
const source = yield* fs.readFileString(path.join(distDirs.desktopDist, chunkName));
const scan = findInlinedDesktopExternalPackages(source);
totalRegions += scan.regionCount;
for (const name of scan.inlined) inlined.add(name);
}
if (inlined.size > 0) {
return yield* new DesktopInlinedExternalPackageError({
packages: [...inlined].sort(),
});
}
// No regions at all means the scan went blind (marker format changed), not
// that the bundle is clean.
if (totalRegions === 0) {
return yield* new DesktopInlinedExternalPackageError({
packages: ["<no module regions found; the bundle scan needs updating>"],
});
}
}

if (!(yield* fs.exists(bundledClientEntry))) {
return yield* new MissingDesktopBuildInputError({
artifact: "bundled-server-client",
Expand Down
105 changes: 105 additions & 0 deletions scripts/lib/desktop-external-packages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { assert, describe, it } from "@effect/vitest";

import {
findInlinedDesktopExternalPackages,
isDesktopRuntimeExternalDependency,
selectDesktopRuntimeExternalDependencies,
} from "./desktop-external-packages.ts";

describe("isDesktopRuntimeExternalDependency", () => {
it("bundles ordinary runtime dependencies", () => {
for (const id of ["effect", "@effect/platform", "electron-store"]) {
assert.strictEqual(isDesktopRuntimeExternalDependency(id), false, id);
}
});

it("leaves native addons and their dlopen wrappers external", () => {
for (const id of [
"@napi-rs/keyring",
"@crowecawcaw/xa11y",
"@clerk/electron-passkeys",
"ffi-rs",
"@yuuang/ffi-rs-win32-x64-msvc",
"playwright-core",
]) {
assert.strictEqual(isDesktopRuntimeExternalDependency(id), true, id);
}
});

// Regression test for #11720: an inlined dbus-next makes the lazy
// PortalCaptureShortcut/NiriCaptureShortcut chunk require("./main.cjs"),
// which re-evaluates top-level runMain after Electron is ready and exits
// every Linux Wayland launch with `registerSchemesAsPrivileged` throwing.
it("leaves dbus-next external, including subpath imports", () => {
for (const id of ["dbus-next", "dbus-next/lib/bus"]) {
assert.strictEqual(isDesktopRuntimeExternalDependency(id), true, id);
}
});
});

describe("selectDesktopRuntimeExternalDependencies", () => {
it("keeps only runtime-external dependency roots for the stage", () => {
assert.deepStrictEqual(
selectDesktopRuntimeExternalDependencies({
"dbus-next": "0.10.2",
effect: "3.0.0",
"@napi-rs/keyring": "1.3.0",
"playwright-core": "1.60.0",
}),
{
"dbus-next": "0.10.2",
"@napi-rs/keyring": "1.3.0",
"playwright-core": "1.60.0",
},
);
});
});

// Configuring the bundler is not the same as checking what it emitted. These
// exercise the scanner against the marker shape rolldown actually produces.
describe("findInlinedDesktopExternalPackages", () => {
const region = (path: string) => `//#region ${path}
var x = 1;
//#endregion
`;

it("flags an inlined dbus-next", () => {
const source =
region("../../node_modules/.pnpm/dbus-next@0.10.2/node_modules/dbus-next/lib/bus.js") +
region("../../node_modules/.pnpm/effect@4.0.0/node_modules/effect/dist/index.js");
const result = findInlinedDesktopExternalPackages(source);

assert.deepStrictEqual(result.inlined, ["dbus-next"]);
assert.strictEqual(result.regionCount, 2);
});

it("ignores packages that are meant to be bundled", () => {
const source =
region("../../node_modules/.pnpm/effect@4.0.0/node_modules/effect/dist/index.js") +
region("../../apps/desktop/src/main.ts");
const result = findInlinedDesktopExternalPackages(source);

assert.deepStrictEqual(result.inlined, []);
assert.strictEqual(result.regionCount, 2);
});

// regionCount is what separates "clean" from "this scan went blind because the
// marker format changed". A caller that ignores it gets a vacuous pass.
it("reports the packages that were inlined, not just the violations", () => {
const source =
region("../../node_modules/.pnpm/effect@4.0.0/node_modules/effect/dist/index.js") +
region("../../node_modules/.pnpm/sax@1.4.1/node_modules/sax/lib/sax.js");
const result = findInlinedDesktopExternalPackages(source);

assert.deepStrictEqual(result.inlinedPackages, ["effect", "sax"]);
assert.deepStrictEqual(result.inlined, []);
});

it("reports no regions when the marker format is absent", () => {
const result = findInlinedDesktopExternalPackages(
"var x = 1; // node_modules/dbus-next/lib.js",
);
assert.strictEqual(result.regionCount, 0);
assert.deepStrictEqual(result.inlined, []);
});
});
48 changes: 48 additions & 0 deletions scripts/lib/desktop-external-packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ export const DESKTOP_RUNTIME_EXTERNAL_PREFIXES = [
// at runtime and ships the browser driver alongside; there is nothing to
// gain from inlining a 10 MB file the code re-reads as text.
"playwright-core",
// dbus-next must stay a real node_modules package. Inlined, the bundler
// hoists its `sax` import into the main-process entry chunk, so the lazy
// PortalCaptureShortcut/NiriCaptureShortcut chunk ends up with
// `require("./main.cjs")`. On Linux Wayland portal sessions that chunk loads
// after Electron is ready, the entry re-evaluates top-level `runMain`, and
// `protocol.registerSchemesAsPrivileged` throws — every launch exits 1
// before a window appears (#11720).
"dbus-next",
] as const;

export function isDesktopRuntimeExternalDependency(id: string): boolean {
Expand All @@ -36,3 +44,43 @@ export function selectDesktopRuntimeExternalDependencies(
Object.entries(dependencies).filter(([name]) => isDesktopRuntimeExternalDependency(name)),
);
}

/**
* Scan an emitted `dist-electron` chunk for desktop runtime-external packages
* that were inlined.
*
* Same shape as `findInlinedExternalPackages` in cli-external-packages.ts, but
* against the desktop predicate: the artifact build scans `serverDist` with
* the CLI list, which does not know `dbus-next`, so an inlined dbus-next
* passed packaging silently and broke every Linux Wayland launch (#11720).
* `regionCount` and `inlinedPackages` carry the same blind-scan protection.
*/
export function findInlinedDesktopExternalPackages(source: string): {
readonly regionCount: number;
readonly inlined: ReadonlyArray<string>;
readonly inlinedPackages: ReadonlyArray<string>;
} {
// Rolldown marks each inlined module with a `//#region <path>` comment.
const regionPattern = /\/\/#region\s+(\S+)/g;
const packagePattern = /node_modules\/((?:@[^/\s]+\/)?[^/\s]+)\//g;

let regionCount = 0;
const inlined = new Set<string>();
const inlinedPackages = new Set<string>();
for (const region of source.matchAll(regionPattern)) {
regionCount += 1;
const regionPath = region[1] ?? "";
for (const candidate of regionPath.matchAll(packagePattern)) {
const name = candidate[1];
if (name === undefined || name === ".pnpm") continue;
inlinedPackages.add(name);
if (isDesktopRuntimeExternalDependency(name)) inlined.add(name);
}
}

return {
regionCount,
inlined: [...inlined].sort(),
inlinedPackages: [...inlinedPackages].sort(),
};
}
Loading