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
177 changes: 177 additions & 0 deletions packages/desktop-shell/.agents/skills/desktop-brand-builder/SKILL.md
Original file line number Diff line number Diff line change
@@ -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.<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": []
}
```

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 <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
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.
Original file line number Diff line number Diff line change
@@ -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 <logo>',
);
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();
Loading