diff --git a/packages/desktop-shell/.agents/skills/desktop-brand-builder/SKILL.md b/packages/desktop-shell/.agents/skills/desktop-brand-builder/SKILL.md new file mode 100644 index 00000000000..52c9895aa88 --- /dev/null +++ b/packages/desktop-shell/.agents/skills/desktop-brand-builder/SKILL.md @@ -0,0 +1,177 @@ +--- +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) +- `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..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/-" +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- origin/main +``` + +If the clone or checkout fails, stop and report the failure. Do not continue +as if `brand-` 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": [] +} +``` + +Install desktop-shell dependencies if `packages/desktop-shell/node_modules` +is missing: + +```bash +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`. +2. Regenerates the full icon set from the logo via + `npx --yes @tauri-apps/cli icon ` (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 +npx tauri build --target aarch64-apple-darwin # explicit macOS arm64 +``` + +For `target: all`, 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/`. + +## 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. + +## Validation + +After packaging: + +1. Confirm the expected artifact exists under + `packages/desktop-shell/src-tauri/target/release/bundle/` + (`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. +- 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. diff --git a/packages/desktop-shell/.agents/skills/desktop-brand-builder/scripts/brand-create.mjs b/packages/desktop-shell/.agents/skills/desktop-brand-builder/scripts/brand-create.mjs new file mode 100644 index 00000000000..b2d88525300 --- /dev/null +++ b/packages/desktop-shell/.agents/skills/desktop-brand-builder/scripts/brand-create.mjs @@ -0,0 +1,200 @@ +#!/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, writeFileSync } from 'node:fs'; +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)) { + fail(`logo must be an existing file path, got: ${input.logo}`); + } + + const words = titleWords(brandId); + return { + brandId, + logo, + website: input.website?.trim() || undefined, + appName: input.appName?.trim() || words.join(' '), + appId: input.appId?.trim() || deriveAppId(input.website, brandId), + artifactPrefix: input.artifactPrefix?.trim() || words.join('-'), + updaterEndpoints: Array.isArray(input.updaterEndpoints) + ? input.updaterEndpoints + : [], + }; +} + +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 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. + if (config.plugins?.updater) { + config.plugins.updater.endpoints = brand.updaterEndpoints; + } + + writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`); + return configPath; +} + +function generateIcons(shellRoot, brand) { + const result = 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 flag that only icon.png changed. + 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 ', + ); + return 'fallback: icon.png only'; +} + +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)); + + 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; + text = text.replaceAll('Qwen Code', brand.appName); + if (file === 'index.html') { + text = text.replaceAll('qwen-code-logo.svg', brandLogoName); + } + if (text !== before) { + writeFileSync(filePath, text); + patched.push(file); + } + } + return patched; +} + +function main() { + const configPath = argValue('--config'); + if (!configPath) fail(USAGE); + const shellRoot = shellRootFromArgs(); + 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();