From 8bdbb5fe0068a80f7e17a9e4cbbe5ecceee4ae99 Mon Sep 17 00:00:00 2001 From: DragonnZhang <731557579@qq.com> Date: Tue, 9 Jun 2026 14:19:18 +0800 Subject: [PATCH] chore(desktop): move brand builder skill into desktop agents OpenWork-Sync-Mode: export Qwen-Code-Base: 1ad4b4b81ad09cbfaaedc025b653d0ce8383020d Qwen-Code-Commit: 957b0f1ed29f169a69f9048fa39ec777cba3c6e6 OpenWork-Base: 316fdc826868e3d99506538367d30980390552cb --- .agents/skills/desktop-brand-builder/SKILL.md | 151 ++++++++++ .../scripts/brand-create.ts | 283 ++++++++++++++++++ 2 files changed, 434 insertions(+) create mode 100644 .agents/skills/desktop-brand-builder/SKILL.md create mode 100644 .agents/skills/desktop-brand-builder/scripts/brand-create.ts diff --git a/.agents/skills/desktop-brand-builder/SKILL.md b/.agents/skills/desktop-brand-builder/SKILL.md new file mode 100644 index 000000000..d670075ad --- /dev/null +++ b/.agents/skills/desktop-brand-builder/SKILL.md @@ -0,0 +1,151 @@ +--- +name: desktop-brand-builder +description: Generate a branded qwen-code desktop package from a minimal brandId and logo. Use when the user wants a custom, white-label, rebranded, ModelStudio/OpenWork/Qwen Code desktop client, installer, DMG/EXE/AppImage, or one-click brand build. +--- + +# Desktop Brand Builder + +## Goal + +Create a branded desktop package 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, dock icon, renderer symbol, signing, or local installation unless +the user explicitly asks to override them. + +## Input Rules + +Required fields: + +- `brandId`: must match `^[a-z][a-z0-9-]*$` +- `logo`: local file path; must exist + +Optional overrides: + +- `website` +- `appName` +- `appId` +- `artifactPrefix` +- `target`: `mac`, `win`, `linux`, or `all` + +If required input is missing, ask once: + +```text +请提供: +brandId: 例如 acme-ai,只能小写字母、数字、短横线 +logo: 本地 logo 文件路径 +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` +- `copyright`: `Copyright © ` +- all brand images: generate icon, dock icon, and renderer symbol from `logo` + +Use explicit user-provided override values as-is after basic validation. + +## Build Workflow + +Use an isolated build directory under the current working directory so user +changes in the current worktree are not mutated. Default to the qwen-code desktop branch; do not clone from +`craft-agents-oss`, OpenWork, or another local checkout unless the user +explicitly asks for that source: + +```bash +BUILD_ROOT="$PWD/brand-builds/-" +mkdir -p "$BUILD_ROOT" +git clone --branch dragon/feat-unstable-desktop-app --single-branch \ + https://github.com/QwenLM/qwen-code.git \ + "$BUILD_ROOT/qwen-code" +cd "$BUILD_ROOT/qwen-code" +git checkout -B dragon/brand- origin/dragon/feat-unstable-desktop-app +``` + +If the branch fetch or checkout fails, stop and report the failure. Do not +continue as if `dragon/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", + "copyright": "Copyright © 2026 Acme AI" +} +``` + +Install desktop dependencies if `packages/desktop/node_modules` is missing: + +```bash +cd packages/desktop +bun install +``` + +Then run this skill's bundled brand creation script: + +```bash +cd /absolute/path/to/qwen-code +bun run packages/desktop/.agents/skills/desktop-brand-builder/scripts/brand-create.ts \ + --desktop-root /absolute/path/to/qwen-code/packages/desktop \ + --config /absolute/path/to/brand.json +``` + +The agent should not hand-edit `branding.ts` or brand asset files when this +bundled script is available. The bundled script is the source of truth for +patching code and generating resources. + +Package with the current host target unless the user requested a target: + +```bash +CRAFT_BRAND= bun run electron:dist:mac +CRAFT_BRAND= bun run electron:dist:win +CRAFT_BRAND= bun run electron:dist:linux +``` + +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. + +## Validation + +After packaging: + +1. Confirm the expected artifact exists under + `packages/desktop/apps/electron/release/`. +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/.agents/skills/desktop-brand-builder/scripts/brand-create.ts` + 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/.agents/skills/desktop-brand-builder/scripts/brand-create.ts b/.agents/skills/desktop-brand-builder/scripts/brand-create.ts new file mode 100644 index 000000000..34cf17e0b --- /dev/null +++ b/.agents/skills/desktop-brand-builder/scripts/brand-create.ts @@ -0,0 +1,283 @@ +import { createRequire } from 'node:module'; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { extname, join, resolve } from 'node:path'; + +interface BrandInput { + brandId?: string; + logo?: string; + website?: string; + appName?: string; + appId?: string; + artifactPrefix?: string; + copyright?: string; +} + +interface BrandConfig { + brandId: string; + logo: string; + website?: string; + appName: string; + appId: string; + artifactPrefix: string; + copyright: string; +} + +const BRAND_ID_RE = /^[a-z][a-z0-9-]*$/; + +function argValue(name: string): string | undefined { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +function configPathFromArgs(): string { + const value = argValue('--config'); + if (!value) { + throw new Error( + 'Usage: bun run scripts/brand-create.ts --desktop-root /path/to/packages/desktop --config /path/to/brand.json', + ); + } + return resolve(value); +} + +function desktopRootFromArgs(): string { + const value = argValue('--desktop-root'); + if (!value) { + throw new Error( + 'Usage: bun run scripts/brand-create.ts --desktop-root /path/to/packages/desktop --config /path/to/brand.json', + ); + } + + const desktopRoot = resolve(value); + if (!existsSync(join(desktopRoot, 'package.json'))) { + throw new Error(`Desktop package not found: ${desktopRoot}`); + } + return desktopRoot; +} + +function titleWords(brandId: string): string[] { + return brandId + .split('-') + .filter(Boolean) + .map((part) => part[0]!.toUpperCase() + part.slice(1)); +} + +function deriveAppId(website: string | undefined, brandId: string): string { + if (!website) return `app.${brandId}.desktop`; + + 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: string): BrandConfig { + const input = JSON.parse(readFileSync(path, 'utf8')) as BrandInput; + const brandId = input.brandId?.trim(); + const logo = input.logo ? resolve(input.logo) : undefined; + + if (!brandId || !BRAND_ID_RE.test(brandId)) { + throw new Error(`brandId must match ${BRAND_ID_RE}`); + } + if (!logo || !existsSync(logo)) { + throw new Error(`Logo file not found: ${logo ?? '(missing)'}`); + } + + const words = titleWords(brandId); + const appName = input.appName?.trim() || words.join(' '); + const artifactPrefix = input.artifactPrefix?.trim() || words.join('-'); + + return { + brandId, + logo, + website: input.website?.trim() || undefined, + appName, + appId: input.appId?.trim() || deriveAppId(input.website, brandId), + artifactPrefix, + copyright: + input.copyright?.trim() || + `Copyright \u00a9 ${new Date().getFullYear()} ${appName}`, + }; +} + +async function run(cmd: string[], cwd: string): Promise { + const proc = Bun.spawn({ + cmd, + cwd, + stdout: 'inherit', + stderr: 'inherit', + stdin: 'inherit', + }); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(`${cmd.join(' ')} failed with exit code ${exitCode}`); + } +} + +async function writeBrandAssets( + config: BrandConfig, + desktopRoot: string, +): Promise { + const requireFromDesktop = createRequire(join(desktopRoot, 'package.json')); + const sharp = requireFromDesktop('sharp') as typeof import('sharp'); + const electronDir = join(desktopRoot, 'apps', 'electron'); + const brandDir = join(electronDir, 'resources', 'brands', config.brandId); + mkdirSync(brandDir, { recursive: true }); + + async function writePng(output: string, size: number) { + await sharp(config.logo) + .resize(size, size, { + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + .png() + .toFile(output); + } + + const sourceExt = extname(config.logo) || '.logo'; + copyFileSync(config.logo, join(brandDir, `source${sourceExt}`)); + + await writePng(join(brandDir, 'icon.png'), 512); + await writePng(join(brandDir, 'dock.png'), 512); + await writePng(join(brandDir, 'symbol.png'), 512); + + if (process.platform !== 'darwin') return 'icon.png'; + + const iconset = join(brandDir, 'icon.iconset'); + rmSync(iconset, { recursive: true, force: true }); + mkdirSync(iconset, { recursive: true }); + + const sizes = [ + ['icon_16x16.png', 16], + ['icon_16x16@2x.png', 32], + ['icon_32x32.png', 32], + ['icon_32x32@2x.png', 64], + ['icon_128x128.png', 128], + ['icon_128x128@2x.png', 256], + ['icon_256x256.png', 256], + ['icon_256x256@2x.png', 512], + ['icon_512x512.png', 512], + ['icon_512x512@2x.png', 1024], + ] as const; + + for (const [file, size] of sizes) { + await writePng(join(iconset, file), size); + } + + await run( + ['iconutil', '-c', 'icns', iconset, '-o', join(brandDir, 'icon.icns')], + brandDir, + ); + return 'icon.icns'; +} + +function tsString(value: string): string { + return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`; +} + +function helpMenuLinks(config: BrandConfig): string { + if (!config.website) return '[]'; + + return `[ + { + labelKey: 'menu.homepage', + url: ${tsString(config.website)}, + icon: 'House', + }, + ]`; +} + +function brandBlock(config: BrandConfig, macIcon: string): string { + const resourceDir = `resources/brands/${config.brandId}`; + + return ` ${tsString(config.brandId)}: { + id: ${tsString(config.brandId)}, + appName: ${tsString(config.appName)}, + appId: ${tsString(config.appId)}, + productName: ${tsString(config.appName)}, + artifactPrefix: ${tsString(config.artifactPrefix)}, + copyright: ${tsString(config.copyright)}, + coAuthorLine: ${tsString(`Co-Authored-By: ${config.appName} `)}, + selfReferName: ${tsString(config.appName)}, + viewerUrl: 'https://agents.craft.do', + helpMenuLinks: ${helpMenuLinks(config)}, + assets: { + resourceDir: ${tsString(resourceDir)}, + rendererSymbol: ${tsString(`${resourceDir}/symbol.png`)}, + macIcon: ${tsString(`${resourceDir}/${macIcon}`)}, + winIcon: ${tsString(`${resourceDir}/icon.png`)}, + linuxIcon: ${tsString(`${resourceDir}/icon.png`)}, + devDockIcon: ${tsString(`${resourceDir}/dock.png`)}, + }, + credits: '', + creditsShort: '', + creditsEntries: [], + }, +`; +} + +function registerBrand( + config: BrandConfig, + desktopRoot: string, + macIcon: string, +): void { + const brandingPath = join( + desktopRoot, + 'packages', + 'shared', + 'src', + 'branding.ts', + ); + const source = readFileSync(brandingPath, 'utf8'); + if ( + source.includes(`${tsString(config.brandId)}:`) || + source.includes(`id: ${tsString(config.brandId)}`) + ) { + throw new Error(`Brand already exists in branding.ts: ${config.brandId}`); + } + + const marker = '\n};\n\n/** Active brand'; + if (!source.includes(marker)) { + throw new Error(`Could not find BRANDS insertion point in ${brandingPath}`); + } + + writeFileSync( + brandingPath, + source.replace(marker, `\n${brandBlock(config, macIcon)}${marker}`), + ); +} + +async function main(): Promise { + const desktopRoot = desktopRootFromArgs(); + const config = loadConfig(configPathFromArgs()); + const macIcon = await writeBrandAssets(config, desktopRoot); + registerBrand(config, desktopRoot, macIcon); + + console.log(`Created brand ${config.brandId}`); + console.log(`App name: ${config.appName}`); + console.log(`App ID: ${config.appId}`); + console.log( + `Assets: ${join(desktopRoot, 'apps', 'electron', 'resources', 'brands', config.brandId)}`, + ); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +});