From f4861d76ee52fb0acfe84e286bdc74ab75b74b03 Mon Sep 17 00:00:00 2001 From: Jeff Hedlund Date: Sat, 5 Sep 2026 11:04:06 -0400 Subject: [PATCH] fix(desktop): keep packaged frontendDist relative so Windows embeds assets (#7177) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Root cause `tauri-command.mjs` points `frontendDist` at a `mkdtemp` directory so concurrent OSS/internal packages cannot overwrite each other's assets. On Windows that is an absolute path with a drive letter. `FrontendDist` is an untagged serde enum whose **first** variant is `Url(Url)`, and `C:\Users\...` is a valid WHATWG URL with scheme `c:`, so serde selects `Url`. `tauri-codegen` then does: FrontendDist::Url(_url) => Default::default(), // embed nothing A missing *directory* panics with a clear message; a URL is silent. The build exits 0 and produces an installable app with no frontend assets, which boots to `ERR_FILE_NOT_FOUND` in the WebView. Linux and macOS are unaffected — `/tmp/...` has no scheme, so it falls through to `Directory`. This affects every Windows build that goes through `pnpm tauri build`, including `release.yml`'s NSIS job and `windows-canary.yml`. ## Fix Pass the path relative to the config's own directory. `tauri-codegen` resolves `frontendDist` with `config_parent.join(path)`, so a relative path reaches the same directory and cannot parse as a URL. When the temp directory is on another drive there is no relative form, so the scratch root is created beside the config instead. `BUZZ_PROTECTED_BUILD_OUTPUT` still receives the absolute path, and cleanup is unchanged. ## Testing `tauriCommand.test.mjs` asserted against the value it had just been handed, so it could not observe this. Its fake CLI also resolved `frontendDist` against the process cwd, which is not what Tauri does. - Fake CLI now resolves against the config directory, matching `config_parent.join(path)`. - New case asserts the packaged `frontendDist` is not absolute and does not parse as a URL. The absolute check is what fails on Linux/macOS, so the regression stays covered on every platform. - Verified the new case fails on the unpatched wrapper and passes with the fix; the two existing cases pass either way. - Desktop suite: 5844 passed. `useDocumentVisible` has a pre-existing load-dependent flake that also reproduces on an unmodified checkout. - Biome check clean on both files. Verified end to end by rebuilding the Windows NSIS installer: embedded asset keys in `buzz-desktop.exe` went from 0 to 490, and the app launches. --------- Signed-off-by: Jeff Hedlund Co-authored-by: Claude Opus 5 (1M context) --- desktop/scripts/tauri-command.mjs | 21 ++++++++- .../protectedFeatures/tauriCommand.test.mjs | 43 ++++++++++++++++--- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/desktop/scripts/tauri-command.mjs b/desktop/scripts/tauri-command.mjs index dc1d8691e96..bc53d607e72 100644 --- a/desktop/scripts/tauri-command.mjs +++ b/desktop/scripts/tauri-command.mjs @@ -36,11 +36,28 @@ export function runTauriCommand(args) { // Tauri runs beforeBuildCommand and then consumes frontendDist. Give the // entire invocation a private directory so concurrent OSS/internal packages // cannot replace one another's assets between those two operations. - const invocationRoot = mkdtempSync( + let invocationRoot = mkdtempSync( path.join(tmpdir(), "buzz-tauri-package-assets-"), ); + // `frontendDist` deserializes into an untagged enum whose first variant is a + // URL, and a Windows absolute path parses as one -- `C:` becomes the scheme. + // Tauri then embeds zero assets, exits 0, and the app boots to + // ERR_FILE_NOT_FOUND. Hand it a path relative to the config's own directory, + // which can never parse as a URL. If the temp dir is on another drive there + // is no relative form, so put the scratch root beside the config instead. + const configDir = path.join(desktopRoot, "src-tauri"); + const relativeTo = (root) => + path.relative(configDir, path.join(root, "dist")); + if (path.isAbsolute(relativeTo(invocationRoot))) { + rmSync(invocationRoot, { recursive: true, force: true }); + invocationRoot = mkdtempSync( + path.join(desktopRoot, ".buzz-tauri-package-assets-"), + ); + } const frontendDist = path.join(invocationRoot, "dist"); - const outputOverride = JSON.stringify({ build: { frontendDist } }); + const outputOverride = JSON.stringify({ + build: { frontendDist: relativeTo(invocationRoot) }, + }); try { const delimiterIndex = args.indexOf("--"); diff --git a/desktop/src/protectedFeatures/tauriCommand.test.mjs b/desktop/src/protectedFeatures/tauriCommand.test.mjs index e3e1532af45..0902be92b4f 100644 --- a/desktop/src/protectedFeatures/tauriCommand.test.mjs +++ b/desktop/src/protectedFeatures/tauriCommand.test.mjs @@ -10,6 +10,7 @@ const desktopRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "../..", ); +const configDir = path.join(desktopRoot, "src-tauri"); const wrapper = path.join(desktopRoot, "scripts/tauri-command.mjs"); const fakeCli = path.join(tmpdir(), `buzz-fake-tauri-${process.pid}.mjs`); @@ -20,14 +21,21 @@ import path from "node:path"; const args = process.argv.slice(2); const configIndex = args.lastIndexOf("--config"); const override = JSON.parse(args[configIndex + 1]); -const output = override.build.frontendDist; -mkdirSync(output, { recursive: true }); -writeFileSync(path.join(output, "variant.txt"), process.env.VITE_BUZZ_BESTIE); +const configured = override.build.frontendDist; +// Tauri resolves frontendDist against the directory holding tauri.conf.json +// (config_parent.join(path) in tauri-codegen), not against the process cwd. +const output = path.resolve(process.env.BUZZ_TEST_CONFIG_DIR, configured); +// Write through the producer path the wrapper publishes and read back through +// the config-resolved consumer path. Doing both against one path would make the +// fake agree with itself no matter where the wrapper pointed frontendDist. +const producer = process.env.BUZZ_PROTECTED_BUILD_OUTPUT; +mkdirSync(producer, { recursive: true }); +writeFileSync(path.join(producer, "variant.txt"), process.env.VITE_BUZZ_BESTIE); await new Promise((resolve) => setTimeout(resolve, 100)); const observed = readFileSync(path.join(output, "variant.txt"), "utf8"); writeFileSync( process.env.BUZZ_TEST_RESULT, - JSON.stringify({ args, output, observed }), + JSON.stringify({ args, configured, output, observed }), ); `, ); @@ -42,6 +50,7 @@ function packageVariant(variant, result, runnerArguments = []) { env: { ...process.env, BUZZ_TAURI_CLI_ENTRYPOINT: fakeCli, + BUZZ_TEST_CONFIG_DIR: configDir, BUZZ_TEST_RESULT: result, VITE_BUZZ_BESTIE: variant, }, @@ -92,6 +101,30 @@ test("private config precedes Cargo runner arguments", async () => { assert.equal(invocation.args[delimiterIndex + 1], "--locked"); assert.equal( JSON.parse(invocation.args[privateConfigIndex + 1]).build.frontendDist, - invocation.output, + invocation.configured, ); }); + +test("private frontendDist is never mistaken for a URL", async () => { + const result = path.join( + tmpdir(), + `buzz-tauri-frontend-dist-${process.pid}.json`, + ); + await packageVariant("0", result); + const invocation = JSON.parse(readFileSync(result, "utf8")); + + // `FrontendDist` is an untagged enum whose first variant is `Url(Url)`, and + // tauri-codegen embeds *no assets without erroring* for that variant. A + // Windows absolute path parses as a URL -- `C:` becomes the scheme -- so an + // absolute frontendDist produces a UI-less app that still exits 0. + assert.ok( + !path.isAbsolute(invocation.configured), + `frontendDist must stay relative, got ${invocation.configured}`, + ); + // Rust's `url` crate and Node's `URL` both implement the WHATWG standard, so + // this is the same parse serde performs. It only rejects absolute paths on + // Windows, which is why the assertion above carries the check on Linux/macOS. + assert.throws(() => new URL(invocation.configured)); + // The relative path still has to reach the directory the wrapper published. + assert.equal(invocation.observed, "0"); +});