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"); +});