mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-31 18:26:51 +00:00
feat(desktop-shell): restore the brand builder skill for the Tauri shell (#10164)
* feat(desktop-shell): restore the brand builder skill for the Tauri shell The Electron-era desktop-brand-builder skill was removed with packages/desktop in #9085, leaving the Tauri shell without a white-label customization path. Restore it against the new branding hooks: tauri.conf.json, the icon set, and the bootstrap UI. The bundled script is dependency-free Node, regenerates icons via tauri icon, and empties updater endpoints so branded builds never touch the official feed. * fix(desktop-shell): give brand builder scripts node globals for eslint The desktop-brand-builder skill script runs under node but lives outside every existing node-scripts glob in eslint.config.js, so lint:ci failed with 7 no-undef errors on process/console/URL in brand-create.mjs. Add the skill scripts path to that existing block. Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com> * fix(desktop-shell): complete updater identity patching and fix npx spawn on Windows - When branded build has no custom updater endpoints, also clear the official pubkey and set bundle.createUpdaterArtifacts to false so the Tauri bundler does not produce signed updater artifacts that no feed will serve. - Add shell: true to the npx spawnSync call so it works on Windows where npx is npx.cmd. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(desktop-shell): address review findings for brand builder skill - Add brand-builds/ to .gitignore (R1-19) - Validate logo is a file, not a directory (R1-8) - Document target-specific bundle path in SKILL.md (R1-17) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(desktop-shell): harden brand-create against injection and shell safety - Use function replacers in replaceAll to prevent $& pattern interpretation when appName contains dollar signs (R1-3) - Quote logo path in shell spawn to handle paths with spaces (R1-4) - Add image extension validation for logo files (suggestion R1-6) - Document silent config mutations in SKILL.md (suggestion R1-5) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(desktop-shell): document cross-compile workflow and guard against re-run - SKILL.md: document that build:runtime must be re-run with QWEN_DESKTOP_TARGET set before each cross-compile target, otherwise the packaged artifact contains a wrong-arch Node binary. - brand-create.mjs: detect already-branded shell-root (productName no longer 'Qwen Code Desktop') and fail early with a clear message, preventing silent stale results from non-idempotent re-runs. - SKILL.md: add explicit 'never re-run in the same clone' warning to Failure Handling section. Addresses review findings R1-16 (cross-compile docs) and idempotency guard from qwen-code-ci-bot. Co-authored-by: Qwen Code <noreply@alibaba-inc.com> * fix(desktop-shell): harden brand-create against updater crash, icon misreport, and JS injection - Keep updater pubkey as empty string instead of deleting it (the tauri-plugin-updater schema requires `pubkey: String` with no default; removing it causes deserialization failure at startup). - Report accurate icon replacement status when `tauri icon` fails on non-PNG logos (previously claimed icon.png was replaced even when it was not). - Escape single quotes in appName before injecting into bootstrap.js single-quoted string literals (an appName like "Bob's App" caused SyntaxError). - Add root `npm install` step to SKILL.md workflow (build:runtime shells out to repo root which needs cross-env and other root devDependencies). Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(desktop-shell): eliminate shell injection in brand icon generation (R1-4) Replace shell:true npx spawn with direct Node invocation of the Tauri CLI entry point. The logo path is now passed as a plain argv element, preventing command substitution via crafted filenames ($(cmd), `cmd`). Falls back to shell-less npx when @tauri-apps/cli is not resolvable from the shell root. Adds a test verifying generateIcons contains no shell:true usage. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(desktop-shell): require updaterPubkey when custom updater feed is configured (R1-1) When a brand supplies updaterEndpoints, the matching signing pubkey must also be provided. Without this check, custom feeds were paired with the official pubkey, breaking update verification permanently. loadConfig now fails fast when updaterEndpoints is non-empty but updaterPubkey is missing. patchTauriConfig sets the pubkey from the brand config when endpoints are present. SKILL.md documents updaterPubkey and TAURI_SIGNING_PRIVATE_KEY usage. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(desktop-shell): reject appName matching pristine default to close re-run guard bypass (R3-4) When appName equals 'Qwen Code Desktop' (either explicitly or derived from brandId like 'qwen-code-desktop'), the detectAlreadyBranded guard never fires on the first run, allowing a second run to double-patch bootstrap strings. loadConfig now rejects this appName at validation time, before any files are mutated. Covers both explicit override and derived paths. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(desktop-shell): silence empty-block lint in brand-create-safety test Add /* best-effort cleanup */ comments inside two catch blocks used for teardown so ESLint no-empty rule is satisfied. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(desktop-shell): escape backslashes and newlines in appName for bootstrap.js injection brand-create.mjs only escaped single quotes before splicing appName into the single-quoted string literals of bootstrap/bootstrap.js. A free-form appName ending in a backslash (or containing a newline) produced syntactically invalid generated bootstrap.js while the script still exited 0 reporting bootstrapPatched. Derive the injected content via JSON.stringify so backslashes, newlines, and control characters are all escaped, then re-quote it for the single-quoted context. Add a safety test covering hostile appName values. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(desktop-shell): HTML-escape appName in bootstrap/index.html, fail closed on missing updater section (R5-1, R5-8) R5-1: appName was spliced raw into bootstrap/index.html (<title>, alt attribute, <h1>, <h2>), so free-form brand names with <, > or " produced broken markup (and a </title> breakout). The index.html path now uses an HTML-escaped form while bootstrap.js keeps its JS-literal escaping. R5-8: when the target shell-root's tauri.conf.json has no plugins.updater section, the brand's validated updaterEndpoints/updaterPubkey were silently discarded while brand-create exited 0 reporting them as applied. Fail closed before any file is written, leaving the config unmutated. Tests: hostile-appName index.html fixture assertions (escaped output, no raw breakout) and a no-plugins.updater shell-root case (non-zero exit, stderr names the missing section, config byte-identical). Both new assertions go red when the corresponding fix is removed. Suite 8/8 green; prettier + eslint clean on both changed files. Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com> * fix(desktop-shell): fail on non-array updaterEndpoints in brand.json A scalar updaterEndpoints (e.g. a string typo of the JSON-array shape documented in SKILL.md) was silently coerced to [] before the updater-safety guards ran, so brand-create exited 0 while shipping the brand with in-app updates permanently disabled. Reject any non-array value with an explicit error naming the expected shape; omitted or [] still disables updates as documented. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Co-authored-by: Qwen Code <noreply@alibaba-inc.com> Co-authored-by: root <root@vscode-sqlx011163220057.na131>
This commit is contained in:
parent
ff1b691bd7
commit
a511ce4a12
5 changed files with 934 additions and 0 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -139,3 +139,6 @@ tmp/
|
|||
# Auto-generated computer-use marker can also appear under nested packages.
|
||||
**/.qwen/computer-use/
|
||||
.playwright-mcp/
|
||||
|
||||
# Brand build workspaces (created by the desktop-brand-builder skill)
|
||||
brand-builds/
|
||||
|
|
|
|||
|
|
@ -417,6 +417,8 @@ export default tseslint.config(
|
|||
'docs/**/*.mjs',
|
||||
// Plan C CDP-tunnel acceptance harness (issue #5626) runs with `node`.
|
||||
'packages/cli/src/serve/cdp-tunnel/acceptance/**/*.mjs',
|
||||
// Desktop-shell skill helper scripts also run with `node`.
|
||||
'packages/desktop-shell/.agents/skills/**/scripts/**/*.mjs',
|
||||
],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,222 @@
|
|||
---
|
||||
name: desktop-brand-builder
|
||||
description: Generate a branded Qwen Code desktop package from the Tauri desktop shell using a minimal brandId and logo. Use when the user wants a custom, white-label, or rebranded desktop client, installer, DMG/EXE/AppImage/deb, or one-click brand build on top of packages/desktop-shell.
|
||||
---
|
||||
|
||||
# Desktop Brand Builder (Tauri shell)
|
||||
|
||||
## Goal
|
||||
|
||||
Create a branded desktop package from `packages/desktop-shell` with the least
|
||||
user input possible. The user should usually provide only:
|
||||
|
||||
```text
|
||||
brandId: acme-ai
|
||||
logo: /absolute/path/to/logo.png
|
||||
website: https://acme.ai
|
||||
```
|
||||
|
||||
`website` is optional. Do not ask for app name, app id, artifact name,
|
||||
copyright, or updater endpoints unless the user explicitly asks to override
|
||||
them.
|
||||
|
||||
This skill replaces the Electron-era brand builder that lived in the removed
|
||||
`packages/desktop`. The Tauri shell is the only desktop implementation now;
|
||||
branding hooks are `src-tauri/tauri.conf.json`, `src-tauri/icons/`, and the
|
||||
`bootstrap/` startup UI.
|
||||
|
||||
## Input Rules
|
||||
|
||||
Required fields:
|
||||
|
||||
- `brandId`: must match `^[a-z][a-z0-9-]*$`
|
||||
- `logo`: local file path; must exist; `.png` recommended (square, >= 1024px)
|
||||
|
||||
Optional overrides:
|
||||
|
||||
- `website`
|
||||
- `appName`
|
||||
- `appId` (Tauri bundle identifier)
|
||||
- `artifactPrefix`
|
||||
- `updaterEndpoints` (JSON array; empty array disables in-app updates)
|
||||
- `updaterPubkey` (base64 public key; **required** when `updaterEndpoints`
|
||||
is non-empty — must match the `TAURI_SIGNING_PRIVATE_KEY` used to sign
|
||||
your updater artifacts)
|
||||
- `target`: `mac`, `win`, `linux`, or `all`
|
||||
|
||||
If required input is missing, ask once:
|
||||
|
||||
```text
|
||||
请提供:
|
||||
brandId: 例如 acme-ai,只能小写字母、数字、短横线
|
||||
logo: 本地 logo 文件路径(建议 1024x1024 PNG)
|
||||
website: 可选
|
||||
```
|
||||
|
||||
Once the required fields are present, proceed without a confirmation step.
|
||||
|
||||
## Derived Defaults
|
||||
|
||||
Infer missing values deterministically:
|
||||
|
||||
- `appName`: title-case the hyphen-separated `brandId`; `acme-ai` becomes
|
||||
`Acme AI`
|
||||
- `artifactPrefix`: title-case the hyphen-separated `brandId` and join with
|
||||
hyphens; `acme-ai` becomes `Acme-AI`
|
||||
- `appId`: if `website` has a valid host, reverse the host labels and append
|
||||
`.desktop`; `https://acme.ai` becomes `ai.acme.desktop`
|
||||
- fallback `appId`: `app.<brandId>.desktop`
|
||||
- `updaterEndpoints`: empty by default. A branded build must never poll the
|
||||
official Qwen Code updater feed, and the official feed must never update a
|
||||
branded build. Only set endpoints when the user supplies their own feed.
|
||||
|
||||
## Workflow
|
||||
|
||||
Work in an isolated build clone so the working repository stays clean:
|
||||
|
||||
```bash
|
||||
BUILD_ROOT="$PWD/brand-builds/<brandId>-<timestamp>"
|
||||
mkdir -p "$BUILD_ROOT"
|
||||
git clone --branch main --single-branch \
|
||||
https://github.com/QwenLM/qwen-code.git \
|
||||
"$BUILD_ROOT/qwen-code"
|
||||
cd "$BUILD_ROOT/qwen-code"
|
||||
git checkout -B brand-<brandId> origin/main
|
||||
```
|
||||
|
||||
If the clone or checkout fails, stop and report the failure. Do not continue
|
||||
as if `brand-<brandId>` was created.
|
||||
|
||||
Create a temporary `brand.json` in the build directory:
|
||||
|
||||
```json
|
||||
{
|
||||
"brandId": "acme-ai",
|
||||
"logo": "/absolute/path/to/logo.png",
|
||||
"website": "https://acme.ai",
|
||||
"appName": "Acme AI",
|
||||
"appId": "ai.acme.desktop",
|
||||
"artifactPrefix": "Acme-AI",
|
||||
"updaterEndpoints": [],
|
||||
"updaterPubkey": ""
|
||||
}
|
||||
```
|
||||
|
||||
Install dependencies. The brand script itself only needs desktop-shell's
|
||||
own `node_modules`, but `npm run build:runtime` shells out to the repo
|
||||
root (which uses `cross-env` and other root devDependencies), so the
|
||||
root install is also required before packaging:
|
||||
|
||||
```bash
|
||||
# Root dependencies (needed by build:runtime → cross-env, esbuild, etc.)
|
||||
npm install
|
||||
|
||||
# Desktop-shell dependencies
|
||||
cd packages/desktop-shell
|
||||
npm install --workspaces=false
|
||||
cd ../..
|
||||
```
|
||||
|
||||
Then run this skill's bundled brand creation script with plain Node (the
|
||||
script has no dependencies beyond Node >= 18):
|
||||
|
||||
```bash
|
||||
node packages/desktop-shell/.agents/skills/desktop-brand-builder/scripts/brand-create.mjs \
|
||||
--shell-root /absolute/path/to/qwen-code/packages/desktop-shell \
|
||||
--config /absolute/path/to/brand.json
|
||||
```
|
||||
|
||||
The agent should not hand-edit `tauri.conf.json`, icon files, or bootstrap
|
||||
brand strings when this bundled script is available. The bundled script is the
|
||||
source of truth for patching config and generating resources.
|
||||
|
||||
What the script does:
|
||||
|
||||
1. Patches `src-tauri/tauri.conf.json`: `productName`, `identifier`,
|
||||
`bundle.shortDescription`, and `plugins.updater.endpoints`. When
|
||||
`updaterEndpoints` is empty it also clears `bundle.createUpdaterArtifacts`
|
||||
and blanks the official `plugins.updater.pubkey` (set to empty string
|
||||
rather than deleted, because the updater plugin requires the field);
|
||||
a brand supplying its own feed must supply its own pubkey.
|
||||
2. Regenerates the full icon set from the logo via
|
||||
`npx --yes @tauri-apps/cli icon <logo>` (falls back to a warning if the
|
||||
CLI cannot run; in that case copy the logo over `src-tauri/icons/icon.png`
|
||||
manually and tell the user the remaining sizes are stale).
|
||||
3. Patches the bootstrap UI: page title, brand heading, startup strings in
|
||||
`bootstrap/index.html` and `bootstrap/bootstrap.js`, and replaces
|
||||
`bootstrap/qwen-code-logo.svg` usage with the brand logo.
|
||||
|
||||
Package with the current host target unless the user requested a target:
|
||||
|
||||
```bash
|
||||
cd packages/desktop-shell
|
||||
npm run build:runtime --workspaces=false
|
||||
npx tauri build # current platform
|
||||
```
|
||||
|
||||
**Cross-compile:** `build:runtime` bundles the Node runtime for the platform
|
||||
indicated by `QWEN_DESKTOP_TARGET` (defaults to the host). When targeting a
|
||||
different platform you **must** re-run `build:runtime` with the env var set
|
||||
before each `tauri build --target`, otherwise the packaged artifact contains
|
||||
a wrong-arch Node binary and fails at launch with an exec format error:
|
||||
|
||||
```bash
|
||||
# Cross-compile: set QWEN_DESKTOP_TARGET and re-run build:runtime per target
|
||||
QWEN_DESKTOP_TARGET=aarch64-apple-darwin npm run build:runtime --workspaces=false
|
||||
npx tauri build --target aarch64-apple-darwin # explicit macOS arm64
|
||||
```
|
||||
|
||||
For `target: all`, iterate `build:runtime` → `tauri build` per target; run
|
||||
only targets supported by the current machine or CI environment. Do not
|
||||
claim cross-platform artifacts were produced unless the files exist.
|
||||
Artifacts land under `packages/desktop-shell/src-tauri/target/release/bundle/`
|
||||
for the host target, or `src-tauri/target/<triple>/release/bundle/` when
|
||||
`--target <triple>` is used.
|
||||
|
||||
## Signing and Updates
|
||||
|
||||
Branded builds are unsigned by default. The upstream release pipeline's
|
||||
signing secrets (Apple, Windows) and updater private key belong to the
|
||||
official Qwen Code releases only. For a brand that needs signed releases or
|
||||
in-app updates, set up separate credentials and a separate updater feed; do
|
||||
not reuse the upstream ones.
|
||||
|
||||
To generate a signing key pair for your updater feed:
|
||||
|
||||
```bash
|
||||
npx @tauri-apps/cli signer generate -w ~/.tauri/my-brand.key
|
||||
# The .key file is the private key (set as TAURI_SIGNING_PRIVATE_KEY in
|
||||
# your build CI). The corresponding .pub file contains the base64 public
|
||||
# key — paste it into brand.json as updaterPubkey.
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
After packaging:
|
||||
|
||||
1. Confirm the expected artifact exists under
|
||||
`packages/desktop-shell/src-tauri/target/release/bundle/`
|
||||
(or `src-tauri/target/<triple>/release/bundle/` for cross-compile targets)
|
||||
(`dmg/`, `nsis/`, `appimage/`, or `deb/`).
|
||||
2. Compute `sha256sum` or `shasum -a 256` for each artifact.
|
||||
3. On macOS, run `hdiutil verify` for generated DMG files.
|
||||
4. Report the artifact path, SHA-256, app name, app id, and build directory.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
- Invalid `brandId`: show the regex and ask for a corrected value.
|
||||
- Missing `logo`: ask for a valid local path.
|
||||
- Missing bundled script: report that
|
||||
`packages/desktop-shell/.agents/skills/desktop-brand-builder/scripts/brand-create.mjs`
|
||||
is missing, and include the expected command.
|
||||
- Already-branded shell-root: the script refuses to run when
|
||||
`productName` is no longer the default (`Qwen Code Desktop`). Start from
|
||||
a fresh clone — do not re-run the script in an already-patched tree.
|
||||
- Build failure: preserve the build directory, return the last useful error
|
||||
lines, and include the full log path or command that produced the failure.
|
||||
|
||||
Do not delete the build directory on failure. **Never re-run `brand-create`
|
||||
in the same clone** — the script is single-use. The "preserve the build
|
||||
directory" guidance is for post-mortem debugging, not for retrying the
|
||||
brand step. If the brand config was wrong, discard the clone and start
|
||||
fresh.
|
||||
|
|
@ -0,0 +1,377 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Brand creation script for the Tauri desktop shell.
|
||||
*
|
||||
* Patches packages/desktop-shell so a branded desktop app can be built from
|
||||
* a minimal brand.json. Replaces the Electron-era brand-create.ts that was
|
||||
* removed together with packages/desktop.
|
||||
*
|
||||
* Usage:
|
||||
* node brand-create.mjs --shell-root /path/to/packages/desktop-shell \
|
||||
* --config /path/to/brand.json
|
||||
*
|
||||
* Requires Node >= 18. No external dependencies.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { extname, join, resolve } from 'node:path';
|
||||
|
||||
const BRAND_ID_RE = /^[a-z][a-z0-9-]*$/;
|
||||
const USAGE =
|
||||
'Usage: node brand-create.mjs --shell-root /path/to/packages/desktop-shell --config /path/to/brand.json';
|
||||
|
||||
function argValue(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(`brand-create: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function shellRootFromArgs() {
|
||||
const value = argValue('--shell-root');
|
||||
if (!value) fail(USAGE);
|
||||
const shellRoot = resolve(value);
|
||||
if (!existsSync(join(shellRoot, 'src-tauri', 'tauri.conf.json'))) {
|
||||
fail(`desktop-shell package not found: ${shellRoot}`);
|
||||
}
|
||||
return shellRoot;
|
||||
}
|
||||
|
||||
// Common acronyms that should be fully capitalized in derived names.
|
||||
const ACRONYMS = new Set(['ai', 'api', 'cli', 'ide', 'sdk', 'ui', 'url']);
|
||||
|
||||
function titleWords(brandId) {
|
||||
return brandId
|
||||
.split('-')
|
||||
.filter(Boolean)
|
||||
.map((part) =>
|
||||
ACRONYMS.has(part)
|
||||
? part.toUpperCase()
|
||||
: part[0].toUpperCase() + part.slice(1),
|
||||
);
|
||||
}
|
||||
|
||||
function deriveAppId(website, brandId) {
|
||||
if (website) {
|
||||
try {
|
||||
const withProtocol = website.includes('://')
|
||||
? website
|
||||
: `https://${website}`;
|
||||
const host = new URL(withProtocol).hostname.replace(/^www\./, '');
|
||||
const parts = host.split('.').filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
return `${parts.reverse().join('.')}.desktop`;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the deterministic fallback.
|
||||
}
|
||||
}
|
||||
return `app.${brandId}.desktop`;
|
||||
}
|
||||
|
||||
function loadConfig(path) {
|
||||
let input;
|
||||
try {
|
||||
input = JSON.parse(readFileSync(path, 'utf8'));
|
||||
} catch (error) {
|
||||
fail(`cannot read brand config ${path}: ${error.message}`);
|
||||
}
|
||||
|
||||
const brandId = input.brandId?.trim();
|
||||
const logo = input.logo ? resolve(input.logo) : undefined;
|
||||
|
||||
if (!brandId || !BRAND_ID_RE.test(brandId)) {
|
||||
fail(`brandId must match ${BRAND_ID_RE}`);
|
||||
}
|
||||
if (!logo || !existsSync(logo) || !statSync(logo).isFile()) {
|
||||
fail(`logo must be an existing file path, got: ${input.logo}`);
|
||||
}
|
||||
const IMAGE_EXTS = new Set([
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.svg',
|
||||
'.ico',
|
||||
'.webp',
|
||||
]);
|
||||
const logoExt = extname(logo).toLowerCase();
|
||||
if (!IMAGE_EXTS.has(logoExt)) {
|
||||
fail(
|
||||
`logo must be an image file (.png/.jpg/.jpeg/.svg/.ico/.webp), got: ${input.logo}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
input.updaterEndpoints !== undefined &&
|
||||
!Array.isArray(input.updaterEndpoints)
|
||||
) {
|
||||
fail(
|
||||
'updaterEndpoints must be a JSON array of endpoint URLs ' +
|
||||
'(omit it or use [] to disable in-app updates), got: ' +
|
||||
JSON.stringify(input.updaterEndpoints),
|
||||
);
|
||||
}
|
||||
const updaterEndpoints = input.updaterEndpoints ?? [];
|
||||
const updaterPubkey = input.updaterPubkey?.trim() || undefined;
|
||||
|
||||
// When a brand supplies its own updater feed it MUST also supply the
|
||||
// matching signing pubkey. Using the official pubkey with a custom feed
|
||||
// (or no pubkey at all) would silently break in-app updates: the
|
||||
// updater verifies signatures against this key, so a mismatch means
|
||||
// every update check fails verification and the app can never update.
|
||||
if (updaterEndpoints.length > 0 && !updaterPubkey) {
|
||||
fail(
|
||||
'updaterEndpoints is non-empty but updaterPubkey is missing. ' +
|
||||
'Provide the base64 public key that matches your ' +
|
||||
'TAURI_SIGNING_PRIVATE_KEY so the updater can verify your feed.',
|
||||
);
|
||||
}
|
||||
|
||||
const words = titleWords(brandId);
|
||||
const appName = input.appName?.trim() || words.join(' ');
|
||||
|
||||
// The single-use guard (detectAlreadyBranded) keys on productName
|
||||
// changing away from the pristine default. If the brand's appName
|
||||
// equals that default, the guard never fires on run 1, so run 2
|
||||
// proceeds and doubles brand strings in bootstrap files. Reject this
|
||||
// at load time so the error is clear and no files are mutated.
|
||||
if (appName === 'Qwen Code Desktop') {
|
||||
fail(
|
||||
'appName must not be "Qwen Code Desktop" — that is the pristine ' +
|
||||
'shell default and would defeat the re-run guard. Choose a ' +
|
||||
'distinct name or use a different brandId.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
brandId,
|
||||
logo,
|
||||
website: input.website?.trim() || undefined,
|
||||
appName,
|
||||
appId: input.appId?.trim() || deriveAppId(input.website, brandId),
|
||||
artifactPrefix: input.artifactPrefix?.trim() || words.join('-'),
|
||||
updaterEndpoints,
|
||||
updaterPubkey,
|
||||
};
|
||||
}
|
||||
|
||||
function patchTauriConfig(shellRoot, brand) {
|
||||
const configPath = join(shellRoot, 'src-tauri', 'tauri.conf.json');
|
||||
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
||||
|
||||
config.productName = brand.appName;
|
||||
config.identifier = brand.appId;
|
||||
if (config.bundle) {
|
||||
config.bundle.shortDescription = `${brand.appName} desktop shell for the Qwen Code Web Shell`;
|
||||
}
|
||||
// A brand that supplies its own updater feed (or pubkey) needs a
|
||||
// plugins.updater section to apply it to. The pristine in-repo shell
|
||||
// always ships one, but a fork or hand-edited shell-root may not;
|
||||
// silently discarding the validated updater config while the success
|
||||
// JSON reports it as applied would ship a branded build that can
|
||||
// never update. Fail closed before any file is written.
|
||||
if (
|
||||
(brand.updaterEndpoints.length > 0 || brand.updaterPubkey) &&
|
||||
!config.plugins?.updater
|
||||
) {
|
||||
fail(
|
||||
'brand supplies updater config but the target tauri.conf.json has ' +
|
||||
'no plugins.updater section to apply it to',
|
||||
);
|
||||
}
|
||||
// A branded build must never poll the official updater feed, and the
|
||||
// official feed must never update a branded build. Empty endpoints
|
||||
// disable in-app updates unless the brand supplies its own feed.
|
||||
// When endpoints are cleared we must also disable createUpdaterArtifacts
|
||||
// and remove the official pubkey so the Tauri bundler does not attempt
|
||||
// to produce signed updater artifacts that no feed will serve.
|
||||
if (config.plugins?.updater) {
|
||||
config.plugins.updater.endpoints = brand.updaterEndpoints;
|
||||
if (brand.updaterEndpoints.length === 0) {
|
||||
// Keep pubkey as an empty string rather than deleting it: the
|
||||
// tauri-plugin-updater schema declares `pubkey: String` with no
|
||||
// serde default, so removing the field causes deserialization to
|
||||
// fail at startup. An empty string is harmless when endpoints is
|
||||
// also empty (no update check will run).
|
||||
config.plugins.updater.pubkey = '';
|
||||
} else if (brand.updaterPubkey) {
|
||||
// A brand supplying its own feed must pair it with the matching
|
||||
// signing pubkey. loadConfig enforces this invariant.
|
||||
config.plugins.updater.pubkey = brand.updaterPubkey;
|
||||
}
|
||||
}
|
||||
if (brand.updaterEndpoints.length === 0 && config.bundle) {
|
||||
config.bundle.createUpdaterArtifacts = false;
|
||||
}
|
||||
|
||||
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
||||
return configPath;
|
||||
}
|
||||
|
||||
function resolveTauriCli(shellRoot) {
|
||||
// Resolve the Tauri CLI entry point directly so the logo path never
|
||||
// passes through a command interpreter. This eliminates shell injection
|
||||
// via crafted filenames (e.g. $(cmd) or `cmd` in the path).
|
||||
try {
|
||||
const require = createRequire(join(shellRoot, 'package.json'));
|
||||
return require.resolve('@tauri-apps/cli/tauri.js');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function generateIcons(shellRoot, brand) {
|
||||
// Invoke the Tauri CLI directly via Node so the logo path is passed as
|
||||
// a plain argv element — no shell interpretation, no quoting games.
|
||||
const tauriCli = resolveTauriCli(shellRoot);
|
||||
const result = tauriCli
|
||||
? spawnSync(process.execPath, [tauriCli, 'icon', brand.logo], {
|
||||
cwd: shellRoot,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
: spawnSync('npx', ['--yes', '@tauri-apps/cli', 'icon', brand.logo], {
|
||||
cwd: shellRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
if (result.status === 0) {
|
||||
return 'regenerated via tauri icon';
|
||||
}
|
||||
// Fallback: keep the build moving but report what actually happened.
|
||||
const logoExt = extname(brand.logo).toLowerCase();
|
||||
if (logoExt === '.png') {
|
||||
copyFileSync(brand.logo, join(shellRoot, 'src-tauri', 'icons', 'icon.png'));
|
||||
console.warn(
|
||||
'brand-create: WARNING: `tauri icon` failed; only icons/icon.png was ' +
|
||||
'replaced (other sizes still show the Qwen Code logo). Regenerate ' +
|
||||
'with: npx --yes @tauri-apps/cli icon <logo>',
|
||||
);
|
||||
return 'fallback: icon.png only';
|
||||
}
|
||||
// Non-PNG logo and tauri icon failed — nothing was replaced.
|
||||
console.warn(
|
||||
`brand-create: WARNING: \`tauri icon\` failed and the logo is not PNG ` +
|
||||
`(${logoExt}); no icon files were replaced. Convert the logo to PNG ` +
|
||||
`and re-run: npx --yes @tauri-apps/cli icon <logo>`,
|
||||
);
|
||||
return 'fallback: no icons replaced';
|
||||
}
|
||||
|
||||
function patchBootstrap(shellRoot, brand) {
|
||||
const bootstrapDir = join(shellRoot, 'bootstrap');
|
||||
const logoExt = extname(brand.logo).toLowerCase() || '.png';
|
||||
const brandLogoName = `brand-logo${logoExt}`;
|
||||
copyFileSync(brand.logo, join(bootstrapDir, brandLogoName));
|
||||
|
||||
// Build content safe to splice into the single-quoted JS string literals
|
||||
// of bootstrap.js. JSON.stringify escapes backslashes, newlines and other
|
||||
// control characters, not just quotes; then strip the surrounding double
|
||||
// quotes and escape single quotes for the single-quoted context. Escaping
|
||||
// single quotes alone is insufficient: an appName ending in a backslash
|
||||
// (e.g. "Bob's App\") would otherwise escape the literal's closing quote.
|
||||
const appNameJsSafe = JSON.stringify(brand.appName)
|
||||
.slice(1, -1)
|
||||
.replace(/'/g, "\\'");
|
||||
|
||||
// index.html splices appName into text content (<title>, <h1>, <h2>)
|
||||
// and double-quoted attributes (alt="..."), so it must be HTML-escaped:
|
||||
// a raw `<` would start an unknown element and a raw `"` would
|
||||
// terminate an attribute mid-value. Escape `&` first so the entities
|
||||
// introduced by the later replacements are not double-escaped.
|
||||
const appNameHtmlSafe = brand.appName
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const patched = [];
|
||||
for (const file of ['index.html', 'bootstrap.js']) {
|
||||
const filePath = join(bootstrapDir, file);
|
||||
if (!existsSync(filePath)) continue;
|
||||
let text = readFileSync(filePath, 'utf8');
|
||||
const before = text;
|
||||
// Use function replacers to avoid `$` pattern interpretation
|
||||
// (e.g. `$&` in the replacement string would expand to the matched text).
|
||||
if (file === 'bootstrap.js') {
|
||||
// JS file: appName appears inside single-quoted string literals,
|
||||
// so it must be fully JS-literal-escaped (quotes, backslashes,
|
||||
// newlines); see appNameJsSafe above.
|
||||
text = text.replaceAll('Qwen Code', () => appNameJsSafe);
|
||||
} else {
|
||||
// HTML file: splice the HTML-escaped form; see appNameHtmlSafe.
|
||||
text = text.replaceAll('Qwen Code', () => appNameHtmlSafe);
|
||||
}
|
||||
if (file === 'index.html') {
|
||||
text = text.replaceAll('qwen-code-logo.svg', () => brandLogoName);
|
||||
}
|
||||
if (text !== before) {
|
||||
writeFileSync(filePath, text);
|
||||
patched.push(file);
|
||||
}
|
||||
}
|
||||
return patched;
|
||||
}
|
||||
|
||||
function detectAlreadyBranded(shellRoot) {
|
||||
const configPath = join(shellRoot, 'src-tauri', 'tauri.conf.json');
|
||||
try {
|
||||
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
||||
// The pristine shell always ships productName === 'Qwen Code Desktop'.
|
||||
// If it has already been changed, this tree was branded before and
|
||||
// re-running would produce stale results (bootstrap patches key on
|
||||
// the original literals, pubkey/endpoints mutations are not reversible).
|
||||
return config.productName && config.productName !== 'Qwen Code Desktop';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const configPath = argValue('--config');
|
||||
if (!configPath) fail(USAGE);
|
||||
const shellRoot = shellRootFromArgs();
|
||||
|
||||
if (detectAlreadyBranded(shellRoot)) {
|
||||
fail(
|
||||
'this shell-root appears to be already branded (productName is no ' +
|
||||
'longer "Qwen Code Desktop"). brand-create is not idempotent — ' +
|
||||
'start from a fresh clone for each brand.',
|
||||
);
|
||||
}
|
||||
|
||||
const brand = loadConfig(resolve(configPath));
|
||||
|
||||
const configPathPatched = patchTauriConfig(shellRoot, brand);
|
||||
const iconResult = generateIcons(shellRoot, brand);
|
||||
const bootstrapFiles = patchBootstrap(shellRoot, brand);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
brandId: brand.brandId,
|
||||
appName: brand.appName,
|
||||
appId: brand.appId,
|
||||
artifactPrefix: brand.artifactPrefix,
|
||||
updaterEndpoints: brand.updaterEndpoints,
|
||||
tauriConfig: configPathPatched,
|
||||
icons: iconResult,
|
||||
bootstrapPatched: bootstrapFiles,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
330
scripts/tests/brand-create-safety.test.js
Normal file
330
scripts/tests/brand-create-safety.test.js
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
readFileSync,
|
||||
} from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const SCRIPT = join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'packages',
|
||||
'desktop-shell',
|
||||
'.agents',
|
||||
'skills',
|
||||
'desktop-brand-builder',
|
||||
'scripts',
|
||||
'brand-create.mjs',
|
||||
);
|
||||
|
||||
/**
|
||||
* Create a minimal fake shell-root with the files brand-create.mjs expects.
|
||||
* Pass { withUpdater: false } to model a fork or hand-edited shell-root
|
||||
* whose tauri.conf.json has no plugins.updater section.
|
||||
*/
|
||||
function makeShellRoot({ withUpdater = true } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'brand-test-shell-'));
|
||||
mkdirSync(join(root, 'src-tauri', 'icons'), { recursive: true });
|
||||
mkdirSync(join(root, 'bootstrap'), { recursive: true });
|
||||
const tauriConfig = {
|
||||
productName: 'Qwen Code Desktop',
|
||||
identifier: 'com.qwen.code.desktop',
|
||||
bundle: { createUpdaterArtifacts: true, shortDescription: '' },
|
||||
};
|
||||
if (withUpdater) {
|
||||
tauriConfig.plugins = {
|
||||
updater: {
|
||||
endpoints: ['https://updater.qwen-code.org'],
|
||||
pubkey: 'dGVzdA==',
|
||||
},
|
||||
};
|
||||
}
|
||||
writeFileSync(
|
||||
join(root, 'src-tauri', 'tauri.conf.json'),
|
||||
JSON.stringify(tauriConfig),
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, 'package.json'),
|
||||
JSON.stringify({ name: 'desktop-shell', type: 'module' }),
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a minimal @tauri-apps/cli stub so generateIcons resolves and runs it
|
||||
* locally instead of falling back to a slow, network-dependent `npx --yes`.
|
||||
*/
|
||||
function seedTauriCliStub(root) {
|
||||
const cliDir = join(root, 'node_modules', '@tauri-apps', 'cli');
|
||||
mkdirSync(cliDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(cliDir, 'package.json'),
|
||||
JSON.stringify({ name: '@tauri-apps/cli', version: '0.0.0' }),
|
||||
);
|
||||
writeFileSync(join(cliDir, 'tauri.js'), 'process.exit(0);\n');
|
||||
}
|
||||
|
||||
function makeLogo(dir) {
|
||||
const logoPath = join(dir, 'logo.png');
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
writeFileSync(logoPath, png);
|
||||
return logoPath;
|
||||
}
|
||||
|
||||
function runBrand(shellRoot, brandConfig) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'brand-test-cfg-'));
|
||||
const configPath = join(dir, 'brand.json');
|
||||
writeFileSync(configPath, JSON.stringify(brandConfig));
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[SCRIPT, '--shell-root', shellRoot, '--config', configPath],
|
||||
{ encoding: 'utf8', timeout: 10_000 },
|
||||
);
|
||||
return { ...result, configDir: dir };
|
||||
}
|
||||
|
||||
let shellRoot;
|
||||
let logoPath;
|
||||
const tmpDirs = [];
|
||||
|
||||
beforeEach(() => {
|
||||
shellRoot = makeShellRoot();
|
||||
logoPath = makeLogo(shellRoot);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const d of tmpDirs) {
|
||||
try {
|
||||
rmSync(d, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best-effort cleanup */
|
||||
}
|
||||
}
|
||||
try {
|
||||
rmSync(shellRoot, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best-effort cleanup */
|
||||
}
|
||||
});
|
||||
|
||||
describe('brand-create.mjs safety checks', () => {
|
||||
// R1-4: Shell injection via logo path
|
||||
it('does not use shell:true when invoking the icon generator', () => {
|
||||
const source = readFileSync(SCRIPT, 'utf8');
|
||||
const fnMatch = source.match(/function generateIcons[\s\S]*?\n\}/);
|
||||
expect(fnMatch).toBeTruthy();
|
||||
expect(fnMatch[0]).not.toContain('shell: true');
|
||||
expect(fnMatch[0]).not.toContain('shell:true');
|
||||
expect(fnMatch[0]).not.toContain('safeLogo');
|
||||
});
|
||||
|
||||
// R1-1: Missing updaterPubkey for bring-your-own-feed
|
||||
it('rejects updaterEndpoints without updaterPubkey', () => {
|
||||
const result = runBrand(shellRoot, {
|
||||
brandId: 'acme-ai',
|
||||
logo: logoPath,
|
||||
updaterEndpoints: ['https://updates.acme.ai'],
|
||||
});
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain('updaterPubkey');
|
||||
});
|
||||
|
||||
// R6-1: a scalar (non-array) updaterEndpoints must fail loudly instead
|
||||
// of being silently coerced to [], which would ship the brand with
|
||||
// in-app updates permanently disabled while brand-create exits 0.
|
||||
it('rejects non-array updaterEndpoints instead of coercing to []', () => {
|
||||
const confPath = join(shellRoot, 'src-tauri', 'tauri.conf.json');
|
||||
const scalars = [
|
||||
'https://updates.acme.ai/feed.json', // typo of the array shape
|
||||
{ url: 'https://updates.acme.ai/feed.json' },
|
||||
42,
|
||||
true,
|
||||
];
|
||||
for (const updaterEndpoints of scalars) {
|
||||
const before = readFileSync(confPath, 'utf8');
|
||||
const result = runBrand(shellRoot, {
|
||||
brandId: 'acme-ai',
|
||||
logo: logoPath,
|
||||
updaterEndpoints,
|
||||
});
|
||||
expect(result.status, result.stderr).not.toBe(0);
|
||||
expect(result.stderr).toContain('JSON array');
|
||||
expect(
|
||||
readFileSync(confPath, 'utf8'),
|
||||
`value=${JSON.stringify(updaterEndpoints)}`,
|
||||
).toBe(before);
|
||||
}
|
||||
});
|
||||
|
||||
it('still accepts omitted or empty-array updaterEndpoints', () => {
|
||||
for (const extra of [{}, { updaterEndpoints: [] }]) {
|
||||
const root = makeShellRoot();
|
||||
tmpDirs.push(root);
|
||||
seedTauriCliStub(root);
|
||||
const logo = makeLogo(root);
|
||||
const result = runBrand(root, {
|
||||
brandId: 'acme-ai',
|
||||
logo,
|
||||
...extra,
|
||||
});
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts updaterEndpoints when updaterPubkey is provided', () => {
|
||||
const result = runBrand(shellRoot, {
|
||||
brandId: 'acme-ai',
|
||||
logo: logoPath,
|
||||
updaterEndpoints: ['https://updates.acme.ai'],
|
||||
updaterPubkey: 'dGVzdHB1YmtleQ==',
|
||||
});
|
||||
// May fail later (e.g., tauri icon not installed) but must NOT fail
|
||||
// on the updaterPubkey validation.
|
||||
expect(result.stderr).not.toContain('updaterPubkey is missing');
|
||||
});
|
||||
|
||||
// R3-4: Single-use guard bypass when appName === productName
|
||||
it('rejects appName equal to the pristine default "Qwen Code Desktop"', () => {
|
||||
const result = runBrand(shellRoot, {
|
||||
brandId: 'acme-ai',
|
||||
logo: logoPath,
|
||||
appName: 'Qwen Code Desktop',
|
||||
});
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain('Qwen Code Desktop');
|
||||
expect(result.stderr).toContain('re-run guard');
|
||||
});
|
||||
|
||||
it('rejects brandId that derives appName "Qwen Code Desktop"', () => {
|
||||
// brandId "qwen-code-desktop" → titleWords → "Qwen Code Desktop"
|
||||
const result = runBrand(shellRoot, {
|
||||
brandId: 'qwen-code-desktop',
|
||||
logo: logoPath,
|
||||
});
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain('Qwen Code Desktop');
|
||||
});
|
||||
|
||||
// Hostile appName escaping: appName is a free-form override, and the
|
||||
// bootstrap.js patcher must not emit invalid JS for it. Previously only
|
||||
// single quotes were escaped, so a trailing backslash escaped the
|
||||
// literal's closing quote while brand-create still exited 0.
|
||||
it('keeps generated bootstrap.js valid JS for hostile appName values', () => {
|
||||
const hostileNames = [
|
||||
"Bob's App\\", // trailing backslash (the reported trigger)
|
||||
'Line1\nLine2', // raw newline is invalid in a single-quoted literal
|
||||
'Say "hi"', // double quotes
|
||||
'It\'s "quoted" \\ done', // quotes and backslashes combined
|
||||
];
|
||||
for (const appName of hostileNames) {
|
||||
const root = makeShellRoot();
|
||||
tmpDirs.push(root);
|
||||
seedTauriCliStub(root);
|
||||
const logo = makeLogo(root);
|
||||
const bootstrapPath = join(root, 'bootstrap', 'bootstrap.js');
|
||||
writeFileSync(
|
||||
bootstrapPath,
|
||||
"const a = 'Starting Qwen Code';\nconst b = 'Restarting Qwen Code';\n",
|
||||
);
|
||||
const result = runBrand(root, {
|
||||
brandId: 'acme-ai',
|
||||
logo,
|
||||
appName,
|
||||
});
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
const check = spawnSync(process.execPath, ['--check', bootstrapPath], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
expect(check.status, check.stderr).toBe(0);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
// R5-1: appName is also spliced into bootstrap/index.html (<title>,
|
||||
// alt attribute, <h1>, <h2>), where it must be HTML-escaped so the
|
||||
// branded startup screen stays well-formed for free-form brand names.
|
||||
it('HTML-escapes hostile appName values in bootstrap/index.html', () => {
|
||||
const cases = [
|
||||
{
|
||||
appName: 'Acme <Corp>',
|
||||
expected: [
|
||||
'<title>Acme <Corp></title>',
|
||||
'alt="Acme <Corp>"',
|
||||
'<h1>Acme <Corp></h1>',
|
||||
'<h2 id="title">Starting Acme <Corp></h2>',
|
||||
],
|
||||
hostileFragment: '<Corp>',
|
||||
},
|
||||
{
|
||||
appName: 'Acme "Beta" <Desktop>',
|
||||
expected: [
|
||||
'<title>Acme "Beta" <Desktop></title>',
|
||||
'alt="Acme "Beta" <Desktop>"',
|
||||
'<h1>Acme "Beta" <Desktop></h1>',
|
||||
'<h2 id="title">Starting Acme "Beta" <Desktop></h2>',
|
||||
],
|
||||
hostileFragment: 'alt="Acme "Beta"',
|
||||
},
|
||||
];
|
||||
for (const { appName, expected, hostileFragment } of cases) {
|
||||
const root = makeShellRoot();
|
||||
tmpDirs.push(root);
|
||||
seedTauriCliStub(root);
|
||||
const logo = makeLogo(root);
|
||||
const indexPath = join(root, 'bootstrap', 'index.html');
|
||||
writeFileSync(
|
||||
indexPath,
|
||||
'<!doctype html>\n<html>\n<head>\n<title>Qwen Code</title>\n' +
|
||||
'</head>\n<body>\n<img src="qwen-code-logo.svg" alt="Qwen Code">\n' +
|
||||
'<h1>Qwen Code</h1>\n<h2 id="title">Starting Qwen Code</h2>\n' +
|
||||
'</body>\n</html>\n',
|
||||
);
|
||||
const result = runBrand(root, {
|
||||
brandId: 'acme-ai',
|
||||
logo,
|
||||
appName,
|
||||
});
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
const html = readFileSync(indexPath, 'utf8');
|
||||
for (const snippet of expected) {
|
||||
expect(html, `appName=${appName}`).toContain(snippet);
|
||||
}
|
||||
expect(html, `appName=${appName}`).not.toContain(hostileFragment);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
// R5-8: the brand's validated updater config must not be silently
|
||||
// discarded when the target tauri.conf.json has no plugins.updater
|
||||
// section; fail closed with the config file left unmutated.
|
||||
it('fails closed when the shell-root has no plugins.updater section', () => {
|
||||
const root = makeShellRoot({ withUpdater: false });
|
||||
tmpDirs.push(root);
|
||||
const logo = makeLogo(root);
|
||||
const confPath = join(root, 'src-tauri', 'tauri.conf.json');
|
||||
const before = readFileSync(confPath, 'utf8');
|
||||
const result = runBrand(root, {
|
||||
brandId: 'acme-ai',
|
||||
logo,
|
||||
updaterEndpoints: ['https://updates.acme.ai'],
|
||||
updaterPubkey: 'dGVzdHB1YmtleQ==',
|
||||
});
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain('plugins.updater');
|
||||
expect(readFileSync(confPath, 'utf8')).toBe(before);
|
||||
}, 60_000);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue