diff --git a/.agents/skills/desktop-brand-builder/SKILL.md b/.agents/skills/desktop-brand-builder/SKILL.md index c22d02305..a465805d6 100644 --- a/.agents/skills/desktop-brand-builder/SKILL.md +++ b/.agents/skills/desktop-brand-builder/SKILL.md @@ -32,7 +32,6 @@ Optional overrides: - `website` - `appName` - `appId` -- `artifactPrefix` - `target`: `mac`, `win`, `linux`, or `all` If required input is missing, ask once: @@ -52,8 +51,6 @@ 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` @@ -91,37 +88,40 @@ Create a temporary `brand.json` in the build directory: "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: +Install repository and Tauri shell dependencies when missing: ```bash -cd packages/desktop -bun install +npm install +cd packages/desktop-shell && npm install --workspaces=false ``` 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 \ +npx tsx .agents/skills/desktop-brand-builder/scripts/brand-create.ts \ + --desktop-root /absolute/path/to/qwen-code/packages/desktop-shell \ --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. +The agent should not hand-edit Tauri icons, the renderer symbol, or +`tauri.conf.json` when this script is available. It generates icons with the +Tauri CLI and patches the visible bootstrap/Web Shell brand copy and deep-link +scheme. It disables the OpenWork updater endpoint so a white-label build cannot +install an OpenWork release; configure a brand-owned signed endpoint before +enabling updates. 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 +cd packages/desktop-shell +npm run build --workspaces=false -- --bundles dmg +npm run build --workspaces=false -- --bundles nsis +npm run build --workspaces=false -- --bundles appimage,deb ``` For `target: all`, run only targets supported by the current machine or CI @@ -133,7 +133,7 @@ files exist. After packaging: 1. Confirm the expected artifact exists under - `packages/desktop/apps/electron/release/`. + `packages/desktop-shell/src-tauri/target/release/bundle/`. 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. @@ -143,8 +143,8 @@ After packaging: - 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. + `.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. diff --git a/.agents/skills/desktop-brand-builder/scripts/brand-create.ts b/.agents/skills/desktop-brand-builder/scripts/brand-create.ts index 8112a423e..880611fe6 100644 --- a/.agents/skills/desktop-brand-builder/scripts/brand-create.ts +++ b/.agents/skills/desktop-brand-builder/scripts/brand-create.ts @@ -1,15 +1,7 @@ +import { execFileSync } from 'node:child_process'; import { createRequire } from 'node:module'; -import { - copyFileSync, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs'; -import { tmpdir } from 'node:os'; -import { extname, join, resolve } from 'node:path'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; interface BrandInput { brandId?: string; @@ -17,7 +9,6 @@ interface BrandInput { website?: string; appName?: string; appId?: string; - artifactPrefix?: string; copyright?: string; } @@ -27,7 +18,6 @@ interface BrandConfig { website?: string; appName: string; appId: string; - artifactPrefix: string; copyright: string; } @@ -38,341 +28,233 @@ function argValue(name: string): string | undefined { return index >= 0 ? process.argv[index + 1] : undefined; } -function configPathFromArgs(): string { - const value = argValue('--config'); +function requiredPath(name: string): string { + const value = argValue(name); if (!value) { throw new Error( - 'Usage: bun run scripts/brand-create.ts --desktop-root /path/to/packages/desktop --config /path/to/brand.json', + 'Usage: npx tsx brand-create.ts --desktop-root /path/to/packages/desktop-shell --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 host = new URL( + website?.includes('://') ? website : `https://${website}`, + ).hostname.replace(/^www\./, ''); const parts = host.split('.').filter(Boolean); - if (parts.length >= 2) { - return `${parts.reverse().join('.')}.desktop`; - } + if (parts.length >= 2) return `${parts.reverse().join('.')}.desktop`; } catch { - // Fall through to the deterministic fallback. + // Use the deterministic fallback below. } - 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; +function normalizeWebsite(value: string | undefined): string | undefined { + if (!value?.trim()) return undefined; + const url = new URL( + value.includes('://') ? value.trim() : `https://${value.trim()}`, + ); + if ( + !['http:', 'https:'].includes(url.protocol) || + !url.hostname || + url.username || + url.password + ) { + throw new Error('website must be an HTTP(S) URL without credentials'); + } + return url.toString(); +} +function validateText(name: string, value: string, maxLength: number): string { + if (!value || value.length > maxLength || /[\0\r\n]/.test(value)) { + throw new Error(`${name} must be 1-${maxLength} characters on one line`); + } + return value; +} + +function loadConfig(file: string): BrandConfig { + const input = JSON.parse(readFileSync(file, 'utf8')) as BrandInput; + const brandId = input.brandId?.trim(); + const logo = input.logo ? resolve(input.logo) : ''; 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)'}`); + if (!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('-'); - + const website = normalizeWebsite(input.website); + const appName = validateText( + 'appName', + input.appName?.trim() || words.join(' '), + 80, + ); + if (!/^[\p{L}\p{N}][\p{L}\p{N} ._-]*$/u.test(appName)) { + throw new Error('appName may contain only letters, digits, spaces, ._-'); + } + const appId = input.appId?.trim() || deriveAppId(website, brandId); + if (!/^[A-Za-z0-9.-]+$/.test(appId) || !appId.includes('.')) { + throw new Error(`Invalid Tauri appId: ${appId}`); + } return { brandId, logo, - website: input.website?.trim() || undefined, + website, appName, - appId: input.appId?.trim() || deriveAppId(input.website, brandId), - artifactPrefix, - copyright: + appId, + copyright: validateText( + 'copyright', input.copyright?.trim() || - `Copyright \u00a9 ${new Date().getFullYear()} ${appName}`, + `Copyright © ${new Date().getFullYear()} ${appName}`, + 200, + ), }; } -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}`); - } -} - -interface BrandAssetsResult { - macIcon: string; - hasAssetsCar: boolean; +function run(command: [string, ...string[]], cwd: string): void { + execFileSync(command[0], command.slice(1), { cwd, stdio: 'inherit' }); } -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 { macIcon: 'icon.png', hasAssetsCar: false }; - - 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, +function replaceVisibleText(file: string, appName: string): void { + if (!existsSync(file)) return; + writeFileSync( + file, + readFileSync(file, 'utf8').replaceAll('OpenWork', appName), ); - - const hasAssetsCar = await compileAssetsCar(config, brandDir, writePng); - return { macIcon: 'icon.icns', hasAssetsCar }; } -async function compileAssetsCar( - config: BrandConfig, - brandDir: string, - writePng: (output: string, size: number) => Promise, -): Promise { - const xcassets = join(brandDir, 'Assets.xcassets'); - const appiconset = join(xcassets, 'AppIcon.appiconset'); - rmSync(xcassets, { recursive: true, force: true }); - mkdirSync(appiconset, { recursive: true }); - +function replaceQuotedText(file: string, appName: string): void { + const source = readFileSync(file, 'utf8'); writeFileSync( - join(xcassets, 'Contents.json'), - JSON.stringify({ info: { author: 'xcode', version: 1 } }), + file, + source.replace(/"(?:[^"\\]|\\.)*"/g, (value) => + value.replaceAll('OpenWork', appName), + ), ); +} - const entries = [ - { file: 'icon_16.png', size: 16, scale: '1x', dims: '16x16' }, - { file: 'icon_32.png', size: 32, scale: '2x', dims: '16x16' }, - { file: 'icon_32.png', size: 32, scale: '1x', dims: '32x32' }, - { file: 'icon_64.png', size: 64, scale: '2x', dims: '32x32' }, - { file: 'icon_128.png', size: 128, scale: '1x', dims: '128x128' }, - { file: 'icon_256.png', size: 256, scale: '2x', dims: '128x128' }, - { file: 'icon_256.png', size: 256, scale: '1x', dims: '256x256' }, - { file: 'icon_512.png', size: 512, scale: '2x', dims: '256x256' }, - { file: 'icon_512.png', size: 512, scale: '1x', dims: '512x512' }, - { file: 'icon_1024.png', size: 1024, scale: '2x', dims: '512x512' }, - ]; - - const uniqueSizes = new Set(entries.map((e) => e.size)); - for (const size of uniqueSizes) { - await writePng(join(appiconset, `icon_${size}.png`), size); +function replaceRequired( + source: string, + from: string, + to: string, + file: string, +): string { + if (!source.includes(from)) { + throw new Error(`Could not find ${JSON.stringify(from)} in ${file}`); } + return source.replaceAll(from, to); +} - writeFileSync( - join(appiconset, 'Contents.json'), - JSON.stringify({ - images: entries.map((e) => ({ - filename: e.file, - idiom: 'mac', - scale: e.scale, - size: e.dims, - })), - info: { author: 'xcode', version: 1 }, - }), +async function main(): Promise { + const desktopRoot = requiredPath('--desktop-root'); + const packageFile = join(desktopRoot, 'package.json'); + if (!existsSync(packageFile)) { + throw new Error(`Tauri desktop package not found: ${desktopRoot}`); + } + const config = loadConfig(requiredPath('--config')); + const repoRoot = resolve(desktopRoot, '../..'); + const requireFromRepo = createRequire(join(repoRoot, 'package.json')); + const sharp = requireFromRepo('sharp') as typeof import('sharp'); + const symbol = join(desktopRoot, 'bootstrap', 'openwork-symbol.png'); + await sharp(config.logo) + .resize(512, 512, { + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + .png() + .toFile(symbol); + run( + [ + 'npx', + 'tauri', + 'icon', + symbol, + '--output', + join(desktopRoot, 'src-tauri', 'icons'), + ], + desktopRoot, ); - const outDir = mkdtempSync(join(tmpdir(), 'assets-car-')); - const partialPlist = join(outDir, 'partial-info.plist'); - const proc = Bun.spawn({ - cmd: [ - 'xcrun', 'actool', xcassets, - '--compile', outDir, - '--app-icon', 'AppIcon', - '--platform', 'macosx', - '--minimum-deployment-target', '14.0', - '--output-partial-info-plist', partialPlist, - ], - cwd: brandDir, - stdout: 'pipe', - stderr: 'pipe', - }); - const exitCode = await proc.exited; - if (exitCode !== 0) { - console.log('Warning: actool compilation failed, skipping Assets.car'); - rmSync(xcassets, { recursive: true, force: true }); - return false; + const tauriConfigPath = join(desktopRoot, 'src-tauri', 'tauri.conf.json'); + const tauriConfig = JSON.parse(readFileSync(tauriConfigPath, 'utf8')); + tauriConfig.productName = config.appName; + tauriConfig.identifier = config.appId; + tauriConfig.bundle.shortDescription = `${config.appName} — AI agent workspace`; + tauriConfig.bundle.copyright = config.copyright; + tauriConfig.plugins['deep-link'].desktop.schemes = [config.brandId]; + tauriConfig.plugins.updater.endpoints = []; + writeFileSync(tauriConfigPath, `${JSON.stringify(tauriConfig, null, 2)}\n`); + + for (const file of [ + join(desktopRoot, 'bootstrap', 'index.html'), + join(desktopRoot, 'bootstrap', 'bootstrap.js'), + join(desktopRoot, 'bootstrap', 'local-control.html'), + join(desktopRoot, 'bootstrap', 'pet.html'), + join(desktopRoot, 'src-tauri', 'Info.plist'), + join(desktopRoot, 'src-tauri', 'windows-app-manifest.xml'), + join(repoRoot, 'packages', 'web-shell', 'client', 'index.html'), + join(repoRoot, 'packages', 'web-shell', 'client', 'i18n.tsx'), + ]) { + replaceVisibleText(file, config.appName); } - const compiledCar = join(outDir, 'Assets.car'); - if (!existsSync(compiledCar)) { - console.log('Warning: actool produced no Assets.car, skipping'); - rmSync(xcassets, { recursive: true, force: true }); - return false; + const rustMain = join(desktopRoot, 'src-tauri', 'src', 'main.rs'); + replaceQuotedText(rustMain, config.appName); + let rustSource = replaceRequired( + readFileSync(rustMain, 'utf8'), + 'openwork://', + `${config.brandId}://`, + rustMain, + ); + if (config.website) { + rustSource = rustSource.replaceAll( + 'https://github.com/modelstudioai/openwork', + config.website, + ); } + rustSource = replaceRequired( + rustSource, + 'url.scheme() != "openwork"', + `url.scheme() != "${config.brandId}"`, + rustMain, + ); + writeFileSync(rustMain, rustSource); - copyFileSync(compiledCar, join(brandDir, 'Assets.car')); - rmSync(xcassets, { recursive: true, force: true }); - rmSync(outDir, { recursive: true, force: true }); - console.log('Assets.car compiled successfully'); - return true; -} - -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, hasAssetsCar: boolean): string { - const resourceDir = `resources/brands/${config.brandId}`; - const liquidGlassLine = hasAssetsCar - ? `\n liquidGlassAssetsCar: ${tsString(`${resourceDir}/Assets.car`)},` - : ''; - - 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`)},${liquidGlassLine} - }, - credits: '', - creditsShort: '', - creditsEntries: [], - }, -`; -} - -function registerBrand( - config: BrandConfig, - desktopRoot: string, - macIcon: string, - hasAssetsCar: boolean, -): void { - const brandingPath = join( - desktopRoot, + const desktopLayer = join( + repoRoot, 'packages', - 'shared', - 'src', - 'branding.ts', + 'web-shell', + 'client', + 'openwork', + 'OpenWorkDesktopLayer.tsx', ); - 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, hasAssetsCar)}${marker}`), + let desktopSource = readFileSync(desktopLayer, 'utf8'); + desktopSource = replaceRequired( + desktopSource, + "url.protocol !== 'openwork:'", + `url.protocol !== '${config.brandId}:'`, + desktopLayer, ); -} - -async function main(): Promise { - const desktopRoot = desktopRootFromArgs(); - const config = loadConfig(configPathFromArgs()); - const { macIcon, hasAssetsCar } = await writeBrandAssets(config, desktopRoot); - registerBrand(config, desktopRoot, macIcon, hasAssetsCar); + desktopSource = replaceRequired( + desktopSource, + "title: 'OpenWork'", + `title: ${JSON.stringify(config.appName)}`, + desktopLayer, + ); + writeFileSync(desktopLayer, desktopSource); - console.log(`Created brand ${config.brandId}`); + console.log(`Created Tauri 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)}`, - ); - if (hasAssetsCar) { - console.log('Assets.car: generated (macOS 26+ Liquid Glass icon)'); - } + console.log(`Desktop root: ${desktopRoot}`); } main().catch((error: unknown) => { diff --git a/.agents/skills/desktop-develop/SKILL.md b/.agents/skills/desktop-develop/SKILL.md index ee91353da..c115eadf7 100644 --- a/.agents/skills/desktop-develop/SKILL.md +++ b/.agents/skills/desktop-develop/SKILL.md @@ -1,6 +1,6 @@ --- name: desktop-develop -description: Develop, debug, and verify the OpenWork desktop/Electron app with an agent-readable harness. Use when working on packages/desktop, Electron renderer/main/preload code, desktop UI bugs, local desktop runtime failures, Chrome DevTools MCP investigation, desktop logs, messaging gateway issues, or when improving the development feedback loop for desktop features. +description: Develop, debug, and verify the OpenWork Tauri desktop shell and its daemon-served Qwen Web Shell with an agent-readable harness. --- # Desktop Development Harness @@ -19,30 +19,28 @@ workflow, observability, docs, tests, or agent-facing harness itself. For bug reports, UI failures, hangs, startup problems, messaging issues, or anything involving the running desktop app, inspect the runtime logs directly. -Important paths: +Important paths on macOS: -- `~/Library/Logs/@craft-agent/electron/main.log` -- `~/Library/Logs/@craft-agent/electron/main.old.log` -- `~/.craft-agent/logs/messaging-gateway.log` +- `~/Library/Logs/com.alibaba.openwork/desktop-runtime.log` +- `~/.qwen/` for Qwen runtime state and transcripts Search logs before guessing: ```bash -rg -n "error|warn|failed|exception|crash|Unhandled|rejection|browser-cdp|messaging-gateway" \ - "$HOME/Library/Logs/@craft-agent/electron/main.log" \ - "$HOME/.craft-agent/logs/messaging-gateway.log" +rg -n "error|warn|failed|exception|crash|Unhandled|rejection" \ + "$HOME/Library/Logs/com.alibaba.openwork/desktop-runtime.log" ``` ## Harness Loop -1. **Map the surface.** Identify whether the task touches Electron main, - preload, renderer, shared desktop packages, server, messaging, or browser - CDP. Read nearby code and tests before editing. +1. **Map the surface.** Identify whether the task touches Tauri Rust, + bootstrap assets, Web Shell, bundled runtime, channels, or the browser + child webview. Read nearby code and tests before editing. 2. **Collect live evidence.** Read and tail the relevant log while reproducing. Treat missing or ambiguous logs as part of the bug. -3. **Drive the UI.** Use Chrome DevTools MCP when a browser/renderer page is - involved: `list_pages`, `select_page`, `take_snapshot`, then console/network - inspection. Prefer accessibility snapshots over screenshots for reasoning. +3. **Drive the UI.** Inspect the daemon-served Web Shell in a browser for DOM, + accessibility, console, and network evidence; verify native behavior in the + Tauri app and runtime log. 4. **Reproduce first.** For bugs, capture the exact observed behavior and the evidence that proves it. If reproduction differs from the user's report, compare environment, app state, build artifact, account, timing, and logs. @@ -57,50 +55,34 @@ rg -n "error|warn|failed|exception|crash|Unhandled|rejection|browser-cdp|messagi ## Running Desktop -Use desktop-specific commands from `packages/desktop`: +Use desktop-specific commands from `packages/desktop-shell`: ```bash -cd packages/desktop -bun run electron:dev -bun run electron:dev:terminal -bun run electron:dev:logs +cd packages/desktop-shell +npm install --workspaces=false +npm run build:runtime --workspaces=false +npm run dev --workspaces=false ``` -Use `electron:dev:terminal` when the bug involves process output, startup, or -shutdown. Use `electron:dev:logs` when the app is already running and you need a -live log tail. +Reuse the prepared runtime on later runs. Set +`OPENWORK_DESKTOP_WORKSPACE=/absolute/path` for an isolated workspace. -## Chrome DevTools MCP +## Web Shell inspection -If DevTools tools are not loaded, search for `chrome-devtools` tools first. -Then: - -1. Call `mcp__chrome_devtools.list_pages`. -2. Select the relevant page with `mcp__chrome_devtools.select_page`. -3. Capture an accessibility snapshot with - `mcp__chrome_devtools.take_snapshot`. -4. Inspect runtime failures with - `mcp__chrome_devtools.list_console_messages`, then - `mcp__chrome_devtools.get_console_message` for important entries. -5. Inspect selected network requests with - `mcp__chrome_devtools.get_network_request` when network state is involved. -6. For memory issues, save a heap snapshot with - `mcp__chrome_devtools.take_heapsnapshot` and keep it under `.qwen/` or - `/tmp`, not in source directories. - -Always take a fresh snapshot after each UI-changing action. Do not rely on stale -element ids or old console state. +Run `npm run smoke:runtime --workspaces=false` to launch and probe the bundled +loopback runtime. Use `npm run dev --workspaces=false` for native behavior; do +not substitute the retired Electron renderer. ## Focused Verification Choose the narrowest checks that cover the touched surface: ```bash -cd packages/desktop && bun run typecheck:electron -cd packages/desktop && bun run typecheck:all -cd packages/desktop && bun run validate:dev -cd packages/desktop/apps/electron && bun run lint -cd packages/desktop/packages/shared && bun test path/to/file.test.ts +cd packages/desktop-shell && npm test --workspaces=false +cd packages/desktop-shell && npm run test:migration --workspaces=false +cd packages/desktop-shell && npm run test:release --workspaces=false +cd packages/desktop-shell && npm run smoke:runtime --workspaces=false +npm run typecheck --workspace=packages/web-shell ``` For root CLI/core changes, use the root repository commands from `AGENTS.md` diff --git a/.agents/skills/desktop-pet/SKILL.md b/.agents/skills/desktop-pet/SKILL.md index 1f7b7324f..423fbb29b 100644 --- a/.agents/skills/desktop-pet/SKILL.md +++ b/.agents/skills/desktop-pet/SKILL.md @@ -6,9 +6,10 @@ version: 1.0.0 # Desktop Pet Creator -Create pixel-art chibi desktop pet companions for OpenWork's floating pet window. +Create pixel-art chibi desktop pet companions for OpenWork's Tauri pet window. Given any character name, generate a complete pet package with animated spritesheet -and place it in `~/.qwen/pets/` where OpenWork auto-discovers it. +and place it in `~/.qwen/pets/` where OpenWork auto-discovers it through the +scoped Tauri asset protocol. ## Workflow @@ -108,8 +109,8 @@ Rules: ``` 3. Tell the user to activate: - > Open **OpenWork → Settings → Appearance → Pet Companion**, - > click **Refresh**, then select ****. + > Reopen **OpenWork → Settings → Appearance → Desktop pet**, then select + > ****. Selection opens a live preview. ## Character Design Guidelines @@ -229,7 +230,8 @@ Set via `features.extras` (list): ## Troubleshooting -- **Pet not showing**: Click Refresh in Settings → Appearance → Pet Companion +- **Pet not showing**: Reopen Settings → Appearance so OpenWork rescans + `~/.qwen/pets/`; confirm `pet.json` points to a file inside the same pet folder - **Colors look wrong**: Check that RGB values are tuples, not hex strings - **Spritesheet too large**: Must be under 5MB (webp lossless usually ~8-50KB) - **Animation jittery**: Ensure all 8 frames per row are visually distinct but not jarring diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index c52999285..312020955 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -4,5 +4,6 @@ self-hosted-runner: - 'ecs-win' - 'ecs-update-sg' - 'ecs-update-64c' + - 'macos-15-intel' config-variables: null diff --git a/.github/actions/post-coverage-comment/action.yml b/.github/actions/post-coverage-comment/action.yml index deeb3145b..a3aa8bd13 100644 --- a/.github/actions/post-coverage-comment/action.yml +++ b/.github/actions/post-coverage-comment/action.yml @@ -105,10 +105,16 @@ runs: echo "_For detailed HTML reports, please see the 'coverage-reports-${NODE_VERSION}-${OS}' artifact from the main CI run._" >> "${COMMENT_FILE}" - name: 'Post Coverage Comment' - uses: 'thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b' # ratchet:thollander/actions-comment-pull-request@v3 if: |- ${{ always() }} - with: - file-path: 'coverage-comment.md' # Use the generated file directly - comment-tag: 'code-coverage-summary' - github-token: '${{ inputs.github_token }}' + shell: 'bash' + env: + BOT_LOGIN: 'github-actions[bot]' + GH_TOKEN: '${{ inputs.github_token }}' + PR_NUMBER: '${{ github.event.pull_request.number }}' + REPOSITORY: '${{ github.repository }}' + run: |- + marker='' + printf '\n%s\n' "${marker}" >> coverage-comment.md + .github/scripts/upsert-bot-comment.sh \ + "${REPOSITORY}" "${PR_NUMBER}" "${marker}" coverage-comment.md diff --git a/.github/scripts/create-electron-bridge-manifest.mjs b/.github/scripts/create-electron-bridge-manifest.mjs index e143d4bcc..90ddc5ef4 100644 --- a/.github/scripts/create-electron-bridge-manifest.mjs +++ b/.github/scripts/create-electron-bridge-manifest.mjs @@ -6,15 +6,24 @@ import path from 'node:path'; const options = parseArguments(process.argv.slice(2)); const assets = fs.readdirSync(options.assets).sort(); -const names = [ - 'Qwen-Code-Desktop-arm64.zip', - 'Qwen-Code-Desktop-x64.zip', - 'Qwen-Code-Desktop-arm64.dmg', - 'Qwen-Code-Desktop-x64.dmg', -]; -const artifacts = names.map((name) => readArtifact(assets, name)); +const patterns = { + macos: [ + /[-_]arm64\.zip$/i, + /[-_]x64\.zip$/i, + /[-_]arm64\.dmg$/i, + /[-_]x64\.dmg$/i, + ], + windows: [/-setup\.exe$/i], + linux: [/\.AppImage$/i], +}; +const selectedPatterns = patterns[options.platform]; +if (!selectedPatterns) { + throw new Error(`Invalid --platform: ${options.platform}`); +} +const artifacts = selectedPatterns.map((pattern) => + readArtifact(selectArtifact(assets, pattern)), +); const primary = artifacts[0]; - const lines = [ `version: ${options.version}`, 'files:', @@ -29,10 +38,17 @@ const lines = [ ]; fs.writeFileSync(options.output, `${lines.join('\n')}\n`); -function readArtifact(assets, name) { - if (!assets.includes(name)) { - throw new Error(`Missing Electron bridge artifact: ${name}`); +function selectArtifact(assets, pattern) { + const matches = assets.filter((asset) => pattern.test(asset)); + if (matches.length !== 1) { + throw new Error( + `Expected one Electron bridge artifact matching ${pattern}, found ${matches.length}: ${matches.join(', ')}`, + ); } + return matches[0]; +} + +function readArtifact(name) { const file = path.join(options.assets, name); return { name, @@ -52,7 +68,7 @@ function parseArguments(args) { if (!name || value === undefined) throw new Error('Invalid arguments.'); values[name] = value; } - for (const required of ['assets', 'version', 'output']) { + for (const required of ['assets', 'platform', 'version', 'output']) { if (!values[required]) throw new Error(`Missing --${required}`); } if ( diff --git a/.github/scripts/openwork-workflows.test.mjs b/.github/scripts/openwork-workflows.test.mjs new file mode 100644 index 000000000..cdecf86e7 --- /dev/null +++ b/.github/scripts/openwork-workflows.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const workflowsDir = join( + dirname(fileURLToPath(import.meta.url)), + '..', + 'workflows', +); + +const reviewedWorkflows = [ + 'audio-capture-prebuilds.yml', + 'ci.yml', + 'codeql.yml', + 'desktop-build.yml', + 'desktop-release.yml', + 'docs-page-action.yml', + 'e2e.yml', + 'main-ci-failure-issue.yml', + 'npm-cache.yml', + 'repo-hygiene.yml', + 'sdk-java.yml', + 'sdk-python.yml', + 'stale.yml', + 'web-shell-visuals-cleanup.yml', + 'windows-runner-smoke.yml', +]; + +describe('OpenWork workflow boundary', () => { + it('requires every checked-in workflow to be explicitly reviewed', () => { + const workflows = readdirSync(workflowsDir) + .filter((name) => name.endsWith('.yml') || name.endsWith('.yaml')) + .sort(); + + assert.deepEqual(workflows, reviewedWorkflows); + }); +}); diff --git a/.github/scripts/upsert-bot-comment.sh b/.github/scripts/upsert-bot-comment.sh index 965eda4e9..d36d17bf2 100755 --- a/.github/scripts/upsert-bot-comment.sh +++ b/.github/scripts/upsert-bot-comment.sh @@ -17,6 +17,9 @@ # path), never the no-op success reserved for a lookup that genuinely # found nothing. # +# Set BOT_LOGIN when the token cannot access the /user endpoint, such as a +# GitHub Actions integration token. +# # Usage: upsert-bot-comment.sh [--update-only] # --update-only: PATCH an existing bot-authored marker comment if present; # succeed as a no-op when none exists (never POSTs). For @@ -30,10 +33,13 @@ marker="${3:?missing marker}" body_file="${4:?missing body file}" update_only="${5:-}" -body="$(cat "${body_file}")" +comment_payload() { + jq -n --rawfile body "${body_file}" '{body: $body}' +} for _attempt in 1 2 3; do - if bot_login="$(gh api user --jq '.login')" \ + if bot_login="${BOT_LOGIN:-}" \ + && { [ -n "${bot_login}" ] || bot_login="$(gh api user --jq '.login')"; } \ && [ -n "${bot_login}" ] \ && listing="$(gh api "repos/${repo}/issues/${number}/comments" \ --method GET \ @@ -45,17 +51,18 @@ for _attempt in 1 2 3; do | select((.body // "") | contains($marker))] | last | .id // empty')"; then if [ -n "${existing_id}" ]; then - if gh api --method PATCH \ + if comment_payload | gh api --method PATCH \ "repos/${repo}/issues/comments/${existing_id}" \ - -f body="${body}" >/dev/null; then + --input - >/dev/null; then echo "updated comment ${existing_id}" exit 0 fi elif [ "${update_only}" = "--update-only" ]; then echo "no existing comment; nothing to update" exit 0 - elif gh api "repos/${repo}/issues/${number}/comments" \ - -f body="${body}" >/dev/null; then + elif comment_payload | gh api --method POST \ + "repos/${repo}/issues/${number}/comments" \ + --input - >/dev/null; then echo "posted new comment" exit 0 fi diff --git a/.github/scripts/upsert-bot-comment.test.mjs b/.github/scripts/upsert-bot-comment.test.mjs index 6ecd2fd76..c970201dc 100644 --- a/.github/scripts/upsert-bot-comment.test.mjs +++ b/.github/scripts/upsert-bot-comment.test.mjs @@ -31,14 +31,17 @@ const here = dirname(fileURLToPath(import.meta.url)); const script = join(here, 'upsert-bot-comment.sh'); const MARKER = ''; -function run(scenario, { updateOnly = false } = {}) { +function run( + scenario, + { updateOnly = false, botLogin = '', body = `${MARKER}\nhello` } = {}, +) { const dir = mkdtempSync(join(tmpdir(), 'upsert-bot-comment-')); const bin = join(dir, 'bin'); mkdirSync(bin); const calls = join(dir, 'calls'); writeFileSync(calls, ''); const bodyFile = join(dir, 'body'); - writeFileSync(bodyFile, `${MARKER}\nhello`); + writeFileSync(bodyFile, body); const write = (name, body) => { writeFileSync(join(bin, name), body); chmodSync(join(bin, name), 0o755); @@ -49,6 +52,7 @@ function run(scenario, { updateOnly = false } = {}) { [ '#!/bin/bash', 'echo "$*" >> "$CALLS"', + 'if [[ "$*" == *"--input -"* ]]; then cat >/dev/null; fi', 'n=$(grep -c "method GET" "$CALLS" || true)', 'case "$*" in', ' "api user"*)', @@ -93,6 +97,7 @@ function run(scenario, { updateOnly = false } = {}) { PATH: `${bin}:${process.env.PATH}`, SCENARIO: scenario, CALLS: calls, + BOT_LOGIN: botLogin, }, }, ); @@ -112,6 +117,22 @@ test('POSTs a fresh comment when no bot-authored marker exists', () => { assert.doesNotMatch(r.calls, /--method PATCH/); }); +test('uses BOT_LOGIN when the token cannot access /user', () => { + const r = run('user-fails', { botLogin: 'bot' }); + assert.equal(r.code, 0); + assert.match(r.stdout, /posted new comment/); + assert.doesNotMatch(r.calls, /api user/); +}); + +test('streams large comment bodies through stdin', () => { + const r = run('fresh', { + body: `${MARKER}\n${'x'.repeat(200_000)}`, + }); + assert.equal(r.code, 0); + assert.match(r.calls, /--method POST .* --input -/); + assert.doesNotMatch(r.calls, /body=/); +}); + test('PATCHes the existing bot-authored marker comment', () => { const r = run('existing-bot'); assert.equal(r.code, 0); @@ -141,7 +162,7 @@ test('--update-only is a no-op success when nothing exists', () => { assert.match(r.stdout, /nothing to update/); assert.doesNotMatch(r.calls, /--method PATCH/); // And no POST either: the only api writes would be comment creation. - assert.doesNotMatch(r.calls, /issues\/42\/comments -f/); + assert.doesNotMatch(r.calls, /--method POST/); }); test('a failed listing NEVER falls through to POST (retries, then PATCHes)', () => { @@ -151,7 +172,7 @@ test('a failed listing NEVER falls through to POST (retries, then PATCHes)', () const r = run('listing-fails-once'); assert.equal(r.code, 0); assert.match(r.stdout, /updated comment 7/); - assert.doesNotMatch(r.calls, /issues\/42\/comments -f/); + assert.doesNotMatch(r.calls, /--method POST/); }); test('a persistently failing identity lookup exits 1 without writing', () => { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c6df710a..53c0cdf83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,6 @@ # .github/workflows/ci.yml -name: 'Qwen Code CI' +name: 'OpenWork CI' on: # No `push` trigger: every job here is gated to pull_request / merge_group, so @@ -50,7 +50,7 @@ env: # BOTH the github_ci_only helper step and the full-profile Test step, so a # new helper test can't be added to one path and silently dropped from the # other. - HELPER_TESTS: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/resolve-sandbox-image.test.mjs .github/scripts/web-shell-visuals-publish.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/ci-runner-routing.test.mjs' + HELPER_TESTS: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/resolve-sandbox-image.test.mjs .github/scripts/web-shell-visuals-publish.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/ci-runner-routing.test.mjs .github/scripts/openwork-workflows.test.mjs' jobs: classify_pr: @@ -807,18 +807,14 @@ jobs: node -e "const fs = require('node:fs'); for (const key of ['HOME', 'USERPROFILE']) { const dir = process.env[key]; if (dir) fs.mkdirSync(dir, { recursive: true }); }" npm run test:ci - # Windows counterpart of test_macos (see that job's note). ECS is the default - # with a windows-2022 kill-switch fallback; the check name stays unchanged so - # it matches the required-status-check context. The job is merge_group-only, - # so code reaching it is post-approval; maintainers can still queue fork PRs. - # The runs-on expression therefore needs only the kill switch. ECS-only - # tuning is gated on runner.environment; the hosted fallback is the pre-ECS - # job plus the checkout guard and a job-level timeout-minutes. + # Windows counterpart of test_macos (see that job's note). Qwen Code uses its + # ECS runner by default; OpenWork has no self-hosted Windows runner and stays + # on windows-2022. The check name remains stable for branch protection. test_windows: name: 'Test (windows-latest, Node 22.x)' needs: 'classify_pr' if: "${{ !cancelled() && github.event_name == 'merge_group' }}" - runs-on: '${{ vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && fromJSON(''["self-hosted", "Windows", "X64", "ecs-win"]'') || fromJSON(''["windows-2022"]'') }}' + runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "Windows", "X64", "ecs-win"]'') || fromJSON(''["windows-2022"]'') }}' timeout-minutes: 60 permissions: contents: 'read' @@ -828,9 +824,7 @@ jobs: # autocrlf on checks out LF-only files. Repository-local `./` actions # resolve from the job workspace, so the checkout must precede them; # the rest of the self-hosted tuning runs after the checkout via the - # configure-windows-runner action, shared verbatim with - # windows-runner-smoke.yml so the runner-validation smoke exercises - # exactly what this gate uses. LC_ALL mirrors the Linux gates' locale + # configure-windows-runner action. LC_ALL mirrors the Linux gates' locale # env (inert on Windows, where Node collates through ICU), and Git Bash # goes on PATH so the remaining steps can run under the workflow-level # bash default. @@ -850,9 +844,9 @@ jobs: if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment == 'self-hosted' }}" uses: './.github/actions/configure-windows-runner' - # Same stale-checkout guard as the Ubuntu gate: this job now runs on ECS, - # so fail loud if the checkout lacks the merge-queue head rather than - # silently testing the wrong tree into a merge. + # Same stale-checkout guard as the Ubuntu gate: Qwen Code may run this on + # ECS, so fail loud if the checkout lacks the merge-queue head rather + # than silently testing the wrong tree into a merge. - name: 'Verify checkout includes expected head commit' if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" uses: './.github/actions/verify-checkout-head' @@ -950,6 +944,7 @@ jobs: path: 'coverage_artifact' # Download to a specific directory - name: 'Post Coverage Comment using Composite Action' + continue-on-error: true uses: './.github/actions/post-coverage-comment' # Path to the composite action directory with: cli_json_file: 'coverage_artifact/cli/coverage/coverage-summary.json' @@ -960,25 +955,15 @@ jobs: os: '${{ matrix.os }}' github_token: '${{ secrets.GITHUB_TOKEN }}' - # Integration tests run only in the merge queue, not on every PR push. - # They are the suite that previously ran *only* in the nightly Release - # pipeline (`release.yml`), so regressions stayed hidden until release - # time. Gating them on `merge_group` catches the failure before the PR - # lands on `main`, while keeping the per-PR critical path fast. The - # `merge_group` event runs in the base-repo context, so the same model - # secrets used by the release jobs are available here. - # - # Until merge queue is enabled on `main` this job simply never triggers, - # so adding it is a no-op for existing PR/push runs. Reuses the exact - # `test:integration:cli:sandbox:none` script from `release.yml`. + # Qwen Code runs model-backed integration tests in its merge queue. OpenWork + # does not own those OPENAI_* credentials, so the repository gate keeps this + # imported job disabled here. The no-AK integration gate above remains active. integration_cli: name: 'Integration Tests (CLI, No Sandbox)' needs: 'classify_pr' - # Same ECS routing as the Ubuntu gate (via classify_pr): the merge queue runs - # in the base-repo context, so use the self-hosted ECS pool and keep the - # scarce hosted Linux runners free. Falls back to hosted if classify_pr is - # skipped or the ECS kill-switch is set. - if: "${{ !cancelled() && github.event_name == 'merge_group' }}" + # Same ECS routing as the Ubuntu gate for Qwen Code. This job is skipped in + # OpenWork by the repository gate above. + if: "${{ !cancelled() && github.repository == 'QwenLM/qwen-code' && github.event_name == 'merge_group' }}" runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'') }}' permissions: contents: 'read' diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml new file mode 100644 index 000000000..284dd6b25 --- /dev/null +++ b/.github/workflows/desktop-build.yml @@ -0,0 +1,400 @@ +name: 'Desktop Build' + +on: + workflow_call: + inputs: + version: + required: true + type: 'string' + release_name: + required: true + type: 'string' + tag: + required: true + type: 'string' + electron_bridge: + required: true + type: 'boolean' + publish: + required: true + type: 'boolean' + draft: + required: true + type: 'boolean' + prerelease: + required: true + type: 'boolean' + +jobs: + desktop: + name: '${{ matrix.name }}' + runs-on: '${{ matrix.os }}' + timeout-minutes: 120 + permissions: + contents: 'read' + strategy: + fail-fast: false + matrix: + include: + - name: 'macOS Apple Silicon' + os: 'macos-15' + target: 'aarch64-apple-darwin' + legacy_arch: 'arm64' + - name: 'macOS Intel' + os: 'macos-15-intel' + target: 'x86_64-apple-darwin' + legacy_arch: 'x64' + - name: 'Windows x64' + os: 'windows-2025' + target: 'x86_64-pc-windows-msvc' + - name: 'Linux x64' + os: 'ubuntu-22.04' + target: 'x86_64-unknown-linux-gnu' + steps: + - name: 'Check out source' + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + with: + persist-credentials: false + + - name: 'Set up Node.js' + uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: 'Set up Rust' + uses: 'dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4' # stable + with: + targets: '${{ matrix.target }}' + + - name: 'Cache Rust dependencies' + uses: 'Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6' # v2.9.2 + with: + workspaces: 'packages/desktop-shell/src-tauri -> target' + + - name: 'Install Linux dependencies' + if: "runner.os == 'Linux'" + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libfuse2 + + - name: 'Validate signing configuration' + if: 'inputs.publish' + shell: 'bash' + env: + APPLE_API_ISSUER: '${{ secrets.APPLE_API_ISSUER || secrets.APPLE_NOTARY_ISSUER_ID }}' + APPLE_API_KEY: '${{ secrets.APPLE_API_KEY || secrets.APPLE_NOTARY_KEY_ID }}' + APPLE_API_KEY_P8_INPUT: '${{ secrets.APPLE_API_KEY_P8_BASE64 || secrets.APPLE_NOTARY_API_KEY_P8_BASE64 }}' + APPLE_CERTIFICATE_INPUT: '${{ secrets.APPLE_CERTIFICATE || secrets.MAC_CSC_LINK }}' + APPLE_CERTIFICATE_PASSWORD: '${{ secrets.APPLE_CERTIFICATE_PASSWORD || secrets.MAC_CSC_KEY_PASSWORD }}' + OPENWORK_UPDATER_PUBLIC_KEY: '${{ secrets.TAURI_SIGNING_PUBLIC_KEY }}' + TAURI_SIGNING_PRIVATE_KEY: '${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}' + WINDOWS_CERTIFICATE_INPUT: '${{ secrets.WINDOWS_CERTIFICATE || secrets.WIN_CSC_LINK }}' + WINDOWS_CERTIFICATE_PASSWORD_INPUT: '${{ secrets.WINDOWS_CERTIFICATE_PASSWORD || secrets.WIN_CSC_KEY_PASSWORD }}' + run: | + set -euo pipefail + if [[ -z "$TAURI_SIGNING_PRIVATE_KEY" || -z "$OPENWORK_UPDATER_PUBLIC_KEY" ]]; then + echo "::error::Published releases require TAURI_SIGNING_PRIVATE_KEY and TAURI_SIGNING_PUBLIC_KEY." + exit 1 + fi + if [[ "$RUNNER_OS" == "macOS" && ( -z "$APPLE_CERTIFICATE_INPUT" || -z "$APPLE_CERTIFICATE_PASSWORD" || -z "$APPLE_API_ISSUER" || -z "$APPLE_API_KEY" || -z "$APPLE_API_KEY_P8_INPUT" ) ]]; then + echo "::error::Published macOS releases require signing and App Store Connect notarization secrets." + exit 1 + fi + if [[ "$RUNNER_OS" == "Windows" && ( -z "$WINDOWS_CERTIFICATE_INPUT" || -z "$WINDOWS_CERTIFICATE_PASSWORD_INPUT" ) ]]; then + echo "::error::Published Windows releases require an Authenticode certificate and password." + exit 1 + fi + + - name: 'Install dependencies' + run: 'npm ci --no-audit --progress=false' + + - name: 'Install desktop tooling' + run: 'npm ci --prefix packages/desktop-shell --workspaces=false --no-audit --progress=false' + + - name: 'Set desktop version' + run: 'node packages/desktop-shell/scripts/version.js "${{ inputs.version }}"' + + - name: 'Prepare bundled runtime' + env: + OPENWORK_DESKTOP_TARGET: '${{ matrix.target }}' + run: 'npm run build:runtime --prefix packages/desktop-shell --workspaces=false' + + - name: 'Run desktop tests' + run: 'npm test --prefix packages/desktop-shell --workspaces=false' + + - name: 'Run desktop release tests' + run: 'npm run test:release --prefix packages/desktop-shell --workspaces=false' + + - name: 'Configure macOS signing and notarization' + if: "runner.os == 'macOS' && inputs.publish" + shell: 'bash' + env: + APPLE_API_KEY: '${{ secrets.APPLE_API_KEY || secrets.APPLE_NOTARY_KEY_ID }}' + APPLE_API_KEY_P8_INPUT: '${{ secrets.APPLE_API_KEY_P8_BASE64 || secrets.APPLE_NOTARY_API_KEY_P8_BASE64 }}' + APPLE_CERTIFICATE_INPUT: '${{ secrets.APPLE_CERTIFICATE || secrets.MAC_CSC_LINK }}' + APPLE_CERTIFICATE_PASSWORD: '${{ secrets.APPLE_CERTIFICATE_PASSWORD || secrets.MAC_CSC_KEY_PASSWORD }}' + run: | + set -euo pipefail + certificate_path="$RUNNER_TEMP/openwork-signing.p12" + if [[ "$APPLE_CERTIFICATE_INPUT" =~ ^https?:// ]]; then + curl --fail --silent --show-error --location "$APPLE_CERTIFICATE_INPUT" --output "$certificate_path" + else + CERTIFICATE_PATH="$certificate_path" node -e "require('node:fs').writeFileSync(process.env.CERTIFICATE_PATH, Buffer.from(process.env.APPLE_CERTIFICATE_INPUT.replace(/^.*base64,/, ''), 'base64'))" + fi + keychain="$RUNNER_TEMP/openwork-signing.keychain-db" + keychain_password="$(openssl rand -hex 32)" + security create-keychain -p "$keychain_password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + security import "$certificate_path" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$keychain" + security list-keychains -d user -s "$keychain" login.keychain-db + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain" + identity="$(security find-identity -v -p codesigning "$keychain" | sed -n 's/.*"\(Developer ID Application:.*\)"/\1/p' | head -n 1)" + if [[ -z "$identity" ]]; then + echo "::error::Developer ID Application identity was not found." + exit 1 + fi + echo "APPLE_SIGNING_IDENTITY=$identity" >> "$GITHUB_ENV" + key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY}.p8" + APPLE_KEY_PATH="$key_path" node -e "require('node:fs').writeFileSync(process.env.APPLE_KEY_PATH, Buffer.from(process.env.APPLE_API_KEY_P8_INPUT, 'base64'), { mode: 0o600 })" + echo "APPLE_API_KEY_PATH=$key_path" >> "$GITHUB_ENV" + + - name: 'Import Windows signing certificate' + if: "runner.os == 'Windows' && inputs.publish" + shell: 'pwsh' + env: + WINDOWS_CERTIFICATE_INPUT: '${{ secrets.WINDOWS_CERTIFICATE || secrets.WIN_CSC_LINK }}' + WINDOWS_CERTIFICATE_PASSWORD_INPUT: '${{ secrets.WINDOWS_CERTIFICATE_PASSWORD || secrets.WIN_CSC_KEY_PASSWORD }}' + run: | + $certificatePath = Join-Path $env:RUNNER_TEMP 'openwork-signing.pfx' + if ($env:WINDOWS_CERTIFICATE_INPUT -match '^https?://') { + Invoke-WebRequest -Uri $env:WINDOWS_CERTIFICATE_INPUT -OutFile $certificatePath + } else { + $encoded = $env:WINDOWS_CERTIFICATE_INPUT -replace '^.*base64,', '' + [IO.File]::WriteAllBytes($certificatePath, [Convert]::FromBase64String($encoded)) + } + $password = ConvertTo-SecureString $env:WINDOWS_CERTIFICATE_PASSWORD_INPUT -AsPlainText -Force + $certificate = Import-PfxCertificate -FilePath $certificatePath -CertStoreLocation Cert:\CurrentUser\My -Password $password + if (-not $certificate.HasPrivateKey) { throw 'The Windows certificate has no private key.' } + "WINDOWS_CERTIFICATE_THUMBPRINT=$($certificate.Thumbprint)" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: 'Sign bundled runtime binaries (macOS)' + if: "runner.os == 'macOS' && inputs.publish" + shell: 'bash' + run: | + set -euo pipefail + while IFS= read -r -d '' binary; do + if ! file "$binary" | grep -q 'Mach-O'; then continue; fi + args=(--force --sign "$APPLE_SIGNING_IDENTITY" --options runtime --timestamp) + if [[ "$binary" == */node/bin/node ]]; then + args+=(--entitlements packages/desktop-shell/src-tauri/NodeEntitlements.plist) + fi + codesign "${args[@]}" "$binary" + done < <(find packages/desktop-shell/runtime/openwork -type f -print0) + + - name: 'Refresh bundled runtime checksums (macOS)' + if: "runner.os == 'macOS' && inputs.publish" + run: 'node packages/desktop-shell/scripts/prepare-runtime.js --refresh-checksums' + + - name: 'Verify bundled runtime' + run: 'npm run smoke:runtime --prefix packages/desktop-shell --workspaces=false' + + - name: 'Configure platform signing' + shell: 'bash' + env: + IS_PUBLISH: '${{ inputs.publish }}' + OPENWORK_UPDATER_PUBLIC_KEY: "${{ inputs.publish && secrets.TAURI_SIGNING_PUBLIC_KEY || '' }}" + run: | + node --input-type=module -e "import fs from 'node:fs'; const publish = process.env.IS_PUBLISH === 'true'; const thumbprint = process.env.WINDOWS_CERTIFICATE_THUMBPRINT; const config = { bundle: { createUpdaterArtifacts: publish, ...(thumbprint ? { windows: { certificateThumbprint: thumbprint } } : {}) }, ...(publish ? { plugins: { updater: { pubkey: process.env.OPENWORK_UPDATER_PUBLIC_KEY } } } : {}) }; fs.writeFileSync('packages/desktop-shell/src-tauri/release.conf.json', JSON.stringify(config));" + + - name: 'Build desktop artifacts' + uses: 'tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f' # v1.0.0 + env: + APPLE_API_ISSUER: "${{ runner.os == 'macOS' && inputs.publish && (secrets.APPLE_API_ISSUER || secrets.APPLE_NOTARY_ISSUER_ID) || '' }}" + APPLE_API_KEY: "${{ runner.os == 'macOS' && inputs.publish && (secrets.APPLE_API_KEY || secrets.APPLE_NOTARY_KEY_ID) || '' }}" + APPLE_CERTIFICATE_PASSWORD: "${{ runner.os == 'macOS' && inputs.publish && (secrets.APPLE_CERTIFICATE_PASSWORD || secrets.MAC_CSC_KEY_PASSWORD) || '' }}" + TAURI_SIGNING_PRIVATE_KEY: "${{ inputs.publish && secrets.TAURI_SIGNING_PRIVATE_KEY || '' }}" + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: "${{ inputs.publish && secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD || '' }}" + with: + projectPath: 'packages/desktop-shell' + args: '--config src-tauri/release.conf.json --target ${{ matrix.target }}' + uploadUpdaterJson: false + uploadUpdaterSignatures: false + updaterJsonPreferNsis: true + uploadWorkflowArtifacts: false + + - name: 'Verify macOS signature' + if: "runner.os == 'macOS' && inputs.publish" + shell: 'bash' + run: | + set -euo pipefail + app="$(find packages/desktop-shell/src-tauri/target/${{ matrix.target }}/release/bundle/macos -maxdepth 1 -name '*.app' -print -quit)" + codesign --verify --deep --strict --verbose=2 "$app" + spctl --assess --type execute --verbose=2 "$app" + + - name: 'Verify Windows signature' + if: "runner.os == 'Windows' && inputs.publish" + shell: 'pwsh' + run: | + $installer = Get-ChildItem packages/desktop-shell/src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*.exe | Select-Object -First 1 + $signature = Get-AuthenticodeSignature $installer.FullName + if ($signature.Status -ne 'Valid') { throw "Invalid Authenticode signature: $($signature.Status)" } + + - name: 'Create Electron bridge archive' + if: "runner.os == 'macOS' && inputs.electron_bridge" + shell: 'bash' + env: + LEGACY_ARCH: '${{ matrix.legacy_arch }}' + RELEASE_VERSION: '${{ inputs.version }}' + run: | + set -euo pipefail + app="$(find packages/desktop-shell/src-tauri/target/${{ matrix.target }}/release/bundle/macos -maxdepth 1 -name 'OpenWork.app' -print -quit)" + if [[ -z "$app" ]]; then + echo '::error::The OpenWork macOS app bundle was not produced.' + exit 1 + fi + destination="packages/desktop-shell/src-tauri/target/${{ matrix.target }}/release/bundle/electron-bridge" + mkdir -p "$destination" + ditto -c -k --sequesterRsrc --keepParent "$app" "$destination/OpenWork_${RELEASE_VERSION}_${LEGACY_ARCH}.zip" + + - name: 'Smoke packaged application (macOS)' + if: "runner.os == 'macOS'" + shell: 'bash' + working-directory: 'packages/desktop-shell' + run: | + set -euo pipefail + executable="$(find src-tauri/target/${{ matrix.target }}/release/bundle/macos -path '*.app/Contents/MacOS/*' -type f -perm -111 -print -quit)" + npm run smoke:packaged -- "$executable" + + - name: 'Smoke packaged application (Windows)' + if: "runner.os == 'Windows'" + shell: 'pwsh' + working-directory: 'packages/desktop-shell' + run: | + $executable = Get-ChildItem src-tauri/target/${{ matrix.target }}/release/openwork-desktop.exe | Select-Object -First 1 + npm run smoke:packaged -- $executable.FullName + + - name: 'Smoke packaged application (Linux)' + if: "runner.os == 'Linux'" + shell: 'bash' + working-directory: 'packages/desktop-shell' + run: 'xvfb-run -a npm run smoke:packaged -- src-tauri/target/${{ matrix.target }}/release/openwork-desktop' + + - name: 'Collect verified artifacts' + shell: 'bash' + env: + LEGACY_ARCH: '${{ matrix.legacy_arch }}' + RELEASE_VERSION: '${{ inputs.version }}' + run: | + set -euo pipefail + destination="$RUNNER_TEMP/openwork-desktop-artifacts" + mkdir -p "$destination" + bundle_root="packages/desktop-shell/src-tauri/target/${{ matrix.target }}/release/bundle" + while IFS= read -r -d '' artifact; do + name="$(basename "$artifact")" + if [[ "$RUNNER_OS" == 'macOS' ]]; then + case "$name" in + *.app.tar.gz.sig) name="${name%.app.tar.gz.sig}-${{ matrix.target }}.app.tar.gz.sig" ;; + *.app.tar.gz) name="${name%.app.tar.gz}-${{ matrix.target }}.app.tar.gz" ;; + *.dmg) name="OpenWork_${RELEASE_VERSION}_${LEGACY_ARCH}.dmg" ;; + OpenWork_*.zip) ;; + *) continue ;; + esac + elif [[ "$RUNNER_OS" == 'Windows' ]]; then + case "$name" in *-setup.exe|*-setup.exe.sig) ;; *) continue ;; esac + else + case "$name" in *.AppImage|*.AppImage.sig|*.deb|*.deb.sig) ;; *) continue ;; esac + fi + cp "$artifact" "$destination/${name// /-}" + done < <(find "$bundle_root" -mindepth 2 -maxdepth 2 -type f \( -name '*.dmg' -o -name '*.AppImage' -o -name '*.deb' -o -name '*.exe' -o -name '*.zip' -o -name '*.app.tar.gz' -o -name '*.sig' \) -print0) + if [[ -z "$(find "$destination" -type f -print -quit)" ]]; then + echo '::error::No desktop artifacts were produced.' + exit 1 + fi + + - name: 'Upload verified artifacts' + uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # v4 + with: + name: 'openwork-desktop-${{ matrix.target }}' + path: '${{ runner.temp }}/openwork-desktop-artifacts/*' + if-no-files-found: 'error' + retention-days: 14 + + publish: + name: 'Publish verified release' + if: 'inputs.publish' + needs: 'desktop' + runs-on: 'ubuntu-latest' + steps: + - name: 'Check out source' + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + with: + persist-credentials: false + + - name: 'Download verified artifacts' + uses: 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093' # v4 + with: + pattern: 'openwork-desktop-*' + path: 'release-assets' + merge-multiple: true + + - name: 'Generate updater manifest and checksums' + shell: 'bash' + env: + ELECTRON_BRIDGE: '${{ inputs.electron_bridge }}' + RELEASE_TAG: '${{ inputs.tag }}' + RELEASE_VERSION: '${{ inputs.version }}' + run: | + set -euo pipefail + node .github/scripts/create-desktop-update-manifest.mjs --assets release-assets --repository "$GITHUB_REPOSITORY" --tag "$RELEASE_TAG" --version "$RELEASE_VERSION" --output release-assets/latest.json + if [[ "$ELECTRON_BRIDGE" == 'true' ]]; then + for manifest in macos:latest-mac.yml windows:latest.yml linux:latest-linux.yml; do + platform="${manifest%%:*}" + output="${manifest#*:}" + node .github/scripts/create-electron-bridge-manifest.mjs --assets release-assets --platform "$platform" --version "$RELEASE_VERSION" --output "release-assets/$output" + done + fi + (cd release-assets && sha256sum -- * > SHA256SUMS.txt) + + - name: 'Create GitHub release' + env: + GH_TOKEN: '${{ github.token }}' + ELECTRON_BRIDGE: '${{ inputs.electron_bridge }}' + RELEASE_DRAFT: '${{ inputs.draft }}' + RELEASE_NAME: '${{ inputs.release_name }}' + RELEASE_PRERELEASE: '${{ inputs.prerelease }}' + RELEASE_TAG: '${{ inputs.tag }}' + run: | + set -euo pipefail + args=("$RELEASE_TAG" release-assets/* --target "$GITHUB_SHA" --title "$RELEASE_NAME" --generate-notes) + if [[ "$RELEASE_DRAFT" == 'false' && "$RELEASE_PRERELEASE" == 'false' && "$ELECTRON_BRIDGE" == 'true' ]]; then args+=(--latest); else args+=(--latest=false); fi + if [[ "$RELEASE_DRAFT" == 'true' ]]; then args+=(--draft); fi + if [[ "$RELEASE_PRERELEASE" == 'true' ]]; then args+=(--prerelease); fi + gh release create "${args[@]}" + + - name: 'Update stable updater feed' + if: 'inputs.draft == false && inputs.prerelease == false' + env: + GH_TOKEN: '${{ github.token }}' + RELEASE_VERSION: '${{ inputs.version }}' + run: | + set -euo pipefail + if gh release view desktop-latest >/dev/null 2>&1; then + directory="$(mktemp -d)" + trap 'rm -rf "$directory"' EXIT + gh release download desktop-latest --dir "$directory" --pattern 'latest.json' + current="$(jq -r '.version' "$directory/latest.json")" + if [[ ! "$current" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Current Desktop stable feed has an invalid version: $current" + exit 1 + fi + newest="$(printf '%s\n%s\n' "$RELEASE_VERSION" "$current" | sort -V | tail -n 1)" + if [[ "$current" != "$RELEASE_VERSION" && "$newest" == "$current" ]]; then + echo "::notice::Desktop $RELEASE_VERSION will not replace newer stable feed $current." + exit 0 + fi + gh release upload desktop-latest release-assets/latest.json --clobber + else + gh release create desktop-latest release-assets/latest.json --title 'OpenWork Desktop latest' --notes 'Stable desktop updater feed.' --latest=false + fi diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml new file mode 100644 index 000000000..bd9c480ff --- /dev/null +++ b/.github/workflows/desktop-release.yml @@ -0,0 +1,134 @@ +name: 'Desktop Release' + +run-name: 'Desktop release ${{ inputs.version }}' + +on: + workflow_dispatch: + inputs: + version: + description: 'Desktop semantic version, for example 0.2.0' + required: true + type: 'string' + release_name: + description: 'Release title. Defaults to openwork-v.' + required: false + type: 'string' + electron_bridge: + description: 'Publish Electron-compatible update manifests and payloads for macOS, Windows, and Linux.' + required: true + default: true + type: 'boolean' + dry_run: + description: 'Build installers without publishing a release.' + required: true + default: true + type: 'boolean' + draft: + description: 'Create a draft GitHub release.' + required: true + default: true + type: 'boolean' + prerelease: + description: 'Mark the GitHub release as a prerelease.' + required: true + default: false + type: 'boolean' + +permissions: + contents: 'read' + +concurrency: + group: "desktop-release-${{ inputs.dry_run && inputs.version || 'publish' }}" + cancel-in-progress: false + +jobs: + metadata: + name: 'Validate release' + runs-on: 'ubuntu-latest' + timeout-minutes: 5 + outputs: + release_name: '${{ steps.release.outputs.release_name }}' + tag: '${{ steps.release.outputs.tag }}' + version: '${{ steps.release.outputs.version }}' + steps: + - id: 'release' + name: 'Validate version and source' + shell: 'bash' + env: + ELECTRON_BRIDGE: '${{ inputs.electron_bridge }}' + INPUT_VERSION: '${{ inputs.version }}' + INPUT_RELEASE_NAME: '${{ inputs.release_name }}' + IS_DRAFT: '${{ inputs.draft }}' + IS_DRY_RUN: '${{ inputs.dry_run }}' + IS_PRERELEASE: '${{ inputs.prerelease }}' + SOURCE_BRANCH: '${{ github.ref_name }}' + run: | + set -euo pipefail + version="${INPUT_VERSION#v}" + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Invalid semantic version: $INPUT_VERSION" + exit 1 + fi + if [[ "$IS_DRY_RUN" == "false" && "$SOURCE_BRANCH" != "main" ]]; then + echo "::error::Published desktop releases must run from main." + exit 1 + fi + if [[ "$IS_PRERELEASE" == "true" && ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+- ]]; then + echo "::error::Prereleases require a SemVer prerelease suffix: $INPUT_VERSION" + exit 1 + fi + if [[ "$IS_DRY_RUN" == "false" && "$IS_DRAFT" == "false" && "$IS_PRERELEASE" == "false" && ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Published stable releases require an X.Y.Z version: $INPUT_VERSION" + exit 1 + fi + if [[ "$ELECTRON_BRIDGE" == "true" ]]; then + core="${version%%[-+]*}" + IFS='.' read -r major minor _ <<< "$core" + if [[ "$major" -eq 0 && "$minor" -lt 2 ]]; then + echo "::error::The Electron bridge starts at OpenWork 0.2.0: $INPUT_VERSION" + exit 1 + fi + fi + if [[ "$INPUT_RELEASE_NAME" == *$'\n'* || "$INPUT_RELEASE_NAME" == *$'\r'* || ${#INPUT_RELEASE_NAME} -gt 200 ]]; then + echo "::error::Release names must be a single line up to 200 characters." + exit 1 + fi + tag="openwork-v$version" + { + echo "version=$version" + echo "tag=$tag" + echo "release_name=${INPUT_RELEASE_NAME:-$tag}" + } >> "$GITHUB_OUTPUT" + + build: + name: 'Build installers' + needs: 'metadata' + if: 'inputs.dry_run == true' + permissions: + contents: 'read' + uses: './.github/workflows/desktop-build.yml' + with: + version: '${{ needs.metadata.outputs.version }}' + release_name: '${{ needs.metadata.outputs.release_name }}' + tag: '${{ needs.metadata.outputs.tag }}' + electron_bridge: '${{ inputs.electron_bridge }}' + publish: false + draft: '${{ inputs.draft }}' + prerelease: '${{ inputs.prerelease }}' + + publish: + name: 'Build and publish installers' + needs: 'metadata' + if: 'inputs.dry_run == false' + permissions: + contents: 'write' + uses: './.github/workflows/desktop-build.yml' + with: + version: '${{ needs.metadata.outputs.version }}' + release_name: '${{ needs.metadata.outputs.release_name }}' + tag: '${{ needs.metadata.outputs.tag }}' + electron_bridge: '${{ inputs.electron_bridge }}' + publish: true + draft: '${{ inputs.draft }}' + prerelease: '${{ inputs.prerelease }}' + secrets: 'inherit' diff --git a/.gitignore b/.gitignore index 493d7b8af..f65547da4 100644 --- a/.gitignore +++ b/.gitignore @@ -95,6 +95,7 @@ pr_body.md packages/cli/src/generated/ packages/core/src/generated/ packages/web-templates/src/generated/ +packages/desktop-shell/src-tauri/permissions/autogenerated/ .integration-tests/ packages/vscode-ide-companion/*.vsix diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 78d6be911..eb4be8347 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -97,13 +97,16 @@ This section guides contributors on how to build, modify, and understand the dev ### Build Process -To clone the repository: +To clone OpenWork: ```bash -git clone https://github.com/QwenLM/qwen-code.git # Or your fork's URL -cd qwen-code +git clone https://github.com/modelstudioai/openwork.git +cd openwork ``` +Maintainers who synchronize Qwen Code should also configure the fetch-only +`qwen-upstream` remote and follow the [upstream maintenance guide](./docs/developers/openwork-upstream-maintenance.md). + To install dependencies defined in `package.json` as well as root dependencies: ```bash @@ -138,6 +141,9 @@ To start the Qwen Code application from the source code (after building), run th npm start ``` +For the OpenWork desktop app, use the commands in +[`packages/desktop-shell/README.md`](./packages/desktop-shell/README.md). + If you'd like to run the source build outside of the qwen-code folder, you can utilize `npm link path/to/qwen-code/packages/cli` (see: [docs](https://docs.npmjs.com/cli/v9/commands/npm-link)) to run with `qwen-code` ### Running Tests diff --git a/docs/design/2026-07-31-desktop-web-shell-release.md b/docs/design/2026-07-31-desktop-web-shell-release.md index 1c64f63d8..59d40dc24 100644 --- a/docs/design/2026-07-31-desktop-web-shell-release.md +++ b/docs/design/2026-07-31-desktop-web-shell-release.md @@ -32,7 +32,7 @@ flowchart LR C -->|authenticated loopback URL| D[Existing Web Shell] A -->|retry / choose workspace / logs| B B -->|exit event| A - E[GitHub latest.json + installers] -->|signed updater| B + E[GitHub desktop-latest/latest.json + installers] -->|signed updater| B ``` ### 组件职责 @@ -128,7 +128,7 @@ Tauri updater 使用签名更新产物和固定公开 key。应用启动后后 - 检查失败:写日志,不阻塞启动。 - 有更新:bootstrap/Web Shell 上方显示原生确认对话框;用户确认后下载并安装,然后重启。 -发布 CI 使用 `TAURI_SIGNING_PRIVATE_KEY` 与 `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` 生成 updater signatures。`latest.json` 指向同一 GitHub Release 的平台更新包。只有非 draft、非 prerelease 发布会更新固定的 `desktop-latest` feed release。 +发布 CI 使用 `TAURI_SIGNING_PRIVATE_KEY` 与 `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` 生成 updater signatures。`latest.json` 指向版本化 GitHub Release 的平台更新包。只有非 draft、非 prerelease 发布会更新固定的 `desktop-latest` feed release,客户端只读取该固定 feed。 ## 平台发布矩阵 diff --git a/docs/design/desktop-electron-to-tauri-update-bridge.md b/docs/design/desktop-electron-to-tauri-update-bridge.md index bc3a98d6c..c3c8c0c1a 100644 --- a/docs/design/desktop-electron-to-tauri-update-bridge.md +++ b/docs/design/desktop-electron-to-tauri-update-bridge.md @@ -2,65 +2,21 @@ ## Context -The last published desktop release, `desktop-v0.0.5`, is an Electron app named `Qwen Code Desktop` with bundle identifier `com.alibaba.qwen-code`. Its macOS updater reads `latest-mac.yml` from the fixed `desktop-latest` release and installs a ZIP archive. - -The new desktop shell is a Tauri app. It currently uses a different product name and bundle identifier and publishes `desktop-latest.json`, so the existing Electron app cannot discover or replace it. - -## Goals - -- Let signed macOS Electron `0.0.5` installations update directly to the first stable Tauri release. -- Preserve the existing macOS application identity so the updater replaces the installed app bundle. -- Keep Tauri's signed updater feed for all releases after the migration. -- Make the bridge opt-in and one-time; later releases must not need Electron build tooling. - -## Non-goals - -- Migrating Electron settings, sessions, or workspace state. The Tauri app may ask for a workspace on first launch. -- Bridging Windows or Linux Electron installations. -- Generating Electron differential blockmaps. Electron updater falls back to the checksum-verified full ZIP. +OpenWork Electron releases use GitHub's latest stable release and read `latest-mac.yml`, `latest.yml`, or `latest-linux.yml`. Tauri reads `latest.json` from the fixed `desktop-latest` release. A stable Tauri release must therefore publish both update formats, and the versioned release must remain GitHub Latest for legacy clients. ## Compatibility contract -The Tauri bundle uses the legacy macOS identity: - -- product name: `Qwen Code Desktop` -- bundle identifier: `com.alibaba.qwen-code` -- artifact prefix: `Qwen-Code-Desktop` -- signing identity: the existing Developer ID Application certificate - -The bridge release must be newer than `0.0.5`. It publishes two updater views over the same signed app bundles: - -1. `latest-mac.yml` points legacy Electron clients at `Qwen-Code-Desktop-arm64.zip` or `Qwen-Code-Desktop-x64.zip`. -2. `desktop-latest.json` points Tauri clients at the signed Tauri updater archives. - -The ZIP is created from the already signed and notarized `.app`; it is not rebuilt by Electron tooling. - -## Release flow - -`Desktop Release` gains an `electron_bridge` input, disabled by default. - -- All macOS builds continue to produce the Tauri app, DMG, updater archive, and updater signature. -- When `electron_bridge` is enabled, each macOS build also creates a legacy-compatible ZIP. -- The publish job generates `latest-mac.yml` from the two ZIPs and two DMGs. -- A stable bridge release uploads the legacy metadata and payloads to `desktop-latest` together with `desktop-latest.json`. -- Later stable releases leave `electron_bridge` disabled. Updating `desktop-latest.json` does not remove the bridge files, so Electron installations that return later can still cross to Tauri. - -Draft and prerelease runs may build and publish bridge artifacts for inspection, but they never update the stable feed. - -## Signing credentials - -The repository already stores the Electron-era Apple certificate and App Store Connect API key under `MAC_CSC_*` and `APPLE_NOTARY_*` secret names. The workflow accepts those names as fallbacks for the newer Tauri names, so the Developer ID identity remains unchanged. +OpenWork 0.2.0 keeps the Electron product name `OpenWork` and application identifier `com.alibaba.openwork`. With `electron_bridge` enabled, a release contains: -Tauri updater artifacts additionally require `TAURI_SIGNING_PRIVATE_KEY`; `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` is only needed for an encrypted private key. The private key must match the public key in the Tauri configuration before the first published Tauri release. +- `latest-mac.yml` plus versioned ZIP and DMG payloads for Apple Silicon and Intel; +- `latest.yml` plus the x64 NSIS installer for Windows; +- `latest-linux.yml` plus the x64 AppImage for Linux; +- `latest.json` and signed updater archives for Tauri clients. -## Validation +The macOS ZIPs are created from the signed and notarized Tauri app. Windows removes the matching per-user Electron installation through its registered uninstaller before Tauri writes files, preserving user data and avoiding duplicate uninstall entries. Linux AppImage updates replace the current AppImage directly. -Automated release-helper tests verify: +## Release usage -- the legacy application identity, -- exact bridge artifact selection, -- SHA-512 and size values in `latest-mac.yml`, -- failure when a required bridge artifact is missing, -- existing Tauri updater manifest and version synchronization behavior. +`Desktop Release` defaults `electron_bridge` to true. For a stable release, use `dry_run=false`, `draft=false`, and `prerelease=false`. A stable bridge release is marked GitHub Latest and updates the fixed Tauri feed. Keep the bridge enabled on later stable releases while Electron installations remain supported. Once support is intentionally retired, disable it; later Tauri-only releases use `--latest=false`, so the previous bridge release remains GitHub Latest for dormant Electron clients. -Before the stable release, install the signed `desktop-v0.0.5` arm64 and x64 builds, point them at an isolated bridge feed, and verify both `0.0.5 -> Tauri bridge` and `Tauri bridge -> newer Tauri` updates. +Before publishing, verify signed 0.1.4 clients on each platform can install the bridge and that the resulting Tauri app can then update to a newer Tauri release. Retire the bridge only after the legacy support window is explicitly closed. diff --git a/docs/design/openwork-tauri-customization-audit.md b/docs/design/openwork-tauri-customization-audit.md index df79c1405..4401812be 100644 --- a/docs/design/openwork-tauri-customization-audit.md +++ b/docs/design/openwork-tauri-customization-audit.md @@ -34,7 +34,7 @@ Status values: | Session renderer compatibility | Intermediate/commentary rendering, tool errors, subagent history, duration and ordering fixes | Web Shell has its own daemon-native timeline and structured message implementation | Upstream equivalent | PR1 verification | | Image/file attachments | Preserve Qwen image attachment metadata and ordering | Web Shell has native attachment support; desktop-path parity is not yet exercised end to end | Partial | PR2 parity verification | | Approval mode and permissions | Persist approval mode through Qwen settings; align permission settings with Qwen policy | Web Shell and daemon expose approval/permission controls | Upstream equivalent | PR1 verification | -| Model and thinking controls | Persist model changes, refresh model defaults, provider-managed model handling, thinking-level picker and shortcut | Web Shell has native model/effort controls and local commands; exact OpenWork composer UX is not fully mapped | Partial | PR2 | +| Model and thinking controls | Persist model changes, refresh model defaults, provider-managed model handling | Web Shell retains its native model controls and `/effort` command without OpenWork-specific composer controls | Upstream equivalent | PR1 | | Qwen settings | Qwen settings, memory, MCP, hooks, extensions, runtime settings, error handling | Web Shell has daemon-native settings, memory, MCP, hooks, extensions, tools and workspace settings | Upstream equivalent | PR1 verification | | Provider connection setup | Provider selection, API-key setup, onboarding and provider/skill settings flows | Web Shell exposes provider/settings infrastructure, but the OpenWork onboarding/connect flow is not preserved as a desktop flow | Partial | PR2 | | Skills | Workspace-scoped skills, disabled-skill filtering, mention cache synchronization, examples and install status | Web Shell has native skills, mentions, extensions and capability-backed settings | Upstream equivalent | PR1 verification | @@ -48,9 +48,6 @@ Status values: | High contrast | User-facing Increase contrast setting | No matching Web Shell user setting identified | Missing | PR2 | | Chat text size | Small/default/large setting | No matching Web Shell user setting identified | Missing | PR2 | | Interface zoom | Cmd/Ctrl `+`, `-`, `0` and appearance setting | No matching Web Shell/Tauri zoom setting identified | Missing | PR2 | -| Composer expand | Expand/collapse chat composer | No exact OpenWork composer maximize control confirmed | Missing | PR2 | -| Composer count | Live word/character count | No equivalent identified | Missing | PR2 | -| Thinking-menu shortcut | Cmd/Ctrl+Shift+E opens thinking menu | No exact equivalent confirmed | Missing | PR2 | | Command palette | Global command palette with recently used commands | No equivalent OpenWork palette/recents experience confirmed | Missing | PR2 | | Shortcut search | Search box in keyboard-shortcuts settings | Web Shell has shortcut help/search-history commands, but no matching shortcuts settings search identified | Missing | PR2 | | Copy as Markdown | Assistant response action | Web Shell has message copy/export actions, but exact Markdown action parity needs confirmation | Partial | PR2 | diff --git a/docs/design/openwork-tauri-pr2.md b/docs/design/openwork-tauri-pr2.md new file mode 100644 index 000000000..f4b98cc15 --- /dev/null +++ b/docs/design/openwork-tauri-pr2.md @@ -0,0 +1,131 @@ +# OpenWork Tauri PR2 + +## Context + +PR1 established the target architecture: the OpenWork Tauri shell starts the +bundled `qwen serve` runtime and displays Qwen Web Shell. The old Electron +renderer and agent runtime remain historical evidence only. + +PR2 closes the product gaps explicitly retained by the migration session. It +must be additive at the Web Shell and Tauri boundaries and must not fork Qwen's +session, model, attachment, voice, permission, worktree, skill, or channel +management implementations. + +## Scope + +### Web Shell product layer + +- Add an OpenWork command palette on `Cmd/Ctrl+K` with six deduplicated recent + commands. +- Add starter prompts. +- Add persistent appearance controls for 50–200% interface zoom, small/default/ + large chat text, comfortable/wide/full transcript widths, high contrast, and + explicit reduced motion. +- Reuse all 15 historical OpenWork color themes and all seven shipped locales. + Existing translated keys remain localized; new Qwen-only strings fall back to + English until the legacy catalogs contain them. +- Add search to Settings and Keyboard Shortcuts. +- Preserve raw Markdown copy and surface both success and failure states. +- Add the three OpenWork curated skills to the existing Skills manager and use + the daemon's existing install endpoint. +- Expose Telegram and WhatsApp in the existing Channels manager. + +### Desktop integration + +- Add a docked child webview for human browsing. Chat HTTP(S) links route to the + dock while Qwen session links retain their current in-app behavior. Browser + URLs and bounds are validated in Rust; browser content receives no Tauri IPC. +- Register `openwork://session/` deep links and route only valid session IDs + into the authenticated runtime origin. +- Send native completion notifications when a hidden window finishes a turn. +- Hold the browser Screen Wake Lock while a turn is active and release it when + idle; unsupported platforms remain a safe no-op. +- Apply the resolved HTTP(S) proxy to the browser child webview and expose a + redacted proxy status for verification. +- Add a transparent, always-on-top pet window using the existing OpenWork pet + sprite, controlled from the native View menu and command palette. Discover + additional pets from validated manifests under `~/.qwen/pets/`. +- Add native App/File/Edit/View/Window/Help menus, zoom actions, browser/pet + actions, About/credits, repository links, and update checks. +- Bundle the eight historical document-tool launchers, their Python scripts, + and a pinned `uv` executable. The daemon receives the same `CRAFT_UV`, + `CRAFT_SCRIPTS`, and launcher `PATH` contract used by the Electron package. + +### Data migration + +- On first launch, import the active legacy workspace and appearance/pet + preferences without changing the legacy state file. +- Copy native Qwen JSONL sessions into the corresponding Qwen project only when + the destination does not exist, rewriting the working directory and + preserving the parent chain and title. +- Archive legacy labels, status, sources, automations, and workspace metadata + under `$QWEN_HOME/openwork-legacy-v1`, with a checksum report for audit and + idempotence. +- Never copy or alter legacy encrypted credentials or Qwen OAuth credentials. + Both desktop shells use the existing `$QWEN_HOME/oauth_creds.json`, so the + active Qwen login survives the upgrade. Rollback removes only + migration-created files whose checksums are unchanged. + +### Channels + +- Telegram gains serializable management metadata; its existing adapter remains + unchanged. +- WhatsApp becomes a normal `ChannelPlugin` using Baileys directly in the Qwen + daemon process. It persists auth state in the adapter state directory, emits + pairing information through channel logs, routes text through `ChannelBase`, + filters its own echoes, reconnects transient disconnects, and supports text + replies. The old Electron subprocess and gateway are not restored. + +### Release + +- Enable Tauri updater artifacts and the OpenWork GitHub `latest.json` endpoint. +- Require the updater signing key for published builds. +- Build architecture-specific macOS DMG bundles, Windows NSIS, and + Linux AppImage/deb artifacts with the official Tauri action. +- Use the existing Apple signing/notarization secrets and optional Windows + signing secrets. Dry runs may be unsigned; published macOS builds may not. +- Remove release-branch force pushes and Electron artifact paths. + +## Existing behavior reused as-is + +- Qwen sessions, history, timeline, approvals, permissions, models/providers, + attachments, voice, workspaces/worktrees, skills, agents, extensions, MCP, + scheduled tasks, and channel lifecycle. +- Web Shell prompt history, jump-to-latest, raw assistant Markdown, safe external + URL validation, blob downloads, single-instance handling, and window-state + persistence. +- `qwen serve` remains the sole product runtime. No Electron IPC, BrowserView, + updater, messaging gateway, or duplicated renderer package is reintroduced. + +## Security and ownership + +- Bootstrap-only commands continue to require the bootstrap origin. +- Runtime product commands require the exact authenticated runtime origin kept + by `ApplicationState`; arbitrary web content cannot invoke them. +- Only `http` and `https` browser URLs are accepted. Deep links accept only the + `openwork` scheme, `session` host, and a bounded session-ID path. +- The browser dock is owned by the main desktop window. It is hidden before the + main webview navigates away and destroyed when the app exits. +- Updater signatures are mandatory and verified by Tauri before installation. +- WhatsApp auth state stays under the channel-owned state directory and is never + returned through the management API. +- Custom pet IDs and sprite paths are validated and canonicalized inside the + configured pet directory; only the pet window can resolve a sprite. +- Release secrets are exposed only to the validation/signing/build steps that + need them, and only for published builds. + +## Verification + +- Focused Web Shell tests cover preferences, recents, the OpenWork settings + surface, Markdown copy feedback, and worktree session creation. +- Channel tests cover Telegram metadata, WhatsApp message classification, and + plugin registration. +- Rust tests cover URL/deep-link validation, proxy redaction, and browser bounds. +- Release contract tests assert updater configuration, OpenWork endpoints, + supported bundle targets, signing inputs, and Tauri artifact paths. +- Migration tests cover copy/rewrite, archive checksums, idempotence, OAuth + preservation, and rollback refusal after user modification. +- The packaged runtime smoke checks the pinned `uv`, document launchers, and + migration entrypoint before daemon startup. +- Desktop smoke testing launches the bundled app and verifies its runtime health + endpoint before shutdown. diff --git a/docs/developers/openwork-upstream-maintenance.md b/docs/developers/openwork-upstream-maintenance.md new file mode 100644 index 000000000..06746d535 --- /dev/null +++ b/docs/developers/openwork-upstream-maintenance.md @@ -0,0 +1,96 @@ +# Maintaining OpenWork on Qwen Code + +OpenWork is a standalone GitHub repository, not a GitHub fork. Its Git history nevertheless includes Qwen Code, which remains the upstream runtime and Web Shell. Keep that relationship explicit in Git and keep OpenWork-specific changes small enough to review after every upstream merge. + +## Repository model + +- `origin` is `https://github.com/modelstudioai/openwork`. +- `qwen-upstream` is `https://github.com/QwenLM/qwen-code.git` and is fetch-only for OpenWork maintenance. +- `main` is the released OpenWork line. +- OpenWork owns the Tauri shell, branding and customization, migration, desktop release configuration, and OpenWork-only channels. +- Shared CLI, daemon, SDK, and Web Shell behavior should stay compatible with Qwen Code. Put reusable fixes upstream when practical instead of maintaining a second implementation here. + +Add the upstream remote once after cloning: + +```bash +git remote add qwen-upstream https://github.com/QwenLM/qwen-code.git +git remote set-url --push qwen-upstream DISABLED +git config remote.qwen-upstream.tagOpt --no-tags +git fetch origin --prune +git fetch qwen-upstream --prune +``` + +`git remote set-url --push` prevents an accidental push to Qwen Code without changing normal fetches. Keeping upstream tags out also avoids collisions with OpenWork release tags. + +## Syncing Qwen Code + +Use a normal merge so both histories and the exact upstream commit remain visible. Do not rebase or force-push a published sync branch. + +```bash +git fetch origin --prune +git fetch qwen-upstream --prune +git switch main +git pull --ff-only origin main +git switch -c chore/sync-qwen-code-YYYYMMDD +git merge --no-ff --no-commit qwen-upstream/main +``` + +Resolve conflicts by ownership: + +- Prefer upstream for shared runtime, CLI, SDK, and Web Shell internals. +- Preserve OpenWork behavior in `packages/desktop-shell`, OpenWork customization under `packages/web-shell/client/openwork`, migration code, OpenWork channel adapters, and desktop release workflows. +- Review `package.json`, lockfiles, branding, application identifiers, updater endpoints, and release secrets rather than taking either side wholesale. +- Treat every added `.github/workflows/*.yml` file as unapproved until it is reviewed and added to `.github/scripts/openwork-workflows.test.mjs` with an explicit GitHub Actions state. + +Do not push a sync branch that introduces an unreviewed workflow. Review every workflow change, run the workflow inventory test locally, and verify the repository-side state of retained disabled workflows with `gh workflow list --all`. The inventory test records reviewed files; GitHub stores whether each workflow is enabled or disabled. + +After resolving conflicts, run the checks for the touched packages plus: + +```bash +node --test .github/scripts/openwork-workflows.test.mjs +npm run build +npm run typecheck +``` + +Commit the reviewed merge, then open a PR to `main` that names the upstream before/after commits and lists every conflict resolution. After CI passes, use GitHub's **Create a merge commit** option. Squash and rebase merging are not valid for an upstream-sync PR because `main` must retain the Qwen commit as an ancestor for the next merge. + +## Workflow policy + +OpenWork intentionally enables only these workflows: + +| Workflow | Purpose | +| --------------------- | --------------------------------------------------------- | +| `ci.yml` | Pull request, merge-queue, and manual source verification | +| `sdk-java.yml` | Java SDK compatibility | +| `sdk-python.yml` | Python SDK compatibility | +| `codeql.yml` | Scheduled and manual source security analysis | +| `desktop-build.yml` | Reusable cross-platform installer build | +| `desktop-release.yml` | Manual dry-run or published desktop release | + +Qwen-specific jobs inside a retained workflow must also remain repository-gated. In particular, OpenWork's `ci.yml` does not run the model-backed merge-queue integration job because the repository does not own its `OPENAI_*` credentials. + +These Qwen Code workflows remain checked in for upstream maintenance but are disabled in the `modelstudioai/openwork` GitHub Actions settings: + +| Disabled workflow | Why it is disabled | Enable only when | +| ------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------ | +| `audio-capture-prebuilds.yml` | Produces an artifact for Qwen package publishing; OpenWork has no consumer | An OpenWork release downloads and ships the artifact | +| `docs-page-action.yml` | The repository has no GitHub Pages site | Pages is configured with an OpenWork-owned source and domain | +| `e2e.yml` | Scheduled jobs require model and Docker Hub credentials not configured here | OpenWork owns the credentials, cost, and failure rotation | +| `main-ci-failure-issue.yml` | Creates and assigns issues through Qwen bot labels and credentials | OpenWork defines the bot, labels, and incident owner | +| `npm-cache.yml` | Targets Qwen's `ecs-qwen` runner and feeds removed triage jobs | OpenWork operates the runner and a real cache consumer | +| `repo-hygiene.yml` | Runs a model-backed agent with Qwen bot credentials and can open PRs | OpenWork explicitly owns the bot and review policy | +| `stale.yml` | Automatically mutates and closes contributor PRs under Qwen policy | OpenWork maintainers approve a local stale policy | +| `web-shell-visuals-cleanup.yml` | Only deletes Qwen asset branches | OpenWork introduces the matching asset publisher | +| `windows-runner-smoke.yml` | Requires the unavailable `ecs-win` runner | OpenWork registers and operates that runner | + +Other Qwen release, publishing, issue, PR bot, mirror, and runner-maintenance workflows remain absent for the same ownership reason. They depend on Qwen-owned infrastructure, credentials, labels, artifact consumers, or repository policy. + +To enable a disabled workflow, use a separate PR that documents its trigger, permissions, secrets, runners, owner, failure response, and artifact consumer, then run `gh workflow enable --repo modelstudioai/openwork`. A copied upstream workflow must never become active only because an upstream merge added the file. + +## Day-to-day development + +Start product work from current OpenWork `main` in a dedicated branch or worktree. Keep OpenWork UI and native integrations in their existing customization layers. If a change belongs to shared Qwen behavior, make it upstream-compatible and avoid introducing an OpenWork-only fork of the same runtime path. + +The existing `npm run desktop-openwork-sync` command is a legacy, narrow tool for moving commits between the old `packages/desktop` trees. It is not an alternative to the whole-repository merge procedure above and should not be used for CLI, daemon, SDK, Web Shell, workflow, or Tauri updates. + +Desktop development and release commands are documented in [`packages/desktop-shell/README.md`](../../packages/desktop-shell/README.md). diff --git a/package-lock.json b/package-lock.json index fb8e69796..7e91ca6be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "packages/*", "packages/channels/base", "packages/channels/telegram", + "packages/channels/whatsapp", "packages/channels/weixin", "packages/channels/dingtalk", "packages/channels/wecom", @@ -1017,6 +1018,16 @@ "node": ">=18" } }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/@braintree/sanitize-url": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", @@ -1070,6 +1081,85 @@ "node": ">=6" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/memory/node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/memory/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/@cacheable/node-cache": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@cacheable/node-cache/-/node-cache-1.7.6.tgz", + "integrity": "sha512-6Omk2SgNnjtxB5f/E6bTIWIt5xhdpx39fGNRQgU9lojvRxU68v+qY+SXXLsp3ZGukqoPjsK21wZ6XABFr/Ge3A==", + "license": "MIT", + "dependencies": { + "cacheable": "^2.3.1", + "hookified": "^1.14.0", + "keyv": "^5.5.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@cacheable/node-cache/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/@chevrotain/types": { "version": "11.1.2", "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", @@ -2259,6 +2349,21 @@ "node": ">=6" } }, + "node_modules/@hapi/boom": { + "version": "9.1.4", + "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-9.1.4.tgz", + "integrity": "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "9.x.x" + } + }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, "node_modules/@hono/node-server": { "version": "2.0.12", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.12.tgz", @@ -3392,6 +3497,12 @@ "tslib": "2" } }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "license": "MIT" + }, "node_modules/@kwsites/file-exists": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", @@ -4542,6 +4653,12 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -4727,6 +4844,10 @@ "resolved": "packages/channels/weixin", "link": true }, + "node_modules/@qwen-code/channel-whatsapp": { + "resolved": "packages/channels/whatsapp", + "link": true + }, "node_modules/@qwen-code/chrome-bridge": { "resolved": "packages/chrome-extension", "link": true @@ -7874,6 +7995,29 @@ "@textlint/ast-node-types": "15.7.1" } }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, "node_modules/@ts-morph/common": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", @@ -9831,6 +9975,44 @@ "ws": "^8.16.0" } }, + "node_modules/@whiskeysockets/baileys": { + "version": "6.7.24", + "resolved": "https://registry.npmjs.org/@whiskeysockets/baileys/-/baileys-6.7.24.tgz", + "integrity": "sha512-Ljq7si+gsNIE9d5dP69E99TTQp+BxxPgitWvmZMXExGc1Ctr0SCDLFZmxRLUDC5HB0/Itw5uk+HJeBONrLP99Q==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@cacheable/node-cache": "^1.4.0", + "@hapi/boom": "^9.1.3", + "async-mutex": "^0.5.0", + "axios": "^1.6.0", + "libsignal": "git+https://github.com/whiskeysockets/libsignal-node.git", + "music-metadata": "^11.7.0", + "pino": "^9.6", + "protobufjs": "^7.2.4", + "ws": "^8.13.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "audio-decode": "^2.1.3", + "jimp": "^1.6.0", + "link-preview-js": "^3.0.0", + "sharp": "*" + }, + "peerDependenciesMeta": { + "audio-decode": { + "optional": true + }, + "jimp": { + "optional": true + }, + "link-preview-js": { + "optional": true + } + } + }, "node_modules/@xterm/headless": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.5.0.tgz", @@ -10534,6 +10716,15 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/auto-bind": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", @@ -11014,6 +11205,28 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/cacheable/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -12185,6 +12398,12 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/curve25519-js": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/curve25519-js/-/curve25519-js-0.0.4.tgz", + "integrity": "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==", + "license": "MIT" + }, "node_modules/cytoscape": { "version": "3.34.0", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz", @@ -14829,6 +15048,24 @@ "node": ">=16.0.0" } }, + "node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/filesize": { "version": "10.1.6", "resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.6.tgz", @@ -15793,6 +16030,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -16031,6 +16280,12 @@ "node": ">=16.9.0" } }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "license": "MIT" + }, "node_modules/hosted-git-info": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.1.0.tgz", @@ -16198,7 +16453,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -17954,6 +18208,15 @@ "node": ">= 0.8.0" } }, + "node_modules/libsignal": { + "version": "6.0.0", + "resolved": "git+ssh://git@github.com/whiskeysockets/libsignal-node.git#bcea72df9ec34d9d9140ab30619cf479c7c144c7", + "license": "GPL-3.0", + "dependencies": { + "curve25519-js": "^0.0.4", + "protobufjs": "^7.5.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -20281,6 +20544,63 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/music-metadata": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.14.0.tgz", + "integrity": "sha512-RyOSq98kuVfXB1emJ+NjBF0av8Ph3oBuqNy+Z5sFFfLhjYrkBQEB53V8u+U0RNTVwNo20WoPUwNkfKwZfrOqmQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.2", + "@tokenizer/token": "^0.3.0", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "file-type": "^21.3.4", + "media-typer": "^2.0.0", + "strtok3": "^10.3.5", + "token-types": "^6.1.2", + "uint8array-extras": "^1.5.0", + "win-guid": "^0.2.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/music-metadata/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/music-metadata/node_modules/media-typer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-2.0.0.tgz", + "integrity": "sha512-kOy3OxT2HH39N70UnKgu4NWDZjLOz8W/mfyvniHjRH/DrL3f2pOfvWQ4p60offbbtDAnXWp0v9LfMIqMec269Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mute-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", @@ -21039,6 +21359,15 @@ "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", "license": "MIT" }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -21797,6 +22126,43 @@ "node": ">=4" } }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -22327,6 +22693,22 @@ "dev": true, "license": "MIT" }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/promise-retry": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", @@ -22496,6 +22878,24 @@ "node": ">=6" } }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "license": "MIT" + }, "node_modules/qrcode-terminal": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", @@ -22564,6 +22964,12 @@ ], "license": "MIT" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/qwen-code-vscode-ide-companion": { "resolved": "packages/vscode-ide-companion", "link": true @@ -23154,6 +23560,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/recast": { "version": "0.23.11", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", @@ -23735,6 +24150,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -24536,6 +24960,15 @@ "node": ">= 14" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -24598,6 +25031,15 @@ "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==", "license": "CC0-1.0" }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -25067,6 +25509,22 @@ "anynum": "^1.0.1" } }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/structured-source": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/structured-source/-/structured-source-4.0.0.tgz", @@ -25702,6 +26160,15 @@ "tslib": "^2" } }, + "node_modules/thread-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", + "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -25869,6 +26336,24 @@ "node": ">=0.6" } }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -26272,6 +26757,18 @@ "dev": true, "license": "MIT" }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -27203,6 +27700,12 @@ "node": ">=8" } }, + "node_modules/win-guid": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz", + "integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==", + "license": "MIT" + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -27845,6 +28348,17 @@ "typescript": "^5.0.0" } }, + "packages/channels/whatsapp": { + "name": "@qwen-code/channel-whatsapp", + "version": "0.21.10", + "dependencies": { + "@qwen-code/channel-base": "0.21.10", + "@whiskeysockets/baileys": "^6.7.0" + }, + "devDependencies": { + "typescript": "^5.0.0" + } + }, "packages/chrome-extension": { "name": "@qwen-code/chrome-bridge", "version": "0.21.10", @@ -27892,6 +28406,7 @@ "@qwen-code/channel-telegram": "file:../channels/telegram", "@qwen-code/channel-wecom": "file:../channels/wecom", "@qwen-code/channel-weixin": "file:../channels/weixin", + "@qwen-code/channel-whatsapp": "file:../channels/whatsapp", "@qwen-code/qwen-code-core": "file:../core", "@qwen-code/sdk": "file:../sdk-typescript", "@qwen-code/web-templates": "file:../web-templates", diff --git a/package.json b/package.json index d054109ba..56a772bd3 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "packages/*", "packages/channels/base", "packages/channels/telegram", + "packages/channels/whatsapp", "packages/channels/weixin", "packages/channels/dingtalk", "packages/channels/wecom", diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index d09c15131..25a25379a 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -212,6 +212,8 @@ export interface ChannelBaseOptions { proxy?: string; /** Adapter-owned persistent state directory. */ stateDir?: string; + /** Called when an adapter becomes permanently unavailable after connecting. */ + onTerminalDisconnect?: (error: Error) => void; channelMemory?: ChannelMemoryCallbacks; memoryIntentClassifier?: ChannelMemoryIntentClassifier; channelMemoryRecallObserver?: ( @@ -373,6 +375,7 @@ export abstract class ChannelBase { protected proxy?: string; /** Adapter-owned persistent state directory, when supplied by the runtime. */ protected readonly stateDir?: string; + protected readonly onTerminalDisconnect?: (error: Error) => void; private readonly channelMemory?: ChannelMemoryCallbacks; private readonly memoryIntentClassifier?: ChannelMemoryIntentClassifier; private readonly channelMemoryRecallObserver?: ( @@ -810,6 +813,7 @@ export abstract class ChannelBase { this.bridge = bridge; this.proxy = options?.proxy; this.stateDir = options?.stateDir; + this.onTerminalDisconnect = options?.onTerminalDisconnect; this.identity = Object.freeze(this.resolveIdentity(name, config)); this.memoryScope = Object.freeze(this.resolveMemoryScope(name, config)); this.channelMemory = options?.channelMemory; diff --git a/packages/channels/telegram/src/index.ts b/packages/channels/telegram/src/index.ts index 97426548d..2d08fd8c4 100644 --- a/packages/channels/telegram/src/index.ts +++ b/packages/channels/telegram/src/index.ts @@ -7,6 +7,18 @@ export const plugin: ChannelPlugin = { channelType: 'telegram', displayName: 'Telegram', requiredConfigFields: ['token'], + management: { + fields: [ + { + key: 'token', + label: 'Bot Token', + kind: 'secret', + required: true, + envResolvable: true, + description: 'Token issued by @BotFather', + }, + ], + }, createChannel: (name, config, bridge, options) => new TelegramChannel(name, config, bridge, options), }; diff --git a/packages/channels/whatsapp/package.json b/packages/channels/whatsapp/package.json new file mode 100644 index 000000000..fa568c1fd --- /dev/null +++ b/packages/channels/whatsapp/package.json @@ -0,0 +1,29 @@ +{ + "name": "@qwen-code/channel-whatsapp", + "version": "0.21.10", + "description": "WhatsApp channel adapter for Qwen Code", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc --build", + "test": "vitest run", + "test:ci": "vitest run" + }, + "dependencies": { + "@qwen-code/channel-base": "0.21.10", + "@whiskeysockets/baileys": "^6.7.0" + }, + "devDependencies": { + "typescript": "^5.0.0" + } +} diff --git a/packages/channels/whatsapp/src/WhatsAppAdapter.test.ts b/packages/channels/whatsapp/src/WhatsAppAdapter.test.ts new file mode 100644 index 000000000..df104c129 --- /dev/null +++ b/packages/channels/whatsapp/src/WhatsAppAdapter.test.ts @@ -0,0 +1,175 @@ +import { chmod, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { + ChannelAgentBridge, + ChannelConfig, +} from '@qwen-code/channel-base'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +type EventHandler = (event: unknown) => void; + +const baileys = vi.hoisted(() => ({ + handlers: new Map(), + makeSocket: vi.fn(), + end: vi.fn(), + sendMessage: vi.fn(), +})); + +vi.mock('@whiskeysockets/baileys', () => ({ + default: baileys.makeSocket, + Browsers: { macOS: () => ['OpenWork', 'Desktop', '1'] }, + DisconnectReason: { loggedOut: 401 }, + useMultiFileAuthState: vi.fn(async () => ({ + state: { + creds: { registered: true }, + keys: { + get: vi.fn(async () => ({})), + set: vi.fn(async () => undefined), + }, + }, + saveCreds: vi.fn(async () => undefined), + })), +})); + +import { WhatsAppChannel } from './WhatsAppAdapter.js'; + +let stateDir: string; + +beforeEach(async () => { + stateDir = await mkdtemp(join(tmpdir(), 'openwork-whatsapp-test-')); + baileys.handlers.clear(); + baileys.end.mockReset(); + baileys.sendMessage.mockReset().mockResolvedValue({ key: { id: 'sent' } }); + baileys.makeSocket.mockReset().mockImplementation(() => ({ + ev: { + on: (event: string, handler: EventHandler) => + baileys.handlers.set(event, handler), + }, + user: { id: '15551234567@s.whatsapp.net' }, + end: baileys.end, + sendMessage: baileys.sendMessage, + requestPairingCode: vi.fn(), + })); +}); + +afterEach(async () => { + await rm(stateDir, { recursive: true, force: true }); +}); + +function channel( + onTerminalDisconnect?: (error: Error) => void, +): WhatsAppChannel { + const config = { + type: 'whatsapp', + phoneNumber: '15551234567', + senderPolicy: 'open', + allowedUsers: [], + sessionScope: 'chat_thread', + cwd: stateDir, + groupPolicy: 'open', + dmPolicy: 'open', + groups: { '*': {} }, + } as unknown as ChannelConfig; + const bridge = { + newSession: vi.fn(), + loadSession: vi.fn(), + prompt: vi.fn(), + cancelSession: vi.fn(), + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + } as unknown as ChannelAgentBridge; + return new WhatsAppChannel('test', config, bridge, { + stateDir, + onTerminalDisconnect, + }); +} + +describe('WhatsApp connection lifecycle', () => { + it('does not report ready or send until the socket is open', async () => { + const adapter = channel(); + let ready = false; + const connecting = adapter.connect().then(() => { + ready = true; + }); + + await vi.waitFor(() => expect(baileys.makeSocket).toHaveBeenCalledOnce()); + expect(ready).toBe(false); + await expect(adapter.sendMessage('chat', 'hello')).rejects.toThrow( + 'not connected', + ); + + baileys.handlers.get('connection.update')?.({ connection: 'open' }); + await connecting; + await adapter.sendMessage('chat', 'hello'); + expect(baileys.sendMessage).toHaveBeenCalledWith('chat', { text: 'hello' }); + await adapter.disconnect(); + }); + + it.skipIf(process.platform === 'win32')( + 'locks down existing authentication state', + async () => { + const nested = join(stateDir, 'keys'); + const credentials = join(nested, 'creds.json'); + await mkdir(nested); + await writeFile(credentials, '{}'); + await chmod(stateDir, 0o755); + await chmod(nested, 0o755); + await chmod(credentials, 0o644); + + const adapter = channel(); + const connecting = adapter.connect(); + await vi.waitFor(() => expect(baileys.makeSocket).toHaveBeenCalledOnce()); + baileys.handlers.get('connection.update')?.({ connection: 'open' }); + await connecting; + + expect((await stat(stateDir)).mode & 0o777).toBe(0o700); + expect((await stat(nested)).mode & 0o777).toBe(0o700); + expect((await stat(credentials)).mode & 0o777).toBe(0o600); + await adapter.disconnect(); + }, + ); + + it('reports a permanent disconnect after becoming ready', async () => { + const onTerminalDisconnect = vi.fn(); + const adapter = channel(onTerminalDisconnect); + const connecting = adapter.connect(); + await vi.waitFor(() => expect(baileys.makeSocket).toHaveBeenCalledOnce()); + baileys.handlers.get('connection.update')?.({ connection: 'open' }); + await connecting; + + baileys.handlers.get('connection.update')?.({ + connection: 'close', + lastDisconnect: { error: { output: { statusCode: 401 } } }, + }); + + await vi.waitFor(() => expect(onTerminalDisconnect).toHaveBeenCalledOnce()); + expect(onTerminalDisconnect).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('logged out'), + }), + ); + await vi.waitFor(async () => { + await expect(stat(stateDir)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + await adapter.disconnect(); + }); + + it('does not report an initial connection failure as a later disconnect', async () => { + const onTerminalDisconnect = vi.fn(); + const adapter = channel(onTerminalDisconnect); + const connecting = adapter.connect(); + await vi.waitFor(() => expect(baileys.makeSocket).toHaveBeenCalledOnce()); + + baileys.handlers.get('connection.update')?.({ + connection: 'close', + lastDisconnect: { error: { output: { statusCode: 401 } } }, + }); + + await expect(connecting).rejects.toThrow('logged out'); + await expect(stat(stateDir)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(onTerminalDisconnect).not.toHaveBeenCalled(); + await adapter.disconnect(); + }); +}); diff --git a/packages/channels/whatsapp/src/WhatsAppAdapter.ts b/packages/channels/whatsapp/src/WhatsAppAdapter.ts new file mode 100644 index 000000000..5f32a1220 --- /dev/null +++ b/packages/channels/whatsapp/src/WhatsAppAdapter.ts @@ -0,0 +1,289 @@ +import { chmod, mkdir, readdir, rm } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import makeWASocket, { + Browsers, + DisconnectReason, + useMultiFileAuthState as loadMultiFileAuthState, +} from '@whiskeysockets/baileys'; +import type { + AuthenticationState, + SignalDataSet, +} from '@whiskeysockets/baileys'; +import { ChannelBase } from '@qwen-code/channel-base'; +import type { + ChannelAgentBridge, + ChannelBaseOptions, + ChannelConfig, + Envelope, +} from '@qwen-code/channel-base'; +import { + acceptInbound, + bareJid, + extractText, + rememberSentId, +} from './message.js'; + +const silentLogger = { + level: 'silent', + fatal: () => undefined, + error: () => undefined, + warn: () => undefined, + info: () => undefined, + debug: () => undefined, + trace: () => undefined, + child: () => silentLogger, +}; + +type WhatsAppSocket = ReturnType; + +async function secureAuthFiles(directory: string): Promise { + await chmod(directory, 0o700); + const entries = await readdir(directory, { withFileTypes: true }); + await Promise.all( + entries.map((entry) => { + const file = join(directory, entry.name); + if (entry.isDirectory()) return secureAuthFiles(file); + return entry.isFile() ? chmod(file, 0o600) : undefined; + }), + ); +} + +export class WhatsAppChannel extends ChannelBase { + private socket: WhatsAppSocket | null = null; + private stopped = false; + private reconnectAttempts = 0; + private reconnectTimer: NodeJS.Timeout | null = null; + private connected = false; + private hasConnected = false; + private rejectConnect: ((error: Error) => void) | null = null; + private readonly sentIds = new Set(); + private readonly phoneNumber: string; + private readonly selfChatMode: boolean; + private readonly responsePrefix: string; + + constructor( + name: string, + config: ChannelConfig, + bridge: ChannelAgentBridge, + options?: ChannelBaseOptions, + ) { + super(name, config, bridge, options); + const values = config as ChannelConfig & { + phoneNumber?: string; + selfChatMode?: boolean; + responsePrefix?: string; + }; + this.phoneNumber = values.phoneNumber?.replace(/\D/g, '') ?? ''; + this.selfChatMode = values.selfChatMode === true; + this.responsePrefix = values.responsePrefix?.trim() || '🤖'; + } + + async connect(): Promise { + this.stopped = false; + this.hasConnected = false; + const authDir = + this.stateDir ?? + join(homedir(), '.qwen', 'channels', this.name, 'whatsapp'); + await mkdir(authDir, { recursive: true, mode: 0o700 }); + await secureAuthFiles(authDir); + const { state, saveCreds } = await loadMultiFileAuthState(authDir); + const secureState: AuthenticationState = { + creds: state.creds, + keys: { + get: state.keys.get.bind(state.keys), + set: async (data: SignalDataSet) => { + await state.keys.set(data); + await secureAuthFiles(authDir); + }, + }, + }; + const saveSecureCreds = async () => { + await saveCreds(); + await secureAuthFiles(authDir); + }; + + return new Promise((resolve, reject) => { + this.rejectConnect = reject; + const connected = () => { + if (!this.rejectConnect) return; + this.rejectConnect = null; + resolve(); + }; + const failed = (error: Error) => { + if (this.rejectConnect) { + this.rejectConnect = null; + reject(error); + } else if (this.hasConnected) { + this.onTerminalDisconnect?.(error); + } + }; + const boot = () => { + if (this.stopped) return; + const socket = makeWASocket({ + auth: secureState, + browser: Browsers.macOS('OpenWork'), + logger: silentLogger, + printQRInTerminal: false, + }); + this.socket = socket; + socket.ev.on('creds.update', () => { + void saveSecureCreds().catch((error) => { + process.stderr.write( + `[WhatsApp:${this.name}] Failed to secure credentials: ${error instanceof Error ? error.message : String(error)}\n`, + ); + socket.end( + error instanceof Error ? error : new Error(String(error)), + ); + }); + }); + socket.ev.on('connection.update', ({ connection, lastDisconnect }) => { + if (connection === 'open') { + this.connected = true; + this.hasConnected = true; + this.reconnectAttempts = 0; + connected(); + process.stderr.write( + `[WhatsApp:${this.name}] Connected as ${socket.user?.id ?? 'unknown'}\n`, + ); + return; + } + if (connection !== 'close' || this.stopped) return; + this.connected = false; + if (this.socket === socket) this.socket = null; + const statusCode = ( + lastDisconnect?.error as + | { output?: { statusCode?: number } } + | undefined + )?.output?.statusCode; + if (statusCode === DisconnectReason.loggedOut) { + const error = new Error( + 'WhatsApp logged out; reconfigure the channel to pair again.', + ); + process.stderr.write(`[WhatsApp:${this.name}] ${error.message}\n`); + void rm(authDir, { recursive: true, force: true }) + .catch((cleanupError) => { + process.stderr.write( + `[WhatsApp:${this.name}] Failed to clear logged-out credentials: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}\n`, + ); + }) + .finally(() => failed(error)); + return; + } + this.reconnectAttempts += 1; + if (this.reconnectAttempts > 10) { + failed(new Error('WhatsApp could not establish a connection.')); + return; + } + if (this.reconnectTimer) return; + this.reconnectTimer = setTimeout( + () => { + this.reconnectTimer = null; + boot(); + }, + Math.min(30_000, 1000 * 2 ** (this.reconnectAttempts - 1)), + ); + }); + socket.ev.on('messages.upsert', ({ messages, type }) => { + if (type !== 'notify') return; + const selfJid = bareJid(socket.user?.id); + const selfLid = bareJid(socket.user?.lid); + for (const message of messages) { + const text = extractText(message.message); + const key = message.key; + if ( + !acceptInbound({ + id: key.id, + remoteJid: key.remoteJid, + fromMe: key.fromMe, + text, + selfChatMode: this.selfChatMode, + selfJid, + selfLid, + responsePrefix: this.responsePrefix, + sentIds: this.sentIds, + }) + ) { + continue; + } + const chatId = key.remoteJid!; + const senderId = key.participant ?? chatId; + const mentioned = + message.message?.extendedTextMessage?.contextInfo?.mentionedJid ?? + []; + const envelope: Envelope = { + channelName: this.name, + senderId, + senderName: message.pushName ?? senderId, + chatId, + text, + isGroup: chatId.endsWith('@g.us'), + isMentioned: mentioned.some((jid) => { + const mention = bareJid(jid); + return mention === selfJid || mention === selfLid; + }), + isReplyToBot: false, + }; + void this.handleInbound(envelope).catch((error) => { + process.stderr.write( + `[WhatsApp:${this.name}] Failed to handle message: ${error instanceof Error ? error.message : String(error)}\n`, + ); + }); + } + }); + if (!state.creds.registered) { + if (!this.phoneNumber) { + const error = new Error( + 'WhatsApp phoneNumber is required for initial pairing.', + ); + failed(error); + socket.end(error); + return; + } + void socket + .requestPairingCode(this.phoneNumber) + .then((code) => + process.stderr.write( + `[WhatsApp:${this.name}] Pairing code: ${code}\n`, + ), + ) + .catch((error) => { + const failure = + error instanceof Error ? error : new Error(String(error)); + failed(failure); + process.stderr.write( + `[WhatsApp:${this.name}] Pairing failed: ${failure.message}\n`, + ); + socket.end(failure); + }); + } + }; + + boot(); + }); + } + + async sendMessage(chatId: string, text: string): Promise { + if (!this.socket || !this.connected) { + throw new Error('WhatsApp is not connected'); + } + const self = bareJid(this.socket.user?.id); + const output = + this.selfChatMode && bareJid(chatId) === self + ? `${this.responsePrefix} ${text}` + : text; + const sent = await this.socket.sendMessage(chatId, { text: output }); + if (sent?.key.id) rememberSentId(this.sentIds, sent.key.id); + } + + async disconnect(): Promise { + this.stopped = true; + this.connected = false; + this.rejectConnect?.(new Error('WhatsApp connection stopped.')); + this.rejectConnect = null; + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + this.socket?.end(undefined); + this.socket = null; + } +} diff --git a/packages/channels/whatsapp/src/index.ts b/packages/channels/whatsapp/src/index.ts new file mode 100644 index 000000000..b6024a045 --- /dev/null +++ b/packages/channels/whatsapp/src/index.ts @@ -0,0 +1,41 @@ +import type { ChannelPlugin } from '@qwen-code/channel-base'; +import { WhatsAppChannel } from './WhatsAppAdapter.js'; + +export { WhatsAppChannel }; + +export const plugin: ChannelPlugin = { + channelType: 'whatsapp', + displayName: 'WhatsApp (unofficial)', + defaultSessionScope: 'chat_thread', + management: { + fields: [ + { + key: 'phoneNumber', + label: 'Phone Number', + kind: 'string', + required: true, + description: + 'Digits including country code. The pairing code is printed in daemon logs.', + }, + { + key: 'selfChatMode', + label: 'Self-chat mode', + kind: 'boolean', + description: 'Only accept messages sent to your own WhatsApp chat.', + }, + { + key: 'responsePrefix', + label: 'Response prefix', + kind: 'string', + default: '🤖', + description: 'Marks agent replies and prevents self-chat echo loops.', + }, + ], + validateConfig: (config) => + /^\d{7,15}$/.test(String(config['phoneNumber'] ?? '').replace(/\D/g, '')) + ? undefined + : 'Phone number must contain 7–15 digits including country code.', + }, + createChannel: (name, config, bridge, options) => + new WhatsAppChannel(name, config, bridge, options), +}; diff --git a/packages/channels/whatsapp/src/message.test.ts b/packages/channels/whatsapp/src/message.test.ts new file mode 100644 index 000000000..76dbf72ce --- /dev/null +++ b/packages/channels/whatsapp/src/message.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { acceptInbound, extractText } from './message.js'; + +describe('WhatsApp message filtering', () => { + it('accepts contact mode and self-chat mode without echoing bot replies', () => { + const base = { + id: 'message-1', + remoteJid: '15551234567@s.whatsapp.net', + text: 'hello', + selfJid: '15551234567@s.whatsapp.net', + selfLid: null, + responsePrefix: '🤖', + sentIds: new Set(), + }; + expect(acceptInbound({ ...base, fromMe: false, selfChatMode: false })).toBe( + true, + ); + expect(acceptInbound({ ...base, fromMe: false, selfChatMode: true })).toBe( + false, + ); + expect(acceptInbound({ ...base, fromMe: true, selfChatMode: true })).toBe( + true, + ); + expect( + acceptInbound({ + ...base, + fromMe: true, + selfChatMode: true, + text: '🤖 response', + }), + ).toBe(false); + expect(extractText({ imageMessage: { caption: 'caption' } })).toBe( + 'caption', + ); + }); +}); diff --git a/packages/channels/whatsapp/src/message.ts b/packages/channels/whatsapp/src/message.ts new file mode 100644 index 000000000..036a57693 --- /dev/null +++ b/packages/channels/whatsapp/src/message.ts @@ -0,0 +1,66 @@ +export function bareJid(jid: string | null | undefined): string | null { + if (!jid) return null; + const at = jid.indexOf('@'); + if (at < 0) return jid; + return jid.slice(0, at).split(':')[0] + jid.slice(at); +} + +export function extractText(message: unknown): string { + if (!message || typeof message !== 'object') return ''; + const data = message as Record; + if (typeof data['conversation'] === 'string') return data['conversation']; + for (const key of [ + 'extendedTextMessage', + 'imageMessage', + 'videoMessage', + 'documentMessage', + ]) { + const value = data[key]; + if (!value || typeof value !== 'object') continue; + const record = value as Record; + const text = record['text'] ?? record['caption']; + if (typeof text === 'string') return text; + } + return ''; +} + +export function acceptInbound({ + id, + remoteJid, + fromMe, + text, + selfChatMode, + selfJid, + selfLid, + responsePrefix, + sentIds, +}: { + id?: string | null; + remoteJid?: string | null; + fromMe?: boolean | null; + text: string; + selfChatMode: boolean; + selfJid: string | null; + selfLid: string | null; + responsePrefix: string; + sentIds: ReadonlySet; +}): boolean { + if (!id || !remoteJid || !text) return false; + if (!fromMe) return !selfChatMode; + const remote = bareJid(remoteJid); + const selfChat = remote === selfJid || remote === selfLid; + return ( + selfChatMode && + selfChat && + !sentIds.has(id) && + !text.startsWith(responsePrefix) + ); +} + +export function rememberSentId(sentIds: Set, id: string): void { + sentIds.add(id); + if (sentIds.size > 500) { + const oldest = sentIds.values().next().value; + if (oldest) sentIds.delete(oldest); + } +} diff --git a/packages/channels/whatsapp/tsconfig.json b/packages/channels/whatsapp/tsconfig.json new file mode 100644 index 000000000..220d6979e --- /dev/null +++ b/packages/channels/whatsapp/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "declarationMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"], + "references": [{ "path": "../base" }] +} diff --git a/packages/channels/whatsapp/vitest.config.ts b/packages/channels/whatsapp/vitest.config.ts new file mode 100644 index 000000000..bfaebe3ce --- /dev/null +++ b/packages/channels/whatsapp/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + globals: true, + }, +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index 7952ec971..d27fe6a43 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -53,6 +53,7 @@ "@qwen-code/channel-gitlab": "file:../channels/gitlab", "@qwen-code/channel-qqbot": "file:../channels/qqbot", "@qwen-code/channel-telegram": "file:../channels/telegram", + "@qwen-code/channel-whatsapp": "file:../channels/whatsapp", "@qwen-code/channel-wecom": "file:../channels/wecom", "@qwen-code/channel-weixin": "file:../channels/weixin", "@qwen-code/qwen-code-core": "file:../core", diff --git a/packages/cli/src/commands/channel/channel-registry-builtins.test.ts b/packages/cli/src/commands/channel/channel-registry-builtins.test.ts index 4417c9602..c55c54887 100644 --- a/packages/cli/src/commands/channel/channel-registry-builtins.test.ts +++ b/packages/cli/src/commands/channel/channel-registry-builtins.test.ts @@ -52,7 +52,7 @@ describe('built-in channel registry', () => { expect(catalog.map((entry) => entry.type)).toContain('gitlab'); expect( catalog.filter((entry) => entry.manageable).map((entry) => entry.type), - ).toEqual(['wecom', 'feishu', 'github', 'gitlab']); + ).toEqual(['telegram', 'whatsapp', 'wecom', 'feishu', 'github', 'gitlab']); expect(stderr).toHaveBeenCalledWith( expect.stringContaining( 'Invalid management metadata in "dingtalk" channel: Channel field "settings" cannot be a required object.', diff --git a/packages/cli/src/commands/channel/channel-registry.test.ts b/packages/cli/src/commands/channel/channel-registry.test.ts index 84de79466..711fe1acf 100644 --- a/packages/cli/src/commands/channel/channel-registry.test.ts +++ b/packages/cli/src/commands/channel/channel-registry.test.ts @@ -696,6 +696,7 @@ describe('channel registry', () => { ); expect(builtinCatalog.map((entry) => entry.type)).toEqual([ 'telegram', + 'whatsapp', 'weixin', 'dingtalk', 'wecom', @@ -708,7 +709,15 @@ describe('channel registry', () => { builtinCatalog .filter((entry) => entry.manageable) .map((entry) => entry.type), - ).toEqual(['dingtalk', 'wecom', 'feishu', 'github', 'gitlab']); + ).toEqual([ + 'telegram', + 'whatsapp', + 'dingtalk', + 'wecom', + 'feishu', + 'github', + 'gitlab', + ]); expect( catalog.find((entry) => entry.type === 'dingtalk')?.fields, ).toContainEqual( diff --git a/packages/cli/src/commands/channel/channel-registry.ts b/packages/cli/src/commands/channel/channel-registry.ts index 784559f8d..ac73340cd 100644 --- a/packages/cli/src/commands/channel/channel-registry.ts +++ b/packages/cli/src/commands/channel/channel-registry.ts @@ -184,6 +184,7 @@ function ensureBuiltins(): Promise { builtinsPromise = (async () => { const labelled = [ { name: 'telegram', promise: import('@qwen-code/channel-telegram') }, + { name: 'whatsapp', promise: import('@qwen-code/channel-whatsapp') }, { name: 'weixin', promise: import('@qwen-code/channel-weixin') }, { name: 'dingtalk', promise: import('@qwen-code/channel-dingtalk') }, { name: 'wecom', promise: import('@qwen-code/channel-wecom') }, diff --git a/packages/cli/src/commands/channel/daemon-worker.test.ts b/packages/cli/src/commands/channel/daemon-worker.test.ts index e34388984..c3c54972b 100644 --- a/packages/cli/src/commands/channel/daemon-worker.test.ts +++ b/packages/cli/src/commands/channel/daemon-worker.test.ts @@ -882,6 +882,26 @@ describe('runChannelDaemonWorker', () => { expect(mockRouterClearAll).not.toHaveBeenCalled(); }); + it('forwards a terminal adapter disconnect to the worker owner', async () => { + const onTerminalDisconnect = vi.fn(); + const handle = await runChannelDaemonWorker({ + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram'] }, + loadDaemonSdk: async () => createSdk(), + onTerminalDisconnect, + }); + const options = mockCreateChannel.mock.calls[0]![3] as { + onTerminalDisconnect(error: Error): void; + }; + const error = new Error('terminal'); + + options.onTerminalDisconnect(error); + + expect(onTerminalDisconnect).toHaveBeenCalledWith('telegram', error); + await handle.close(); + }); + it('starts a workspace-scoped loop runtime for connected channels', async () => { const sdk = createSdk(); const ready = vi.fn(); @@ -2107,6 +2127,38 @@ describe('daemonWorkerCommand', () => { } }); + it('exits for supervisor restart after a terminal adapter disconnect', async () => { + const exit = mockProcessExitNoThrow(); + const send = vi.fn(); + const restoreSend = stubProcessSend(send as NodeJS.Process['send']); + vi.stubEnv('QWEN_CHANNEL_DAEMON_WORKER', 'worker-token'); + vi.stubEnv('QWEN_DAEMON_URL', 'http://127.0.0.1:4170'); + vi.stubEnv('QWEN_DAEMON_WORKSPACE', '/workspace'); + + try { + const handler = daemonWorkerCommand.handler({ + channel: ['telegram'], + _: [], + $0: 'qwen', + }); + await vi.waitFor(() => { + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ type: 'ready' }), + ); + }); + const options = mockCreateChannel.mock.calls[0]![3] as { + onTerminalDisconnect(error: Error): void; + }; + options.onTerminalDisconnect(new Error('logged out')); + await handler; + + expect(mockBridgeStop).toHaveBeenCalled(); + expect(exit).toHaveBeenCalledWith(1); + } finally { + restoreSend(); + } + }); + it('waits for the supervisor ACK instead of the process.send callback', async () => { const exit = mockProcessExitNoThrow(); const send = vi.fn( diff --git a/packages/cli/src/commands/channel/daemon-worker.ts b/packages/cli/src/commands/channel/daemon-worker.ts index 62495c2a4..24f182ca3 100644 --- a/packages/cli/src/commands/channel/daemon-worker.ts +++ b/packages/cli/src/commands/channel/daemon-worker.ts @@ -176,6 +176,7 @@ export interface RunChannelDaemonWorkerOptions { loadDaemonSdk?: () => Promise; sendReady?: (ready: ChannelDaemonWorkerReady) => void; reportStartup?: (message: ChannelStartupReportMessage) => Promise; + onTerminalDisconnect?: (channelName: string, error: Error) => void; startupSignal?: AbortSignal; channelLoopMcpHost?: DaemonChannelLoopMcpHost; } @@ -540,6 +541,8 @@ export async function runChannelDaemonWorker( ...(proxy ? { proxy } : {}), router: createdRouter, stateDir: daemonChannelStateDir(daemonWorkspace, name), + onTerminalDisconnect: (error) => + opts.onTerminalDisconnect?.(name, error), channelMemory: { readChannelMemory, getChannelMemoryRevision, @@ -580,6 +583,7 @@ export async function runChannelDaemonWorker( writeStdoutLine(`[Channel] Connecting "${safeName}"...`); try { await abortableStartup(channel.connect(), startupSignal); + throwIfStartupAborted(startupSignal); connected.push(name); writeStdoutLine(`[Channel] "${safeName}" connected.`); } catch (err) { @@ -914,6 +918,11 @@ export const daemonWorkerCommand: CommandModule = { 'channel daemon worker', ); const send = process.send!; + let terminalDisconnect: { channelName: string; error: Error } | undefined; + let notifyTerminalDisconnect!: () => void; + const terminalDisconnected = new Promise((resolve) => { + notifyTerminalDisconnect = resolve; + }); channelLoopMcpHost = new ChannelLoopMcpWorkerHost((message, callback) => send.call(process, message, callback ?? (() => {})), ); @@ -944,6 +953,11 @@ export const daemonWorkerCommand: CommandModule = { sendReady: (ready) => { process.send?.({ type: 'ready', ...ready }); }, + onTerminalDisconnect: (channelName, error) => { + terminalDisconnect = { channelName, error }; + startupAbortController.abort(); + notifyTerminalDisconnect(); + }, }); removeEarlyShutdownHandlers(); @@ -1171,6 +1185,7 @@ export const daemonWorkerCommand: CommandModule = { process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); process.once('disconnect', onDisconnect); + void terminalDisconnected.then(() => shutdown('SIGTERM')); if (pendingShutdownReason) { void shutdown(pendingShutdownReason); } @@ -1180,6 +1195,12 @@ export const daemonWorkerCommand: CommandModule = { process.removeListener('SIGINT', shutdown); process.removeListener('SIGTERM', shutdown); process.removeListener('disconnect', onDisconnect); + if (terminalDisconnect) { + writeStderrLine( + `[Channel] "${sanitizeLogText(terminalDisconnect.channelName, 128)}" disconnected permanently: ${sanitizeLogText(terminalDisconnect.error.message, 512)}`, + ); + exitCode = 1; + } process.exit(exitCode); } catch (err) { removeEarlyShutdownHandlers(); diff --git a/packages/cli/src/serve/channel-management-service.test.ts b/packages/cli/src/serve/channel-management-service.test.ts index 5986aa1c2..c2c3c147b 100644 --- a/packages/cli/src/serve/channel-management-service.test.ts +++ b/packages/cli/src/serve/channel-management-service.test.ts @@ -10,6 +10,7 @@ import * as path from 'node:path'; import { PairingStore } from '@qwen-code/channel-base'; import type { CreatePairingRequestResult } from '@qwen-code/channel-base'; import { describe, expect, it, vi } from 'vitest'; +import { daemonChannelStateDir } from '../commands/channel/runtime.js'; import type { ChannelSettingsSnapshot } from './channel-settings-store.js'; import { createChannelManagementService, @@ -558,6 +559,49 @@ describe('createChannelManagementService', () => { expect(persisted().channels['bot']).toBeDefined(); }); + it('removes adapter state when a channel is deleted', async () => { + const previousQwenHome = process.env['QWEN_HOME']; + const qwenHome = await fs.mkdtemp( + path.join(os.tmpdir(), 'channel-management-remove-'), + ); + process.env['QWEN_HOME'] = qwenHome; + try { + const stateDir = daemonChannelStateDir(WORKSPACE, 'bot'); + await fs.mkdir(stateDir, { recursive: true }); + await fs.writeFile(path.join(stateDir, 'credentials.json'), 'secret'); + const { service, store } = setup({ committedNames: ['bot'] }); + + store.remove.mockRejectedValueOnce(new Error('settings write failed')); + await expect( + service.remove('bot', { expectedRevision: 'rev-1' }), + ).rejects.toThrow('settings write failed'); + await expect( + fs.readFile(path.join(stateDir, 'credentials.json'), 'utf8'), + ).resolves.toBe('secret'); + + await service.remove('bot', { expectedRevision: 'rev-1' }); + + await expect(fs.stat(stateDir)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(store.remove).toHaveBeenCalledWith('bot', { + expectedRevision: 'rev-1', + }); + + const tombstone = `${stateDir}.deleting-stale`; + await fs.mkdir(tombstone, { recursive: true }); + await fs.writeFile(path.join(tombstone, 'credentials.json'), 'secret'); + await expect( + service.remove('bot', { expectedRevision: 'rev-2' }), + ).rejects.toMatchObject({ code: 'channel_instance_not_found' }); + await expect(fs.stat(tombstone)).rejects.toMatchObject({ + code: 'ENOENT', + }); + } finally { + if (previousQwenHome === undefined) delete process.env['QWEN_HOME']; + else process.env['QWEN_HOME'] = previousQwenHome; + await fs.rm(qwenHome, { recursive: true, force: true }); + } + }); + it('rejects stale removal before changing runtime state', async () => { const { service, store, manager } = setup({ committedNames: ['bot'] }); diff --git a/packages/cli/src/serve/channel-management-service.ts b/packages/cli/src/serve/channel-management-service.ts index 75e647ed4..524883364 100644 --- a/packages/cli/src/serve/channel-management-service.ts +++ b/packages/cli/src/serve/channel-management-service.ts @@ -4,6 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { randomUUID } from 'node:crypto'; +import { readdir, rename, rm } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; import { redactLogCredentials } from '@qwen-code/acp-bridge/logRedaction'; import { canonicalizeWorkspace } from '@qwen-code/acp-bridge/workspacePaths'; import { @@ -12,6 +15,7 @@ import { type PairingRequest, } from '@qwen-code/channel-base'; import { resolveChannelCwd } from '../commands/channel/channel-cwd.js'; +import { daemonChannelStateDir } from '../commands/channel/runtime.js'; import { getPlugin } from '../commands/channel/channel-registry.js'; import type { ChannelSecretUpdate, @@ -73,6 +77,28 @@ export interface ChannelPairingRequestsSnapshot { requests: PairingRequest[]; } +async function removeStagedChannelState(stateDir: string): Promise { + const parent = dirname(stateDir); + const prefix = `${basename(stateDir)}.deleting-`; + const entries = await readdir(parent, { withFileTypes: true }).catch( + (error: unknown) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + }, + ); + await Promise.all( + entries + .filter((entry) => entry.name.startsWith(prefix)) + .map((entry) => + rm(join(parent, entry.name), { + recursive: true, + force: true, + maxRetries: 3, + }), + ), + ); +} + export interface ChannelPairingApprovalResult extends ChannelPairingRequestsSnapshot { approved: PairingRequest; @@ -443,8 +469,10 @@ export function createChannelManagementService( }, async remove(name, request) { assertManageableInstanceName(name); + const stateDir = daemonChannelStateDir(opts.workspaceCwd, name); const current = opts.store.snapshot(); if (!Object.hasOwn(current.channels, name)) { + await removeStagedChannelState(stateDir); throw new ChannelManagementError( 'channel_instance_not_found', `Channel "${name}" is not configured in this workspace.`, @@ -456,7 +484,22 @@ export function createChannelManagementService( assertOwnedRuntime(name); await stopChannel(name); } - const persisted = await opts.store.remove(name, request); + const stagedStateDir = `${stateDir}.deleting-${randomUUID()}`; + let stagedState = false; + try { + await rename(stateDir, stagedStateDir); + stagedState = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + let persisted: ChannelSettingsSnapshot; + try { + persisted = await opts.store.remove(name, request); + } catch (error) { + if (stagedState) await rename(stagedStateDir, stateDir); + throw error; + } + await removeStagedChannelState(stateDir); diagnostics.delete(name); return resultFor(name, persisted); }, diff --git a/packages/cli/src/serve/multi-workspace-sessions.test.ts b/packages/cli/src/serve/multi-workspace-sessions.test.ts index c0a00a8d9..1271aba6b 100644 --- a/packages/cli/src/serve/multi-workspace-sessions.test.ts +++ b/packages/cli/src/serve/multi-workspace-sessions.test.ts @@ -1081,6 +1081,59 @@ describe('multi-workspace session dispatch', () => { expect(res.body.features).toContain('multi_workspace_session_shell'); }); + it('restores an unqualified dormant session through its persisted workspace owner', async () => { + await withRuntimeDir(async () => { + const sessionId = '00000000-0000-4000-8000-000000000002'; + await writeStoredSession({ + sessionId, + cwd: SECONDARY_CWD, + timestamp: '2026-07-08T00:00:00.000Z', + prompt: 'secondary owner', + mtime: new Date('2026-07-08T00:00:00.000Z'), + }); + const { app, registry, primaryBridge, secondaryBridge } = makeHarness({ + primarySummaries: [], + secondarySummaries: [], + }); + registry.beginReplacement(registry.primaryEntry, 'policy-2'); + + const response = await request(app) + .post(`/session/${sessionId}/load`) + .set('Host', host()) + .send({}); + + expect(response.status).toBe(200); + expect(response.body.workspaceCwd).toBe(SECONDARY_CWD); + expect(primaryBridge.restoreCalls).toEqual([]); + expect(secondaryBridge.restoreCalls).toEqual([ + { + action: 'load', + req: expect.objectContaining({ + sessionId, + workspaceCwd: SECONDARY_CWD, + }), + }, + ]); + }); + }); + + it('does not fall back to primary for an unqualified unknown session', async () => { + const { app, primaryBridge, secondaryBridge } = makeHarness({ + primarySummaries: [], + secondarySummaries: [], + }); + + const response = await request(app) + .post('/session/missing-unqualified/load') + .set('Host', host()) + .send({}); + + expect(response.status).toBe(404); + expect(response.body.code).toBe('session_not_found'); + expect(primaryBridge.restoreCalls).toEqual([]); + expect(secondaryBridge.restoreCalls).toEqual([]); + }); + it('aggregates daemon status session count and exposes workspace metadata', async () => { const { app } = makeHarness(); const res = await request(app).get('/daemon/status').set('Host', host()); diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 0dba63bca..3ed3a327a 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -118,6 +118,7 @@ import { sendWorkspaceRuntimeUnavailable, } from '../workspace-route-runtime.js'; import type { + WorkspaceEntry, WorkspaceRegistry, WorkspaceRuntime, } from '../workspace-registry.js'; @@ -988,40 +989,127 @@ export function registerSessionRoutes( }); }; - const resolveRuntimeForSessionRestore = ( + const resolveRuntimeForSessionRestore = async ( body: Record, res: Response, route: string, sessionId: string, - ): { runtime: WorkspaceRuntime; workspaceCwd: string } | undefined => { + ): Promise< + { runtime: WorkspaceRuntime; workspaceCwd: string } | undefined + > => { const cwd = parseOptionalWorkspaceCwd(body, boundWorkspace, res); if (cwd === undefined) return undefined; - let key: string; - try { - key = canonicalizeWorkspace(cwd); - } catch (err) { - if ('cwd' in body) { + const hasExplicitWorkspace = 'cwd' in body; + const entries = workspaceRegistry.listEntries(); + let key: string | undefined; + if (hasExplicitWorkspace) { + try { + key = canonicalizeWorkspace(cwd); + } catch { logSessionRoutingFailure(route, 'workspace_mismatch', { requestedWorkspace: cwd, }); sendWorkspaceMismatch(res, cwd); return undefined; } - sendBridgeError(res, err, { route, sessionId }); - return undefined; } - const runtime = workspaceRegistry.resolveWorkspaceCwd( - 'cwd' in body ? key : undefined, - ); - if (!runtime) { - logSessionRoutingFailure(route, 'workspace_mismatch', { - requestedWorkspace: key, - }); - sendWorkspaceMismatch(res, key); + let runtime: WorkspaceRuntime | undefined; + let telemetryWorkspacePublished = false; + if (hasExplicitWorkspace) { + const selectedEntry = workspaceRegistry.getEntryByWorkspaceCwd(key!); + if (!selectedEntry) { + logSessionRoutingFailure(route, 'workspace_mismatch', { + requestedWorkspace: key, + }); + sendWorkspaceMismatch(res, key!); + return undefined; + } + if (selectedEntry.state !== 'active' || !selectedEntry.current) { + sendWorkspaceRuntimeUnavailable(res, selectedEntry); + return undefined; + } + runtime = selectedEntry.current.runtime; + setDaemonTelemetryWorkspace(res, runtime.workspaceCwd); + telemetryWorkspacePublished = true; + } else if (entries.length === 1) { + const onlyEntry = entries[0]!; + runtime = onlyEntry.current?.runtime; + if (runtime) { + setDaemonTelemetryWorkspace(res, runtime.workspaceCwd); + telemetryWorkspacePublished = true; + } + if (onlyEntry.state !== 'active' || !runtime) { + sendWorkspaceRuntimeUnavailable(res, onlyEntry); + return undefined; + } + } + + const liveOwner = workspaceRegistry.resolveLiveSessionOwner(sessionId); + if (liveOwner.kind === 'ambiguous') { + sendAmbiguousSessionOwner(res, route, sessionId, liveOwner.runtimes); return undefined; } - setDaemonTelemetryWorkspace(res, runtime.workspaceCwd); + if (hasExplicitWorkspace) { + if ( + liveOwner.kind === 'found' && + liveOwner.runtime.workspaceCwd !== runtime!.workspaceCwd + ) { + sendSessionWorkspaceConflict( + res, + route, + sessionId, + runtime!, + liveOwner.runtime, + ); + return undefined; + } + } else if (liveOwner.kind === 'found') { + runtime = liveOwner.runtime; + } else if (!runtime) { + const persistedOwners: Array<{ + entry: WorkspaceEntry; + runtime: WorkspaceRuntime; + }> = []; + for (const entry of entries) { + const candidate = entry.current?.runtime; + if (!candidate) continue; + const location = + await createWorkspaceRuntimeSessionService( + candidate, + ).getSessionLocation(sessionId); + if (location !== undefined) { + persistedOwners.push({ entry, runtime: candidate }); + } + } + if (persistedOwners.length > 1) { + sendAmbiguousSessionOwner( + res, + route, + sessionId, + persistedOwners.map((owner) => owner.runtime), + ); + return undefined; + } + const persistedOwner = persistedOwners[0]; + if (persistedOwner) { + if ( + persistedOwner.entry.state !== 'active' || + persistedOwner.entry.current?.runtime !== persistedOwner.runtime + ) { + sendWorkspaceRuntimeUnavailable(res, persistedOwner.entry); + return undefined; + } + runtime = persistedOwner.runtime; + } else { + throw new SessionNotFoundError(sessionId); + } + } + + if (!runtime) throw new SessionNotFoundError(sessionId); + if (!telemetryWorkspacePublished) { + setDaemonTelemetryWorkspace(res, runtime.workspaceCwd); + } if (!runtime.primary && !runtime.trusted) { logSessionRoutingFailure(route, 'untrusted_workspace', { workspaceId: runtime.workspaceId, @@ -1034,25 +1122,6 @@ export function registerSessionRoutes( return undefined; } - const liveOwner = workspaceRegistry.resolveLiveSessionOwner(sessionId); - if (liveOwner.kind === 'ambiguous') { - sendAmbiguousSessionOwner(res, route, sessionId, liveOwner.runtimes); - return undefined; - } - if ( - liveOwner.kind === 'found' && - liveOwner.runtime.workspaceCwd !== runtime.workspaceCwd - ) { - sendSessionWorkspaceConflict( - res, - route, - sessionId, - runtime, - liveOwner.runtime, - ); - return undefined; - } - return { runtime, workspaceCwd: runtime.workspaceCwd }; }; @@ -2080,7 +2149,7 @@ export function registerSessionRoutes( | { runtime: WorkspaceRuntime; workspaceCwd: string } | undefined; try { - resolvedRuntime = resolveRuntimeForSessionRestore( + resolvedRuntime = await resolveRuntimeForSessionRestore( body, res, route, diff --git a/packages/cli/src/ui/commands/effort-command.test.ts b/packages/cli/src/ui/commands/effort-command.test.ts index b14e49b6a..dc063c221 100644 --- a/packages/cli/src/ui/commands/effort-command.test.ts +++ b/packages/cli/src/ui/commands/effort-command.test.ts @@ -99,6 +99,16 @@ describe('effortCommand', () => { expect(setReasoningEffort).toHaveBeenCalledWith('xhigh'); }); + it('clears the override with default', async () => { + await effortCommand.action!(context, 'default'); + expect(setReasoningEffort).toHaveBeenCalledWith(undefined); + expect(setValue).toHaveBeenCalledWith( + expect.anything(), + 'model.reasoningEffort', + undefined, + ); + }); + it('rejects an unknown tier without mutating config or settings', async () => { const res = await effortCommand.action!(context, 'turbo'); expect(setReasoningEffort).not.toHaveBeenCalled(); @@ -110,6 +120,8 @@ describe('effortCommand', () => { // No completion so bare `/effort` opens the picker instead of auto-picking // the first tier; `/effort ` still parses in the action above. expect(effortCommand.completion).toBeUndefined(); - expect(effortCommand.argumentHint).toBe('[low|medium|high|xhigh|max]'); + expect(effortCommand.argumentHint).toBe( + '[default|low|medium|high|xhigh|max]', + ); }); }); diff --git a/packages/cli/src/ui/commands/effort-command.ts b/packages/cli/src/ui/commands/effort-command.ts index 34e3be1fe..28118d963 100644 --- a/packages/cli/src/ui/commands/effort-command.ts +++ b/packages/cli/src/ui/commands/effort-command.ts @@ -34,7 +34,7 @@ export const effortCommand: SlashCommand = { // (no tier auto-selected), while `/effort ` still sets one directly. A // completion function would surface the tiers as submenu-like entries and let // Enter auto-pick the first one, which we don't want here. - argumentHint: '[low|medium|high|xhigh|max]', + argumentHint: '[default|low|medium|high|xhigh|max]', kind: CommandKind.BUILT_IN, supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: async ( @@ -76,8 +76,11 @@ export const effortCommand: SlashCommand = { }; } - const tier = normalizeReasoningEffort(args); - if (!tier) { + const tier = + args.toLowerCase() === 'default' + ? undefined + : normalizeReasoningEffort(args); + if (tier === undefined && args.toLowerCase() !== 'default') { return { type: 'message', messageType: 'error', @@ -99,11 +102,16 @@ export const effortCommand: SlashCommand = { // Apply at runtime (takes effect next turn) and persist for future sessions. // Provider adapters clamp the tier to what the active model supports. const applied = applyReasoningEffort(config, tier); - settings.setValue( - getPersistScopeForModelSelection(settings), - 'model.reasoningEffort', - tier, - ); + const scope = getPersistScopeForModelSelection(settings); + settings.setValue(scope, 'model.reasoningEffort', tier); + + if (!tier) { + return { + type: 'message', + messageType: 'info', + content: t('Reasoning effort: model/provider default.'), + }; + } // `setReasoningEffort` is a no-op when thinking is explicitly disabled // (`reasoning: false`), so effort cannot silently re-enable it. The tier is diff --git a/packages/desktop-shell/README.md b/packages/desktop-shell/README.md index 7feeecbdc..13f37da89 100644 --- a/packages/desktop-shell/README.md +++ b/packages/desktop-shell/README.md @@ -7,6 +7,8 @@ This package is an isolated Tauri 2 shell around the existing Web Shell. It does `npm run build:runtime` prepares `runtime/openwork/` with: - the current platform's Node.js runtime, +- a pinned `uv` runtime and the eight historical document-tool launchers, +- the document Python scripts and first-launch migration entrypoint, - the bundled `qwen` CLI, - the built Web Shell under `lib/web-shell/`. @@ -24,10 +26,21 @@ npm run build:runtime --workspaces=false npm run dev --workspaces=false ``` +Set `OPENWORK_UV_DOWNLOAD_ROOT` to a trusted mirror of the pinned uv release +directory when GitHub release assets are unavailable. + The install and runtime build are only needed the first time or after dependencies/runtime sources change. For later runs, `npm run dev --workspaces=false` is enough. Run `npm test --workspaces=false` for the Rust checks. Use `OPENWORK_DESKTOP_WORKSPACE=/absolute/path` to override the initial workspace. The app otherwise restores its saved primary workspace or creates `~/Documents/OpenWork` on first launch. `OPENWORK_DEFAULT_WORKSPACE_DIR=/absolute/path` relocates that first-launch default, matching the Electron shell. Add and switch project workspaces from the Web Shell after startup. +On first launch, the shell non-destructively imports compatible session and preference data from `~/.craft-agent`, records checksums in `~/.qwen/openwork-migration-v1.json`, and leaves credentials untouched. The existing `$QWEN_HOME/oauth_creds.json` remains the shared Qwen login, while legacy encrypted third-party credentials stay in place for rollback. To roll back unchanged migration-created files, run `node runtime/openwork/tools/openwork-migrate.mjs --rollback` from an unpacked app runtime or invoke the same bundled script with `QWEN_HOME` pointed at the target Qwen directory. + +Custom desktop pets are discovered from `~/.qwen/pets//pet.json`; the manifest's sprite path must remain inside that pet directory. + ## Releases -PR1 supports local development and local bundle builds. Updater artifacts, signing, notarization, and release automation are deferred to PR2. +Run the **Desktop Release** workflow with a semantic version. Dry runs upload installers as workflow artifacts; published runs must start from `main` and create `openwork-v` with the updater manifest and signatures. The matrix builds Apple Silicon and Intel macOS packages, Windows x64 installers, and Linux x64 AppImage/deb packages. Each matrix job runs the Rust and release-contract tests, verifies the bundled runtime, and starts the packaged application before publishing. + +Published releases require `TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PUBLIC_KEY`; set `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` when the key is encrypted. macOS additionally requires `APPLE_CERTIFICATE`, `APPLE_CERTIFICATE_PASSWORD`, `APPLE_API_ISSUER`, `APPLE_API_KEY`, and `APPLE_API_KEY_P8_BASE64`; the existing `MAC_CSC_*` and `APPLE_NOTARY_*` names remain accepted. Windows requires a base64 PFX or HTTPS certificate URL in `WINDOWS_CERTIFICATE` plus `WINDOWS_CERTIFICATE_PASSWORD`; the existing `WIN_CSC_*` names remain accepted. + +The updater public key is injected into release builds. Unsigned local and dry-run builds can compile and run, but cannot install release updates. diff --git a/packages/desktop-shell/bootstrap/bootstrap.js b/packages/desktop-shell/bootstrap/bootstrap.js index d65a1c913..97033f0dd 100644 --- a/packages/desktop-shell/bootstrap/bootstrap.js +++ b/packages/desktop-shell/bootstrap/bootstrap.js @@ -12,12 +12,16 @@ const retry = document.querySelector('#retry'); const logs = document.querySelector('#logs'); const version = document.querySelector('#version'); +let currentWorkspace = ''; +let snapshotOverrideStatus; + function setWorkspace(path) { workspace.hidden = !path; workspace.textContent = path || ''; } function setStatus(kind, heading, message, failure = '') { + document.body.dataset.state = kind; title.textContent = heading; detail.textContent = message; pulse.className = `pulse ${kind === 'starting' ? '' : kind}`; @@ -26,10 +30,12 @@ function setStatus(kind, heading, message, failure = '') { retry.hidden = kind !== 'error'; choose.hidden = kind === 'starting'; choose.disabled = kind === 'starting'; + setWorkspace(kind === 'starting' ? '' : currentWorkspace); } async function chooseWorkspace() { if (!invoke) return; + snapshotOverrideStatus = 'starting'; setStatus( 'starting', 'Opening workspace', @@ -37,7 +43,7 @@ async function chooseWorkspace() { ); try { const path = await invoke('choose_workspace'); - if (path) setWorkspace(path); + if (path) currentWorkspace = path; else setStatus('idle', 'Choose another workspace', 'No folder was selected.'); } catch (failure) { @@ -52,6 +58,7 @@ async function chooseWorkspace() { async function retryRuntime() { if (!invoke) return; + snapshotOverrideStatus = 'starting'; setStatus( 'starting', 'Restarting OpenWork', @@ -100,7 +107,8 @@ async function initialize() { await Promise.all([ listen('runtime-starting', ({ payload }) => { - setWorkspace(payload); + snapshotOverrideStatus = 'starting'; + currentWorkspace = String(payload || ''); setStatus( 'starting', 'Starting OpenWork', @@ -108,6 +116,7 @@ async function initialize() { ); }), listen('runtime-failed', ({ payload }) => { + snapshotOverrideStatus = 'failed'; setStatus( 'error', 'OpenWork could not start', @@ -119,7 +128,11 @@ async function initialize() { const state = await invoke('bootstrap_state'); version.textContent = `Desktop ${state.desktopVersion}`; - setWorkspace(state.workspace); + currentWorkspace ||= String(state.workspace || ''); + if (snapshotOverrideStatus) { + if (snapshotOverrideStatus === 'failed') setWorkspace(currentWorkspace); + return; + } if (state.status === 'starting') { setStatus( 'starting', diff --git a/packages/desktop-shell/bootstrap/index.html b/packages/desktop-shell/bootstrap/index.html index 76ae68cbd..35ec536f8 100644 --- a/packages/desktop-shell/bootstrap/index.html +++ b/packages/desktop-shell/bootstrap/index.html @@ -80,10 +80,78 @@ gap: 18px; } .mark { - display: block; width: 58px; height: 58px; object-fit: contain; + filter: drop-shadow(0 14px 24px rgba(88, 63, 226, 0.38)); + } + body[data-state='starting'] { + background: #070b12; + } + body[data-state='starting']::before, + body[data-state='starting'] .brand-copy, + body[data-state='starting'] .actions, + body[data-state='starting'] footer { + display: none; + } + body[data-state='starting'] .shell { + width: auto; + border: 0; + padding: 0; + background: none; + box-shadow: none; + backdrop-filter: none; + } + body[data-state='starting'] .mark { + animation: mark-pulse 1.6s ease-in-out infinite; + } + body[data-state='starting'] .status { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + border: 0; + clip: rect(0, 0, 0, 0); + clip-path: inset(50%); + white-space: nowrap; + } + @keyframes mark-pulse { + 50% { + opacity: 0.55; + transform: scale(0.94); + } + } + @media (prefers-reduced-motion: reduce) { + body[data-state='starting'] .mark { + animation: none; + } + body[data-state='starting'] .brand { + justify-content: center; + } + body[data-state='starting'] .status { + position: static; + width: auto; + height: auto; + margin: 18px 0 0; + overflow: visible; + clip: auto; + clip-path: none; + white-space: normal; + grid-template-columns: 1fr; + color: #8d98aa; + background: none; + text-align: center; + } + body[data-state='starting'] .status > :first-child, + body[data-state='starting'] .status > div > :not(#title) { + display: none; + } + body[data-state='starting'] #title { + font-size: 13px; + font-weight: 500; + } } h1 { margin: 0; @@ -99,7 +167,7 @@ } .status { display: grid; - grid-template-columns: 22px 1fr; + grid-template-columns: 22px minmax(0, 1fr); gap: 14px; margin-top: 40px; padding: 22px 24px; @@ -226,12 +294,12 @@ } - +
- -
+ OpenWork +

OpenWork

Desktop workspace

diff --git a/packages/desktop-shell/bootstrap/pet.html b/packages/desktop-shell/bootstrap/pet.html new file mode 100644 index 000000000..adcb62856 --- /dev/null +++ b/packages/desktop-shell/bootstrap/pet.html @@ -0,0 +1,65 @@ + + + + + + + OpenWork Pet + + + +
+ + + diff --git a/packages/desktop-shell/bootstrap/pet.js b/packages/desktop-shell/bootstrap/pet.js new file mode 100644 index 000000000..2001b9b37 --- /dev/null +++ b/packages/desktop-shell/bootstrap/pet.js @@ -0,0 +1,15 @@ +const petId = new URLSearchParams(window.location.search).get('pet') || 'qwen'; + +if (petId !== 'qwen') { + window.__TAURI__.core + .invoke('resolve_pet_sprite', { petId }) + .then((file) => { + if (!file) return; + document.querySelector('.pet').style.backgroundImage = + `url("${window.__TAURI__.core.convertFileSrc(file)}")`; + document + .querySelector('.pet') + .setAttribute('aria-label', `${petId} desktop pet`); + }) + .catch(console.error); +} diff --git a/packages/desktop-shell/bootstrap/qwen-pet.webp b/packages/desktop-shell/bootstrap/qwen-pet.webp new file mode 100644 index 000000000..94d668752 Binary files /dev/null and b/packages/desktop-shell/bootstrap/qwen-pet.webp differ diff --git a/packages/desktop-shell/migration/openwork-migrate.mjs b/packages/desktop-shell/migration/openwork-migrate.mjs new file mode 100644 index 000000000..909a09275 --- /dev/null +++ b/packages/desktop-shell/migration/openwork-migrate.mjs @@ -0,0 +1,360 @@ +#!/usr/bin/env node + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; + +const VERSION = 1; +const legacyRoot = path.resolve( + process.env.OPENWORK_LEGACY_CONFIG_DIR || + path.join(os.homedir(), '.craft-agent'), +); +const qwenRoot = path.resolve( + process.env.QWEN_HOME || path.join(os.homedir(), '.qwen'), +); +const reportPath = path.join(qwenRoot, `openwork-migration-v${VERSION}.json`); + +if (process.argv.includes('--rollback')) rollback(); +else migrate(); + +function migrate() { + const previous = readJson(reportPath, true); + if ( + previous?.version === VERSION && + (previous.migratedAt || previous.rolledBackAt) + ) + return; + + const createdFiles = + previous?.version === VERSION && Array.isArray(previous.createdFiles) + ? previous.createdFiles + : []; + const reusedSessions = []; + const skippedSessions = []; + const configPath = path.join(legacyRoot, 'config.json'); + const config = readJson(configPath, true); + const workspaces = Array.isArray(config?.workspaces) ? config.workspaces : []; + + for (const workspace of workspaces) { + if (!workspace || typeof workspace !== 'object') continue; + const workspaceRoot = resolveLegacyPath(workspace.rootPath); + if (!workspaceRoot) continue; + const workspaceConfig = readJson( + path.join(workspaceRoot, 'config.json'), + true, + ); + const targetCwd = + resolveLegacyPath( + workspaceConfig?.defaults?.workingDirectory, + workspaceRoot, + ) || workspaceRoot; + migrateSessions( + workspaceRoot, + targetCwd, + createdFiles, + reusedSessions, + skippedSessions, + ); + archiveMetadata( + workspaceRoot, + String(workspace.id || path.basename(workspaceRoot)), + createdFiles, + ); + } + + fs.mkdirSync(qwenRoot, { recursive: true, mode: 0o700 }); + writeAtomic(reportPath, { + version: VERSION, + migratedAt: new Date().toISOString(), + legacyRoot, + createdFiles, + reusedSessions, + skippedSessions, + retainedCredentials: [ + path.join(legacyRoot, 'credentials.enc'), + path.join(qwenRoot, 'oauth_creds.json'), + ], + }); +} + +function migrateSessions( + workspaceRoot, + targetCwd, + createdFiles, + reusedSessions, + skippedSessions, +) { + const sessionsDir = path.join(workspaceRoot, 'sessions'); + for (const entry of readDirectory(sessionsDir)) { + if (!entry.isDirectory() || entry.isSymbolicLink()) continue; + const legacySession = path.join(sessionsDir, entry.name, 'session.jsonl'); + const header = readFirstJsonLine(legacySession); + const sessionId = + typeof header?.sdkSessionId === 'string' + ? header.sdkSessionId + : typeof header?.id === 'string' + ? header.id + : undefined; + if (!sessionId || !/^[A-Za-z0-9._-]{1,128}$/.test(sessionId)) { + skippedSessions.push({ legacySession, reason: 'invalid session id' }); + continue; + } + const sourceCwd = + resolveLegacyPath(header.sdkCwd || header.workingDirectory) || targetCwd; + const source = sessionPath(sourceCwd, sessionId); + const destination = sessionPath(targetCwd, sessionId); + if (source === destination) { + if (!fs.statSync(source, { throwIfNoEntry: false })?.isFile()) { + skippedSessions.push({ + sessionId, + legacySession, + reason: 'native transcript missing', + }); + continue; + } + reusedSessions.push({ sessionId, path: destination }); + continue; + } + if (fs.existsSync(destination)) { + reusedSessions.push({ sessionId, path: destination }); + continue; + } + if (!fs.statSync(source, { throwIfNoEntry: false })?.isFile()) { + skippedSessions.push({ + sessionId, + legacySession, + reason: 'native transcript missing', + }); + continue; + } + const transcript = fs.readFileSync(source, 'utf8'); + let records; + try { + records = transcript + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line)); + if ( + records.some( + (record) => + !record || typeof record !== 'object' || Array.isArray(record), + ) + ) { + throw new Error('invalid native transcript record'); + } + } catch { + skippedSessions.push({ + sessionId, + legacySession, + reason: 'invalid native transcript', + }); + continue; + } + for (const record of records) record.cwd = targetCwd; + const title = typeof header.name === 'string' ? header.name.trim() : ''; + if ( + title && + !records.some( + (record) => + record.type === 'system' && record.subtype === 'custom_title', + ) + ) { + records.push({ + uuid: crypto.randomUUID(), + parentUuid: records.at(-1)?.uuid ?? null, + sessionId, + timestamp: new Date().toISOString(), + type: 'system', + subtype: 'custom_title', + cwd: targetCwd, + version: records[0]?.version, + systemPayload: { customTitle: title, titleSource: 'manual' }, + }); + } + createFile( + destination, + `${records.map((record) => JSON.stringify(record)).join('\n')}\n`, + createdFiles, + ); + } +} + +function archiveMetadata(workspaceRoot, workspaceId, createdFiles) { + const destination = path.join( + qwenRoot, + 'openwork-legacy-v1', + safeName(workspaceId), + ); + for (const name of [ + 'config.json', + 'labels', + 'statuses', + 'sources', + 'automations', + ]) { + copyMetadata( + path.join(workspaceRoot, name), + path.join(destination, name), + createdFiles, + ); + } +} + +function copyMetadata(source, destination, createdFiles) { + if (path.basename(source) === '.credential-cache.json') return; + const metadata = fs.lstatSync(source, { throwIfNoEntry: false }); + if (!metadata || metadata.isSymbolicLink()) return; + if (metadata.isDirectory()) { + for (const entry of readDirectory(source)) { + if (!entry.isSymbolicLink()) { + copyMetadata( + path.join(source, entry.name), + path.join(destination, entry.name), + createdFiles, + ); + } + } + } else if (metadata.isFile()) { + createFile(destination, fs.readFileSync(source), createdFiles); + } +} + +function rollback() { + const report = readJson(reportPath, true); + if (report?.version !== VERSION || report.rolledBackAt) return; + for (const created of Array.isArray(report.createdFiles) + ? report.createdFiles + : []) { + const file = path.resolve(qwenRoot, created.path || ''); + if ( + file.startsWith(`${qwenRoot}${path.sep}`) && + isContainedFile(file, qwenRoot) && + sha256(fs.readFileSync(file)) === created.sha256 + ) { + fs.rmSync(file); + } + } + writeAtomic(reportPath, { + ...report, + rolledBackAt: new Date().toISOString(), + }); +} + +function createFile(destination, contents, createdFiles) { + if (fs.existsSync(destination)) return; + fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 }); + const created = { + path: path.relative(qwenRoot, destination), + sha256: sha256(contents), + }; + const previous = createdFiles.findIndex( + (entry) => entry?.path === created.path, + ); + if (previous === -1) createdFiles.push(created); + else createdFiles[previous] = created; + writeAtomic(reportPath, { version: VERSION, legacyRoot, createdFiles }); + const temporary = `${destination}.${process.pid}.${crypto.randomUUID()}.tmp`; + try { + fs.writeFileSync(temporary, contents, { flag: 'wx', mode: 0o600 }); + fs.renameSync(temporary, destination); + } finally { + fs.rmSync(temporary, { force: true }); + } +} + +function writeAtomic(destination, value) { + fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 }); + const temporary = `${destination}.${process.pid}.tmp`; + fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { + mode: 0o600, + }); + fs.renameSync(temporary, destination); +} + +function sessionPath(cwd, sessionId) { + return path.join( + qwenRoot, + 'projects', + sanitizeCwd(cwd), + 'chats', + `${sessionId}.jsonl`, + ); +} + +function sanitizeCwd(cwd) { + const normalized = process.platform === 'win32' ? cwd.toLowerCase() : cwd; + return normalized.replace(/[^a-zA-Z0-9]/g, '-'); +} + +function resolveLegacyPath(value, base = legacyRoot) { + if (typeof value !== 'string' || !value.trim()) return undefined; + const expanded = value + .replace(/^~(?=$|[\\/])/, os.homedir()) + .replace(/\$\{HOME\}/g, os.homedir()); + return path.resolve(base, expanded); +} + +function readJson(file, strict = false) { + let contents; + try { + contents = fs.readFileSync(file, 'utf8'); + } catch (error) { + if (isMissing(error)) return undefined; + throw error; + } + try { + return JSON.parse(contents); + } catch (error) { + if (strict) throw error; + return undefined; + } +} + +function readFirstJsonLine(file) { + let contents; + try { + contents = fs.readFileSync(file, 'utf8'); + } catch (error) { + if (!isMissing(error)) throw error; + return undefined; + } + try { + return JSON.parse(contents.split(/\r?\n/, 1)[0]); + } catch { + return undefined; + } +} + +function readDirectory(directory) { + try { + return fs.readdirSync(directory, { withFileTypes: true }); + } catch (error) { + if (isMissing(error)) return []; + throw error; + } +} + +function isMissing(error) { + return error?.code === 'ENOENT'; +} + +function safeName(value) { + return value.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 128) || 'workspace'; +} + +function isContainedFile(file, root) { + try { + const boundary = `${fs.realpathSync(root)}${path.sep}`; + return ( + fs.lstatSync(file).isFile() && fs.realpathSync(file).startsWith(boundary) + ); + } catch { + return false; + } +} + +function sha256(contents) { + return crypto.createHash('sha256').update(contents).digest('hex'); +} diff --git a/packages/desktop-shell/package-lock.json b/packages/desktop-shell/package-lock.json index bba030b18..373496b05 100644 --- a/packages/desktop-shell/package-lock.json +++ b/packages/desktop-shell/package-lock.json @@ -1,12 +1,12 @@ { "name": "@openwork/desktop-shell", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@openwork/desktop-shell", - "version": "0.1.0", + "version": "0.2.0", "devDependencies": { "@tauri-apps/cli": "^2.8.5" } diff --git a/packages/desktop-shell/package.json b/packages/desktop-shell/package.json index ee6f0e67b..05f6cb53b 100644 --- a/packages/desktop-shell/package.json +++ b/packages/desktop-shell/package.json @@ -1,6 +1,6 @@ { "name": "@openwork/desktop-shell", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "scripts": { @@ -10,6 +10,7 @@ "build:runtime": "node scripts/prepare-runtime.js", "smoke:runtime": "node scripts/smoke-runtime.js", "smoke:packaged": "node scripts/smoke-packaged.js", + "test:migration": "node scripts/test-migration.js", "test:release": "node scripts/test-release.js", "version": "node scripts/version.js", "test": "cargo test --manifest-path src-tauri/Cargo.toml" diff --git a/packages/desktop-shell/scripts/prepare-runtime.js b/packages/desktop-shell/scripts/prepare-runtime.js index 9852af335..23a293274 100755 --- a/packages/desktop-shell/scripts/prepare-runtime.js +++ b/packages/desktop-shell/scripts/prepare-runtime.js @@ -18,8 +18,21 @@ const sourceRoot = process.env.OPENWORK_ROOT?.trim() : repoRoot; const runtimeDir = path.join(packageDir, 'runtime'); const packageRoot = path.join(runtimeDir, 'openwork'); +const refreshChecksums = process.argv.indexOf('--refresh-checksums'); +if (refreshChecksums !== -1) { + const root = process.argv[refreshChecksums + 1] + ? path.resolve(process.argv[refreshChecksums + 1]) + : packageRoot; + writeChecksums(root); + console.log(`Refreshed OpenWork runtime checksums at ${root}`); + process.exit(0); +} const libDir = path.join(packageRoot, 'lib'); const nodeDir = path.join(packageRoot, 'node'); +const toolsDir = path.join(packageRoot, 'tools'); +const toolsBinDir = path.join(toolsDir, 'bin'); +const toolsScriptsDir = path.join(toolsDir, 'scripts'); +const uvVersion = '0.10.6'; const qwenCodeVersion = JSON.parse( fs.readFileSync(path.join(sourceRoot, 'package.json'), 'utf8'), ).version; @@ -87,6 +100,8 @@ fs.mkdirSync(binDir, { recursive: true }); copyDirectory(distDir, libDir); installRuntimeDependencies(libDir, target); await installNodeRuntime(nodeDir, target); +copyDocumentTools(); +await installUvRuntime(path.join(toolsDir, 'uv'), target); writeLaunchers(target); copyRequiredFile( path.join(sourceRoot, 'LICENSE'), @@ -110,6 +125,7 @@ fs.writeFileSync( qwenCodeCommit: process.env.QWEN_CODE_COMMIT || gitCommit(sourceRoot), target, node: `v${process.versions.node}`, + uv: uvVersion, builtAt: new Date().toISOString(), }, null, @@ -145,7 +161,7 @@ async function installNodeRuntime(destination, desktopTarget) { archiveName, fs.readFileSync(checksumsPath, 'utf8'), ); - extractNodeArchive(archivePath, temporaryRoot); + extractArchive(archivePath, temporaryRoot); const extractedRoot = path.join( temporaryRoot, archiveName.replace(/\.(tar\.gz|tar\.xz|zip)$/, ''), @@ -159,6 +175,77 @@ async function installNodeRuntime(destination, desktopTarget) { } } +function copyDocumentTools() { + const resources = path.join( + sourceRoot, + 'packages', + 'desktop', + 'apps', + 'electron', + 'resources', + ); + copyDirectory(path.join(resources, 'scripts'), toolsScriptsDir); + copyRequiredFile( + path.join(packageDir, 'migration', 'openwork-migrate.mjs'), + path.join(toolsDir, 'openwork-migrate.mjs'), + ); + fs.mkdirSync(toolsBinDir, { recursive: true }); + for (const name of [ + 'doc-diff', + 'docx-tool', + 'ical-tool', + 'img-tool', + 'markitdown', + 'pdf-tool', + 'pptx-tool', + 'xlsx-tool', + ]) { + for (const suffix of ['', '.cmd']) { + const destination = path.join(toolsBinDir, `${name}${suffix}`); + copyRequiredFile( + path.join(resources, 'bin', `${name}${suffix}`), + destination, + ); + if (!suffix && target !== 'win32-x64') fs.chmodSync(destination, 0o755); + } + } +} + +async function installUvRuntime(destination, desktopTarget) { + const archiveName = uvArchiveName(desktopTarget); + const downloadRoot = + process.env.OPENWORK_UV_DOWNLOAD_ROOT?.trim() || + `https://github.com/astral-sh/uv/releases/download/${uvVersion}`; + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'openwork-desktop-uv-'), + ); + try { + const archivePath = path.join(temporaryRoot, archiveName); + const checksumsPath = path.join(temporaryRoot, `${archiveName}.sha256`); + const extractDir = path.join(temporaryRoot, 'extract'); + await download(`${downloadRoot}/${archiveName}`, archivePath); + await download(`${downloadRoot}/${archiveName}.sha256`, checksumsPath); + verifyChecksum( + archivePath, + archiveName, + fs.readFileSync(checksumsPath, 'utf8'), + ); + fs.mkdirSync(extractDir); + extractArchive(archivePath, extractDir); + const binaryName = desktopTarget === 'win32-x64' ? 'uv.exe' : 'uv'; + const binary = findFile(extractDir, binaryName); + if (!binary) + throw new Error(`Extracted uv runtime is missing ${binaryName}`); + fs.mkdirSync(destination, { recursive: true }); + fs.copyFileSync(binary, path.join(destination, binaryName)); + if (desktopTarget !== 'win32-x64') { + fs.chmodSync(path.join(destination, binaryName), 0o755); + } + } finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + function desktopTarget() { const target = process.env.OPENWORK_DESKTOP_TARGET || @@ -195,8 +282,18 @@ function nodeArchiveName(version, desktopTarget) { return `node-v${version}-${nodeTarget}.${extension}`; } +function uvArchiveName(desktopTarget) { + return { + 'darwin-arm64': 'uv-aarch64-apple-darwin.tar.gz', + 'darwin-x64': 'uv-x86_64-apple-darwin.tar.gz', + 'linux-arm64': 'uv-aarch64-unknown-linux-gnu.tar.gz', + 'linux-x64': 'uv-x86_64-unknown-linux-gnu.tar.gz', + 'win32-x64': 'uv-x86_64-pc-windows-msvc.zip', + }[desktopTarget]; +} + async function download(url, destination) { - const response = await fetch(url, { signal: AbortSignal.timeout(120_000) }); + const response = await fetch(url, { signal: AbortSignal.timeout(300_000) }); if (!response.ok || !response.body) { throw new Error(`Failed to download ${url}: HTTP ${response.status}`); } @@ -207,9 +304,9 @@ function verifyChecksum(archivePath, archiveName, checksums) { const expected = checksums .split(/\r?\n/) .map((line) => line.trim().split(/\s+/)) - .find(([, fileName]) => fileName === archiveName)?.[0]; + .find(([, fileName]) => fileName?.replace(/^\*/, '') === archiveName)?.[0]; if (!expected) { - throw new Error(`Node checksums do not list ${archiveName}`); + throw new Error(`Checksums do not list ${archiveName}`); } const actual = crypto .createHash('sha256') @@ -220,10 +317,22 @@ function verifyChecksum(archivePath, archiveName, checksums) { } } -function extractNodeArchive(archivePath, destination) { +function extractArchive(archivePath, destination) { execFileSync('tar', ['-xf', archivePath, '-C', destination]); } +function findFile(directory, name) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + if (entry.isFile() && entry.name === name) return file; + if (entry.isDirectory()) { + const nested = findFile(file, name); + if (nested) return nested; + } + } + return undefined; +} + function installRuntimeDependencies(destination, desktopTarget) { const [platform, arch] = desktopTarget.split('-'); const command = npm ? process.execPath : 'npm'; @@ -272,10 +381,10 @@ function gitCommit(directory) { }).trim(); } -function writeChecksums() { +function writeChecksums(root = packageRoot) { const checksums = {}; - for (const file of runtimeFiles(packageRoot)) { - const relative = path.relative(packageRoot, file).split(path.sep).join('/'); + for (const file of runtimeFiles(root)) { + const relative = path.relative(root, file).split(path.sep).join('/'); if (relative === 'checksums.json') continue; checksums[relative] = crypto .createHash('sha256') @@ -283,7 +392,7 @@ function writeChecksums() { .digest('hex'); } fs.writeFileSync( - path.join(packageRoot, 'checksums.json'), + path.join(root, 'checksums.json'), `${JSON.stringify(checksums, null, 2)}\n`, ); } diff --git a/packages/desktop-shell/scripts/smoke-runtime.js b/packages/desktop-shell/scripts/smoke-runtime.js index 3a198dff3..b60cba46b 100755 --- a/packages/desktop-shell/scripts/smoke-runtime.js +++ b/packages/desktop-shell/scripts/smoke-runtime.js @@ -129,6 +129,9 @@ function finish(error) { } function verifyRuntimeIntegrity() { + const uvRelative = + process.platform === 'win32' ? 'tools/uv/uv.exe' : 'tools/uv/uv'; + const launcherSuffix = process.platform === 'win32' ? '.cmd' : ''; const required = [ 'manifest.json', 'checksums.json', @@ -137,6 +140,19 @@ function verifyRuntimeIntegrity() { 'node/LICENSE', 'lib/cli-entry.js', 'lib/web-shell/index.html', + uvRelative, + 'tools/openwork-migrate.mjs', + 'tools/scripts/img_tool.py', + ...[ + 'doc-diff', + 'docx-tool', + 'ical-tool', + 'img-tool', + 'markitdown', + 'pdf-tool', + 'pptx-tool', + 'xlsx-tool', + ].map((name) => `tools/bin/${name}${launcherSuffix}`), ]; for (const relative of required) { const file = path.join(runtimeRoot, relative); @@ -153,6 +169,7 @@ function verifyRuntimeIntegrity() { 'qwenCodeCommit', 'target', 'node', + 'uv', 'builtAt', ]) { if (!manifest[field]) { @@ -175,6 +192,22 @@ function verifyRuntimeIntegrity() { throw new Error(`Bundled runtime checksum mismatch: ${relative}`); } } + const uv = path.join(runtimeRoot, uvRelative); + const version = execFileSync(uv, ['--version'], { encoding: 'utf8' }); + if (!version.includes(manifest.uv)) { + throw new Error(`Bundled uv version mismatch: ${version.trim()}`); + } + execFileSync( + uv, + [ + 'run', + '--python', + '3.12', + path.join(runtimeRoot, 'tools', 'scripts', 'img_tool.py'), + '--help', + ], + { stdio: 'pipe', timeout: 180_000 }, + ); for (const packageName of [ '@lydell/node-pty', '@qwen-code/audio-capture', diff --git a/packages/desktop-shell/scripts/test-migration.js b/packages/desktop-shell/scripts/test-migration.js new file mode 100644 index 000000000..6199c18e6 --- /dev/null +++ b/packages/desktop-shell/scripts/test-migration.js @@ -0,0 +1,251 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const packageDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', +); +const migration = path.join(packageDir, 'migration', 'openwork-migrate.mjs'); +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openwork-migration-test-')); +const legacy = path.join(root, 'legacy'); +const workspace = path.join(root, 'workspace'); +const sourceCwd = path.join(root, 'source-project'); +const targetCwd = path.join(root, 'target-project'); +const qwen = path.join(root, 'qwen'); +const sessionId = '5ebd99ba-6453-43f5-b2c4-337ea7128fb8'; + +try { + for (const directory of [legacy, workspace, sourceCwd, targetCwd, qwen]) { + fs.mkdirSync(directory, { recursive: true }); + } + fs.writeFileSync( + path.join(legacy, 'config.json'), + JSON.stringify({ + activeWorkspaceId: 'legacy', + workspaces: [{ id: 'legacy', rootPath: workspace }], + }), + ); + fs.writeFileSync( + path.join(workspace, 'config.json'), + JSON.stringify({ defaults: { workingDirectory: targetCwd } }), + ); + fs.mkdirSync(path.join(workspace, 'labels')); + fs.writeFileSync( + path.join(workspace, 'labels', 'config.json'), + '{"labels":[]}', + ); + fs.mkdirSync(path.join(workspace, 'sources')); + fs.writeFileSync( + path.join(workspace, 'sources', 'config.json'), + '{"sources":[]}', + ); + fs.writeFileSync( + path.join(workspace, 'sources', '.credential-cache.json'), + '{"token":"do-not-copy"}', + ); + const legacySessionDir = path.join(workspace, 'sessions', sessionId); + fs.mkdirSync(legacySessionDir, { recursive: true }); + fs.writeFileSync( + path.join(legacySessionDir, 'session.jsonl'), + `${JSON.stringify({ id: sessionId, sdkSessionId: sessionId, sdkCwd: sourceCwd, name: 'Migrated task' })}\n`, + ); + const source = sessionPath(sourceCwd); + fs.mkdirSync(path.dirname(source), { recursive: true }); + fs.writeFileSync( + source, + [ + { + uuid: 'first', + parentUuid: null, + sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + type: 'user', + cwd: sourceCwd, + version: '0.21.10', + message: { role: 'user', parts: [{ text: 'hello' }] }, + }, + { + uuid: 'second', + parentUuid: 'first', + sessionId, + timestamp: '2026-01-01T00:00:01.000Z', + type: 'assistant', + cwd: sourceCwd, + version: '0.21.10', + message: { role: 'assistant', parts: [{ text: 'hi' }] }, + }, + ] + .map(JSON.stringify) + .join('\n') + '\n', + ); + const malformedSessionId = 'malformed-session'; + const malformedLegacyDir = path.join( + workspace, + 'sessions', + malformedSessionId, + ); + fs.mkdirSync(malformedLegacyDir, { recursive: true }); + fs.writeFileSync( + path.join(malformedLegacyDir, 'session.jsonl'), + `${JSON.stringify({ sdkSessionId: malformedSessionId, sdkCwd: sourceCwd })}\n`, + ); + const malformedSource = sessionPath(sourceCwd, malformedSessionId); + fs.mkdirSync(path.dirname(malformedSource), { recursive: true }); + fs.writeFileSync(malformedSource, 'null\n'); + const oauth = path.join(qwen, 'oauth_creds.json'); + fs.writeFileSync(oauth, 'do-not-touch'); + const oauthHash = hash(oauth); + const sourceHash = hash(source); + const destination = sessionPath(targetCwd); + + if (process.platform !== 'win32' && process.getuid?.() !== 0) { + const sessions = path.join(workspace, 'sessions'); + fs.chmodSync(sessions, 0o000); + try { + assert.throws(() => run()); + } finally { + fs.chmodSync(sessions, 0o700); + } + assert.equal( + fs.existsSync(path.join(qwen, 'openwork-migration-v1.json')), + false, + ); + const unreadable = path.join(workspace, 'sources', 'unreadable.json'); + fs.writeFileSync(unreadable, '{}', { mode: 0o000 }); + try { + assert.throws(() => run()); + } finally { + fs.chmodSync(unreadable, 0o600); + fs.rmSync(unreadable); + } + const journal = JSON.parse( + fs.readFileSync(path.join(qwen, 'openwork-migration-v1.json'), 'utf8'), + ); + assert.equal(journal.migratedAt, undefined); + assert.ok( + journal.createdFiles.some( + (entry) => entry.path === path.relative(qwen, destination), + ), + ); + run('--rollback'); + assert.equal(fs.existsSync(destination), false); + run(); + assert.equal(fs.existsSync(destination), false); + fs.rmSync(path.join(qwen, 'openwork-migration-v1.json')); + } + run(); + const records = fs + .readFileSync(destination, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + assert.ok(records.every((record) => record.cwd === targetCwd)); + assert.equal(records.at(-1).systemPayload.customTitle, 'Migrated task'); + assert.equal(hash(source), sourceHash); + assert.equal(hash(oauth), oauthHash); + const archivedLabel = path.join( + qwen, + 'openwork-legacy-v1', + 'legacy', + 'labels', + 'config.json', + ); + assert.ok(fs.existsSync(archivedLabel)); + assert.equal( + fs.existsSync( + path.join( + qwen, + 'openwork-legacy-v1', + 'legacy', + 'sources', + '.credential-cache.json', + ), + ), + false, + ); + assert.equal( + fs.existsSync(sessionPath(targetCwd, malformedSessionId)), + false, + ); + + const destinationHash = hash(destination); + run(); + assert.equal(hash(destination), destinationHash); + + if (process.platform !== 'win32') { + const archivedSource = path.join( + qwen, + 'openwork-legacy-v1', + 'legacy', + 'sources', + 'config.json', + ); + const outside = path.join(root, 'outside'); + fs.mkdirSync(outside); + fs.copyFileSync(archivedSource, path.join(outside, 'config.json')); + fs.rmSync(path.dirname(archivedSource), { recursive: true }); + fs.symlinkSync(outside, path.dirname(archivedSource), 'dir'); + } + fs.writeFileSync(archivedLabel, 'user changed this'); + run('--rollback'); + assert.equal(fs.existsSync(destination), false); + assert.equal(fs.readFileSync(archivedLabel, 'utf8'), 'user changed this'); + if (process.platform !== 'win32') { + assert.equal( + fs.existsSync(path.join(root, 'outside', 'config.json')), + true, + ); + } + assert.equal(hash(oauth), oauthHash); + assert.ok( + JSON.parse( + fs.readFileSync(path.join(qwen, 'openwork-migration-v1.json'), 'utf8'), + ).rolledBackAt, + ); + const report = path.join(qwen, 'openwork-migration-v1.json'); + fs.writeFileSync(report, '{broken'); + assert.throws(() => run()); + fs.rmSync(report); + fs.writeFileSync(path.join(legacy, 'config.json'), '{broken'); + assert.throws(() => run()); + assert.equal(fs.existsSync(report), false); + console.log('OpenWork migration and rollback checks passed.'); +} finally { + fs.rmSync(root, { recursive: true, force: true }); +} + +function run(...args) { + execFileSync(process.execPath, [migration, ...args], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + OPENWORK_LEGACY_CONFIG_DIR: legacy, + QWEN_HOME: qwen, + }, + }); +} + +function sessionPath(cwd, id = sessionId) { + const project = process.platform === 'win32' ? cwd.toLowerCase() : cwd; + return path.join( + qwen, + 'projects', + project.replace(/[^a-zA-Z0-9]/g, '-'), + 'chats', + `${id}.jsonl`, + ); +} + +function hash(file) { + return crypto + .createHash('sha256') + .update(fs.readFileSync(file)) + .digest('hex'); +} diff --git a/packages/desktop-shell/scripts/test-release.js b/packages/desktop-shell/scripts/test-release.js index a95fc306f..f7dd34a79 100755 --- a/packages/desktop-shell/scripts/test-release.js +++ b/packages/desktop-shell/scripts/test-release.js @@ -1,10 +1,12 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import vm from 'node:vm'; import { fileURLToPath } from 'node:url'; const packageDir = path.resolve( @@ -12,19 +14,96 @@ const packageDir = path.resolve( '..', ); const versionScript = path.join(packageDir, 'scripts', 'version.js'); +const electronBridgeScript = path.join( + packageDir, + '..', + '..', + '.github', + 'scripts', + 'create-electron-bridge-manifest.mjs', +); const root = fs.mkdtempSync( path.join(os.tmpdir(), 'openwork-desktop-release-test-'), ); try { testDesktopConfiguration(); + await testBootstrapStartup(); testMacosPermissions(); + testReleaseWorkflow(); + testElectronBridgeManifest(path.join(root, 'electron-bridge')); + testChecksumRefresh(path.join(root, 'checksums')); testVersionSynchronization(path.join(root, 'version')); console.log('OpenWork desktop release contract checks passed.'); } finally { fs.rmSync(root, { recursive: true, force: true }); } +async function testBootstrapStartup() { + const html = fs.readFileSync( + path.join(packageDir, 'bootstrap', 'index.html'), + 'utf8', + ); + assert.match(html, //); + assert.match(html, /body\[data-state='starting'\] \.status/); + assert.match(html, /class="mark" src="\.\/openwork-symbol\.png"/); + + const elements = {}; + const element = (selector) => { + elements[selector] ??= { + addEventListener(event, listener) { + this.listeners ??= {}; + this.listeners[event] = listener; + }, + style: {}, + }; + return elements[selector]; + }; + const listeners = {}; + const body = { dataset: {} }; + let resolveBootstrapState; + vm.runInNewContext( + fs.readFileSync(path.join(packageDir, 'bootstrap', 'bootstrap.js'), 'utf8'), + { + document: { body, querySelector: element }, + window: { + __TAURI__: { + core: { + invoke: async (command) => { + if (command !== 'bootstrap_state') return undefined; + return new Promise((resolve) => { + resolveBootstrapState = resolve; + }); + }, + }, + event: { + listen: async (event, listener) => { + listeners[event] = listener; + }, + }, + }, + }, + }, + { timeout: 5000 }, + ); + await new Promise((resolve) => setImmediate(resolve)); + + listeners['runtime-starting']({ payload: '/tmp/attempted' }); + assert.equal(body.dataset.state, 'starting'); + assert.equal(element('#workspace').hidden, true); + listeners['runtime-failed']({ payload: 'failed' }); + resolveBootstrapState({ + desktopVersion: '0.2.0', + status: 'idle', + workspace: '/tmp/persisted', + error: 'failed', + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(body.dataset.state, 'error'); + assert.equal(element('#workspace').hidden, false); + assert.equal(element('#workspace').textContent, '/tmp/attempted'); +} + function testDesktopConfiguration() { const config = JSON.parse( fs.readFileSync( @@ -34,17 +113,69 @@ function testDesktopConfiguration() { ); assert.equal(config.productName, 'OpenWork'); assert.equal(config.identifier, 'com.alibaba.openwork'); - assert.equal(config.version, '0.1.0'); + assert.equal(config.version, '0.2.0'); assert.equal(config.build.devUrl, 'http://127.0.0.1:1420'); assert.equal(config.build.frontendDist, '../bootstrap'); assert.equal(config.app?.withGlobalTauri, true); - assert.deepEqual(config.app?.security?.capabilities, ['bootstrap']); + assert.equal(config.app?.macOSPrivateApi, true); + assert.deepEqual(config.app?.security?.capabilities, [ + 'bootstrap', + 'runtime', + 'pet', + ]); + const capabilities = Object.fromEntries( + ['bootstrap', 'runtime', 'pet'].map((name) => [ + name, + JSON.parse( + fs.readFileSync( + path.join(packageDir, 'src-tauri', 'capabilities', `${name}.json`), + 'utf8', + ), + ), + ]), + ); + assert.deepEqual(capabilities.bootstrap.webviews, ['main', 'local-control']); + assert.ok( + capabilities.bootstrap.permissions.includes('core:event:allow-listen'), + ); + assert.ok( + capabilities.bootstrap.permissions.includes('core:event:allow-unlisten'), + ); + assert.deepEqual(capabilities.runtime.webviews, ['main']); + assert.equal(capabilities.runtime.local, false); + assert.deepEqual(capabilities.runtime.remote, { + urls: ['http://127.0.0.1:*'], + }); + assert.deepEqual(capabilities.pet.webviews, ['pet']); + assert.deepEqual(config.app?.security?.assetProtocol, { + enable: true, + scope: ['$HOME/.qwen/pets/**'], + }); assert.equal(config.bundle?.createUpdaterArtifacts, false); + assert.equal( + config.bundle?.windows?.nsis?.installerHooks, + 'windows/electron-migration.nsh', + ); + const migrationHook = fs.readFileSync( + path.join(packageDir, 'src-tauri', 'windows', 'electron-migration.nsh'), + 'utf8', + ); + assert.match(migrationHook, /Software\\d6bd5575-5bf2-5dad-acfe-35e3bbeefd68/); + assert.match( + migrationHook, + /ExecWait '"\$R0\\Uninstall OpenWork\.exe" \/currentuser \/S --updated _\?=\$R0'/, + ); assert.equal( config.bundle?.resources?.['../runtime/openwork'], 'runtime/openwork', ); - assert.equal(config.plugins?.updater, undefined); + assert.deepEqual(config.plugins?.['deep-link']?.desktop?.schemes, [ + 'openwork', + ]); + assert.deepEqual(config.plugins?.updater?.endpoints, [ + 'https://github.com/modelstudioai/openwork/releases/download/desktop-latest/latest.json', + ]); + assert.equal(typeof config.plugins?.updater?.pubkey, 'string'); assert.equal( fs.existsSync( path.join(packageDir, 'src-tauri', 'tauri.openwork.conf.json'), @@ -53,6 +184,164 @@ function testDesktopConfiguration() { ); } +function testReleaseWorkflow() { + const releaseWorkflow = fs.readFileSync( + path.join( + packageDir, + '..', + '..', + '.github', + 'workflows', + 'desktop-release.yml', + ), + 'utf8', + ); + const buildWorkflow = fs.readFileSync( + path.join( + packageDir, + '..', + '..', + '.github', + 'workflows', + 'desktop-build.yml', + ), + 'utf8', + ); + const workflow = `${releaseWorkflow}\n${buildWorkflow}`; + for (const expected of [ + 'aarch64-apple-darwin', + 'x86_64-apple-darwin', + 'x86_64-pc-windows-msvc', + 'x86_64-unknown-linux-gnu', + 'tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f', + 'TAURI_SIGNING_PRIVATE_KEY', + 'APPLE_CERTIFICATE', + 'Import-PfxCertificate', + 'createUpdaterArtifacts: publish', + 'Run desktop tests', + 'Verify bundled runtime', + 'Smoke packaged application', + 'create-desktop-update-manifest.mjs', + 'create-electron-bridge-manifest.mjs', + 'macos:latest-mac.yml', + 'windows:latest.yml', + 'linux:latest-linux.yml', + 'electron_bridge', + 'default: true', + 'args+=(--latest)', + 'SHA256SUMS.txt', + 'desktop-latest', + 'Sign bundled runtime binaries (macOS)', + 'Verify Windows signature', + '.app.tar.gz', + ]) { + assert.ok( + workflow.includes(expected), + `Missing release contract: ${expected}`, + ); + } + const buildStart = releaseWorkflow.indexOf(' build:'); + const publishStart = releaseWorkflow.indexOf(' publish:'); + const dryRunJob = releaseWorkflow.slice(buildStart, publishStart); + const publishJob = releaseWorkflow.slice(publishStart); + assert.doesNotMatch(dryRunJob, /secrets/); + assert.match( + dryRunJob, + /if: '?inputs\.dry_run == true'?[\s\S]*contents: '?read'?/, + ); + assert.match( + publishJob, + /if: '?inputs\.dry_run == false'?[\s\S]*contents: '?write'?/, + ); + assert.match(publishJob, /secrets: '?inherit'?/); + assert.doesNotMatch(workflow, /uses: [^\n]+@(v\d|stable)\b/); + assert.doesNotMatch(workflow, /push --force|force-with-lease/); +} + +function testElectronBridgeManifest(directory) { + const assets = path.join(directory, 'assets'); + fs.mkdirSync(assets, { recursive: true }); + const artifacts = [ + 'OpenWork_0.2.0_arm64.zip', + 'OpenWork_0.2.0_x64.zip', + 'OpenWork_0.2.0_arm64.dmg', + 'OpenWork_0.2.0_x64.dmg', + 'OpenWork_0.2.0_x64-setup.exe', + 'OpenWork_0.2.0_amd64.AppImage', + ]; + for (const artifact of artifacts) { + fs.writeFileSync(path.join(assets, artifact), `contents:${artifact}`); + } + for (const [platform, filename, selected] of [ + ['macos', 'latest-mac.yml', artifacts.slice(0, 4)], + ['windows', 'latest.yml', artifacts.slice(4, 5)], + ['linux', 'latest-linux.yml', artifacts.slice(5, 6)], + ]) { + const output = path.join(directory, filename); + execFileSync(process.execPath, [ + electronBridgeScript, + '--assets', + assets, + '--platform', + platform, + '--version', + '0.2.0', + '--output', + output, + ]); + const manifest = fs.readFileSync(output, 'utf8'); + assert.match(manifest, /^version: 0\.2\.0$/m); + for (const artifact of selected) { + const contents = fs.readFileSync(path.join(assets, artifact)); + const sha512 = crypto + .createHash('sha512') + .update(contents) + .digest('base64'); + assert.ok(manifest.includes(`url: ${artifact}`)); + assert.ok(manifest.includes(`sha512: ${sha512}`)); + assert.ok(manifest.includes(`size: ${contents.length}`)); + } + } + + fs.rmSync(path.join(assets, artifacts[1])); + const failure = spawnSync( + process.execPath, + [ + electronBridgeScript, + '--assets', + assets, + '--platform', + 'macos', + '--version', + '0.2.0', + '--output', + path.join(directory, 'latest-mac.yml'), + ], + { encoding: 'utf8' }, + ); + assert.notEqual(failure.status, 0); + assert.match(failure.stderr, /Expected one Electron bridge artifact/); +} + +function testChecksumRefresh(directory) { + fs.mkdirSync(path.join(directory, 'nested'), { recursive: true }); + fs.writeFileSync(path.join(directory, 'one.txt'), 'one'); + fs.writeFileSync(path.join(directory, 'nested', 'two.txt'), 'two'); + execFileSync( + process.execPath, + [ + path.join(packageDir, 'scripts', 'prepare-runtime.js'), + '--refresh-checksums', + directory, + ], + { stdio: 'pipe' }, + ); + const checksums = JSON.parse( + fs.readFileSync(path.join(directory, 'checksums.json'), 'utf8'), + ); + assert.deepEqual(Object.keys(checksums), ['nested/two.txt', 'one.txt']); +} + function testMacosPermissions() { const entitlements = fs.readFileSync( path.join(packageDir, 'src-tauri', 'Entitlements.plist'), diff --git a/packages/desktop-shell/src-tauri/Cargo.lock b/packages/desktop-shell/src-tauri/Cargo.lock index 582c11169..0bdcce697 100644 --- a/packages/desktop-shell/src-tauri/Cargo.lock +++ b/packages/desktop-shell/src-tauri/Cargo.lock @@ -47,6 +47,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -494,6 +503,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "cookie" version = "0.18.1" @@ -577,6 +606,12 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -680,6 +715,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -778,6 +824,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "dom_query" version = "0.27.0" @@ -963,6 +1018,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1408,6 +1473,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.17.1" @@ -1481,6 +1552,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "httparse" version = "1.10.1" @@ -1507,6 +1584,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1775,6 +1867,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.19", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -1932,6 +2054,20 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + [[package]] name = "markup5ever" version = "0.38.0" @@ -1964,6 +2100,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2059,6 +2201,20 @@ dependencies = [ "libc", ] +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2235,6 +2391,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2309,9 +2477,15 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "openwork-desktop" -version = "0.1.0" +version = "0.2.0" dependencies = [ "base64 0.22.1", "command-group", @@ -2324,9 +2498,12 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-deep-link", "tauri-plugin-dialog", + "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-single-instance", + "tauri-plugin-updater", "ureq", "url", ] @@ -2337,6 +2514,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -2347,6 +2534,20 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.19", +] + [[package]] name = "pango" version = "0.18.3" @@ -2782,15 +2983,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -2826,6 +3032,30 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -2854,6 +3084,79 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -2869,6 +3172,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -2926,6 +3238,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.36.1" @@ -3146,6 +3481,22 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -3258,6 +3609,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3352,7 +3709,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -3385,6 +3742,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -3408,7 +3776,8 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "http-range", + "jni 0.21.1", "libc", "log", "mime", @@ -3520,6 +3889,27 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.19", + "tracing", + "url", + "windows-registry", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-dialog" version = "2.7.2" @@ -3562,6 +3952,25 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.19", + "time", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.4" @@ -3593,6 +4002,7 @@ dependencies = [ "serde", "serde_json", "tauri", + "tauri-plugin-deep-link", "thiserror 2.0.19", "tokio", "tracing", @@ -3600,6 +4010,39 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-updater" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.19", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -3610,7 +4053,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -3633,7 +4076,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -3700,6 +4143,17 @@ dependencies = [ "toml 1.1.4+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror 2.0.19", + "windows", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -3792,6 +4246,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -3831,6 +4294,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -4144,6 +4617,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "ureq" version = "3.3.0" @@ -4418,6 +4897,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4603,6 +5091,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -4648,6 +5147,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -4943,7 +5451,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -4990,6 +5498,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" @@ -5115,6 +5633,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4" @@ -5148,6 +5672,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.0", + "memchr", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/packages/desktop-shell/src-tauri/Cargo.toml b/packages/desktop-shell/src-tauri/Cargo.toml index 3edefe07f..c3f9b1b4a 100644 --- a/packages/desktop-shell/src-tauri/Cargo.toml +++ b/packages/desktop-shell/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openwork-desktop" -version = "0.1.0" +version = "0.2.0" description = "OpenWork desktop shell — Tauri" authors = ["Model Studio AI"] license = "Apache-2.0" @@ -20,10 +20,13 @@ qrcode = { version = "0.14.1", default-features = false, features = ["svg"] } rand = "0.9.2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.151" -tauri = { version = "2.8.5", features = [] } +tauri = { version = "2.8.5", features = ["macos-private-api", "protocol-asset", "unstable"] } +tauri-plugin-deep-link = "2" tauri-plugin-dialog = "2.7.2" +tauri-plugin-notification = "2" tauri-plugin-opener = "2.5.4" -tauri-plugin-single-instance = "2.4.0" +tauri-plugin-single-instance = { version = "2.4.0", features = ["deep-link"] } +tauri-plugin-updater = "2" ureq = { version = "3.1.2", default-features = false } url = "2.5.4" diff --git a/packages/desktop-shell/src-tauri/build.rs b/packages/desktop-shell/src-tauri/build.rs index adf32c9b4..052921084 100644 --- a/packages/desktop-shell/src-tauri/build.rs +++ b/packages/desktop-shell/src-tauri/build.rs @@ -1,6 +1,32 @@ fn main() { let windows = tauri_build::WindowsAttributes::new() .app_manifest(include_str!("windows-app-manifest.xml")); - let attributes = tauri_build::Attributes::new().windows_attributes(windows); + let manifest = tauri_build::AppManifest::new().commands(&[ + "bootstrap_state", + "choose_workspace", + "local_control_status", + "enable_local_control", + "disable_local_control", + "open_logs", + "restart_runtime", + "set_interface_zoom", + "read_openwork_client_state", + "write_openwork_client_state", + "browser_open", + "browser_set_bounds", + "browser_navigate", + "browser_close", + "notify_turn_complete", + "proxy_status", + "list_pets", + "resolve_pet_sprite", + "toggle_pet", + "check_for_updates", + "install_update", + "take_pending_deep_links", + ]); + let attributes = tauri_build::Attributes::new() + .windows_attributes(windows) + .app_manifest(manifest); tauri_build::try_build(attributes).expect("failed to run Tauri build script"); } diff --git a/packages/desktop-shell/src-tauri/capabilities/bootstrap.json b/packages/desktop-shell/src-tauri/capabilities/bootstrap.json index 91db2908d..2ee3ef5bf 100644 --- a/packages/desktop-shell/src-tauri/capabilities/bootstrap.json +++ b/packages/desktop-shell/src-tauri/capabilities/bootstrap.json @@ -1,7 +1,17 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "bootstrap", - "description": "Allows the local bootstrap page to subscribe to desktop lifecycle events.", - "windows": ["main", "local-control"], - "permissions": ["core:event:allow-listen", "core:event:allow-unlisten"] + "description": "Allows local bootstrap pages to manage the desktop runtime.", + "webviews": ["main", "local-control"], + "permissions": [ + "core:event:allow-listen", + "core:event:allow-unlisten", + "allow-bootstrap-state", + "allow-choose-workspace", + "allow-local-control-status", + "allow-enable-local-control", + "allow-disable-local-control", + "allow-open-logs", + "allow-restart-runtime" + ] } diff --git a/packages/desktop-shell/src-tauri/capabilities/pet.json b/packages/desktop-shell/src-tauri/capabilities/pet.json new file mode 100644 index 000000000..e79c803b7 --- /dev/null +++ b/packages/desktop-shell/src-tauri/capabilities/pet.json @@ -0,0 +1,10 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "pet", + "description": "Allows the local desktop pet to load its sprite and move its window.", + "webviews": ["pet"], + "permissions": [ + "core:window:allow-start-dragging", + "allow-resolve-pet-sprite" + ] +} diff --git a/packages/desktop-shell/src-tauri/capabilities/runtime.json b/packages/desktop-shell/src-tauri/capabilities/runtime.json new file mode 100644 index 000000000..099a9d031 --- /dev/null +++ b/packages/desktop-shell/src-tauri/capabilities/runtime.json @@ -0,0 +1,28 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "runtime", + "description": "Allows the loopback Web Shell to use desktop integrations.", + "webviews": ["main"], + "local": false, + "remote": { + "urls": ["http://127.0.0.1:*"] + }, + "permissions": [ + "core:event:allow-listen", + "core:event:allow-unlisten", + "allow-set-interface-zoom", + "allow-read-openwork-client-state", + "allow-write-openwork-client-state", + "allow-browser-open", + "allow-browser-set-bounds", + "allow-browser-navigate", + "allow-browser-close", + "allow-notify-turn-complete", + "allow-proxy-status", + "allow-list-pets", + "allow-toggle-pet", + "allow-check-for-updates", + "allow-install-update", + "allow-take-pending-deep-links" + ] +} diff --git a/packages/desktop-shell/src-tauri/src/desktop_state.rs b/packages/desktop-shell/src-tauri/src/desktop_state.rs index dcfac3fbe..5a0571cd3 100644 --- a/packages/desktop-shell/src-tauri/src/desktop_state.rs +++ b/packages/desktop-shell/src-tauri/src/desktop_state.rs @@ -1,4 +1,4 @@ -use serde::{Deserialize, Serialize}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; use std::ffi::OsStr; use std::fs; use std::path::{Path, PathBuf}; @@ -18,6 +18,119 @@ static NEXT_WRITE_ID: AtomicU64 = AtomicU64::new(1); pub struct DesktopSettings { pub workspace: Option, pub window: Option, + pub openwork: OpenWorkClientState, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(default, rename_all = "camelCase")] +pub struct OpenWorkPreferences { + pub preset_theme: String, + pub zoom: u16, + pub text_scale: f64, + pub high_contrast: bool, + pub reduce_motion: bool, + pub keep_awake: bool, +} + +impl Default for OpenWorkPreferences { + fn default() -> Self { + Self { + preset_theme: "default".to_string(), + zoom: 100, + text_scale: 1.0, + high_contrast: false, + reduce_motion: false, + keep_awake: true, + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenWorkRecentSession { + pub id: String, + pub workspace_id: Option, + pub visited_at: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(default, rename_all = "camelCase")] +pub struct OpenWorkClientState { + pub preferences: OpenWorkPreferences, + pub chat_width: String, + pub theme: Option, + pub language: Option, + pub recent_commands: Vec, + pub recent_sessions: Vec, + pub pet_enabled: bool, + pub pet_id: String, +} + +impl Default for OpenWorkClientState { + fn default() -> Self { + Self { + preferences: OpenWorkPreferences::default(), + chat_width: "1100".to_string(), + theme: None, + language: None, + recent_commands: Vec::new(), + recent_sessions: Vec::new(), + pet_enabled: false, + pet_id: "qwen".to_string(), + } + } +} + +impl OpenWorkClientState { + fn validate(&self) -> Result<(), String> { + if !valid_openwork_theme(&self.preferences.preset_theme) + || ![50, 67, 80, 90, 100, 110, 125, 150, 175, 200].contains(&self.preferences.zoom) + || ![0.9, 1.0, 1.15].contains(&self.preferences.text_scale) + || !matches!(self.chat_width.as_str(), "840" | "1100" | "wide") + || !self + .theme + .as_deref() + .map_or(true, |theme| matches!(theme, "dark" | "light")) + || !self.language.as_deref().map_or(true, |language| { + matches!(language, "en" | "de" | "es" | "hu" | "ja" | "pl" | "zh-CN") + }) + { + return Err("Invalid OpenWork appearance preferences.".to_string()); + } + if self.recent_commands.len() > 6 + || self.recent_commands.iter().any(|command| { + command.is_empty() || command.len() > 64 || command.chars().any(char::is_control) + }) + { + return Err("Invalid OpenWork recent commands.".to_string()); + } + if !valid_pet_id(&self.pet_id) { + return Err("Invalid OpenWork desktop pet.".to_string()); + } + if self.recent_sessions.len() > 6 + || self.recent_sessions.iter().any(|session| { + session.id.is_empty() + || session.id.len() > 128 + || !session.id.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-') + }) + || session.workspace_id.as_ref().is_some_and(|workspace_id| { + workspace_id.len() > 256 || workspace_id.chars().any(char::is_control) + }) + }) + { + return Err("Invalid OpenWork recent sessions.".to_string()); + } + Ok(()) + } +} + +pub(crate) fn valid_pet_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -40,7 +153,7 @@ impl SettingsStore { let settings = match fs::read_to_string(&path) { Ok(contents) => parse_settings(&contents), Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - DesktopSettings::default() + legacy_desktop_settings(app)?.unwrap_or_default() } Err(error) => return Err(format!("Failed to read desktop settings: {error}")), }; @@ -62,6 +175,15 @@ impl SettingsStore { self.with_settings(|settings| settings.window.clone()) } + pub fn openwork(&self) -> OpenWorkClientState { + self.with_settings(|settings| settings.openwork.clone()) + } + + pub fn set_openwork(&self, openwork: OpenWorkClientState) -> Result<(), String> { + openwork.validate()?; + self.update(|settings| settings.openwork = openwork) + } + pub fn save_window(&self, window: &WebviewWindow) -> Result<(), String> { let position = window .outer_position() @@ -105,6 +227,168 @@ impl SettingsStore { } } +fn valid_openwork_theme(value: &str) -> bool { + [ + "catppuccin", + "default", + "dracula", + "ghostty", + "github", + "gruvbox", + "haze", + "night-owl", + "nord", + "one-dark-pro", + "pierre", + "rose-pine", + "solarized", + "tokyo-night", + "vitesse", + ] + .contains(&value) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct LegacyConfig { + active_workspace_id: Option, + #[serde(default)] + workspaces: Vec, + color_theme: Option, + keep_awake_while_running: Option, + pet_enabled: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct LegacyWorkspace { + id: String, + root_path: PathBuf, +} + +#[derive(Default, Deserialize)] +struct LegacyWorkspaceConfig { + #[serde(default)] + defaults: LegacyWorkspaceDefaults, +} + +#[derive(Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LegacyWorkspaceDefaults { + working_directory: Option, +} + +fn legacy_desktop_settings(app: &AppHandle) -> Result, String> { + let home = app + .path() + .home_dir() + .map_err(|error| format!("Failed to locate the home directory: {error}"))?; + let legacy_root = std::env::var_os("OPENWORK_LEGACY_CONFIG_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".craft-agent")); + legacy_desktop_settings_from(&legacy_root, &home) +} + +fn read_legacy_json(path: &Path) -> Result, String> { + let contents = match fs::read_to_string(path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "Failed to read legacy desktop settings at {}: {error}", + path.display() + )) + } + }; + serde_json::from_str(&contents).map(Some).map_err(|error| { + format!( + "Failed to parse legacy desktop settings at {}: {error}", + path.display() + ) + }) +} + +fn legacy_desktop_settings_from( + legacy_root: &Path, + home: &Path, +) -> Result, String> { + let Some(config) = read_legacy_json::(&legacy_root.join("config.json"))? else { + return Ok(None); + }; + let selected_workspace = config + .active_workspace_id + .as_deref() + .and_then(|id| { + config + .workspaces + .iter() + .find(|workspace| workspace.id == id) + }) + .or_else(|| config.workspaces.first()); + let workspace = match selected_workspace { + Some(workspace) => { + let root = expand_legacy_path(&workspace.root_path, home, legacy_root); + let workspace_config = + read_legacy_json::(&root.join("config.json"))? + .unwrap_or_default(); + let working_directory = workspace_config + .defaults + .working_directory + .map(|path| expand_legacy_path(&path, home, &root)) + .unwrap_or_else(|| root.clone()); + match fs::metadata(&working_directory) { + Ok(metadata) if metadata.is_dir() => Some(working_directory), + Ok(_) => None, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => { + return Err(format!( + "Failed to inspect legacy workspace at {}: {error}", + working_directory.display() + )) + } + } + } + None => None, + }; + let mut settings = DesktopSettings { + workspace, + ..DesktopSettings::default() + }; + if let Some(theme) = config + .color_theme + .filter(|theme| valid_openwork_theme(theme)) + { + settings.openwork.preferences.preset_theme = theme; + } + if let Some(keep_awake) = config.keep_awake_while_running { + settings.openwork.preferences.keep_awake = keep_awake; + } + if let Some(pet_enabled) = config.pet_enabled { + settings.openwork.pet_enabled = pet_enabled; + } + Ok(Some(settings)) +} + +fn expand_legacy_path(value: &Path, home: &Path, base: &Path) -> PathBuf { + let value = value.to_string_lossy(); + if value == "~" { + return home.to_path_buf(); + } + if let Some(relative) = value + .strip_prefix("~/") + .or_else(|| value.strip_prefix("~\\")) + { + return home.join(relative); + } + let expanded = value.replace("${HOME}", &home.to_string_lossy()); + let path = PathBuf::from(expanded); + if path.is_absolute() { + path + } else { + base.join(path) + } +} + fn settings_persistence_disabled() -> bool { settings_persistence_disabled_value( std::env::var_os(DISABLE_SETTINGS_PERSISTENCE_ENV).as_deref(), @@ -220,8 +504,9 @@ fn write_atomic(path: &Path, contents: &[u8]) -> Result<(), String> { #[cfg(test)] mod tests { use super::{ - parse_settings, saved_window_state, settings_persistence_disabled_value, write_atomic, - DesktopSettings, WindowState, + legacy_desktop_settings_from, parse_settings, saved_window_state, + settings_persistence_disabled_value, write_atomic, DesktopSettings, OpenWorkClientState, + WindowState, }; use std::ffi::OsStr; use std::fs; @@ -232,6 +517,106 @@ mod tests { let settings: DesktopSettings = serde_json::from_str("{}").expect("settings"); assert!(settings.workspace.is_none()); assert!(settings.window.is_none()); + assert_eq!(settings.openwork.preferences.zoom, 100); + assert!(settings.openwork.preferences.keep_awake); + } + + #[test] + fn validates_persisted_openwork_client_state() { + let mut state = OpenWorkClientState::default(); + state.preferences.zoom = 125; + assert!(state.validate().is_ok()); + state.recent_commands = (0..7).map(|index| format!("command-{index}")).collect(); + assert!(state.validate().is_err()); + } + + #[test] + fn imports_legacy_workspace_and_preferences_without_moving_credentials() { + let home = + std::env::temp_dir().join(format!("openwork-legacy-settings-{}", std::process::id())); + let legacy = home.join(".craft-agent"); + let workspace = home.join("Documents").join("OpenWork Legacy"); + let project = home.join("project"); + fs::create_dir_all(&workspace).expect("create workspace"); + fs::create_dir_all(&project).expect("create project"); + fs::create_dir_all(&legacy).expect("create legacy config"); + fs::write( + legacy.join("config.json"), + serde_json::to_vec(&serde_json::json!({ + "activeWorkspaceId": "legacy", + "workspaces": [{"id": "legacy", "rootPath": workspace}], + "colorTheme": "nord", + "keepAwakeWhileRunning": false, + "petEnabled": true + })) + .expect("serialize legacy config"), + ) + .expect("write legacy config"); + fs::write( + workspace.join("config.json"), + r#"{"defaults":{"workingDirectory":"${HOME}/project"}}"#, + ) + .expect("write workspace config"); + + let settings = legacy_desktop_settings_from(&legacy, &home) + .expect("read legacy settings") + .expect("legacy settings"); + assert_eq!(settings.workspace.as_deref(), Some(project.as_path())); + assert_eq!(settings.openwork.preferences.preset_theme, "nord"); + assert!(!settings.openwork.preferences.keep_awake); + assert!(settings.openwork.pet_enabled); + assert!(!home.join(".qwen").exists()); + fs::remove_dir_all(home).expect("cleanup legacy fixture"); + } + + #[test] + fn rejects_malformed_legacy_settings() { + let home = std::env::temp_dir().join(format!( + "openwork-malformed-legacy-settings-{}", + std::process::id() + )); + let legacy = home.join(".craft-agent"); + fs::create_dir_all(&legacy).expect("create legacy config"); + fs::write(legacy.join("config.json"), "{").expect("write malformed config"); + + assert!(legacy_desktop_settings_from(&legacy, &home).is_err()); + fs::remove_dir_all(home).expect("cleanup legacy fixture"); + } + + #[cfg(unix)] + #[test] + fn reports_legacy_workspace_metadata_errors() { + use std::os::unix::fs::symlink; + + let home = std::env::temp_dir().join(format!( + "openwork-invalid-legacy-workspace-{}", + std::process::id() + )); + let legacy = home.join(".craft-agent"); + let workspace = home.join("workspace"); + let loop_path = workspace.join("loop"); + fs::create_dir_all(&legacy).expect("create legacy config"); + fs::create_dir_all(&workspace).expect("create workspace"); + symlink("loop", &loop_path).expect("create symlink loop"); + fs::write( + legacy.join("config.json"), + serde_json::to_vec(&serde_json::json!({ + "workspaces": [{"id": "legacy", "rootPath": workspace}] + })) + .expect("serialize legacy config"), + ) + .expect("write legacy config"); + fs::write( + workspace.join("config.json"), + serde_json::to_vec(&serde_json::json!({ + "defaults": {"workingDirectory": loop_path} + })) + .expect("serialize workspace config"), + ) + .expect("write workspace config"); + + assert!(legacy_desktop_settings_from(&legacy, &home).is_err()); + fs::remove_dir_all(home).expect("cleanup legacy fixture"); } #[test] diff --git a/packages/desktop-shell/src-tauri/src/main.rs b/packages/desktop-shell/src-tauri/src/main.rs index 4c8c0a5a9..1e62bb3a3 100755 --- a/packages/desktop-shell/src-tauri/src/main.rs +++ b/packages/desktop-shell/src-tauri/src/main.rs @@ -5,7 +5,9 @@ mod local_control; mod runtime; use command_group::GroupChild; -use desktop_state::{default_window_size, restore_window, SettingsStore}; +use desktop_state::{ + default_window_size, restore_window, valid_pet_id, OpenWorkClientState, SettingsStore, +}; use local_control::{LocalControlInfo, LocalControlSession}; use runtime::{resolve_workspace, stop_runtime_handle, DesktopRuntime}; use serde::{Deserialize, Serialize}; @@ -13,13 +15,17 @@ use std::ffi::OsString; use std::fs; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; -use tauri::menu::{Menu, MenuItem, MenuItemBuilder, SubmenuBuilder}; -use tauri::webview::{DownloadEvent, NewWindowResponse, WebviewWindowBuilder}; +use std::sync::{Arc, Mutex, OnceLock}; +use tauri::menu::{AboutMetadata, Menu, MenuItem, MenuItemBuilder, SubmenuBuilder}; +use tauri::webview::{DownloadEvent, NewWindowResponse, WebviewBuilder, WebviewWindowBuilder}; use tauri::{ - AppHandle, Emitter, Listener, Manager, RunEvent, State, WebviewUrl, WebviewWindow, WindowEvent, + AppHandle, Emitter, Listener, LogicalPosition, LogicalSize, Manager, RunEvent, State, + WebviewUrl, WebviewWindow, WindowEvent, }; +use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_dialog::DialogExt; +use tauri_plugin_notification::NotificationExt; +use tauri_plugin_updater::UpdaterExt; use url::Url; #[cfg(debug_assertions)] @@ -37,6 +43,7 @@ static FULLSCREEN_HIDE_GENERATION: AtomicU64 = AtomicU64::new(0); // packages/desktop/packages/shared/src/config/storage.ts: ~/Documents/OpenWork, // relocatable through OPENWORK_DEFAULT_WORKSPACE_DIR (see default_workspace). const DEFAULT_WORKSPACE_DIRECTORY: &str = "OpenWork"; +static PENDING_DEEP_LINKS: OnceLock>> = OnceLock::new(); #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -54,6 +61,23 @@ struct RuntimeStopped { status: String, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PetManifest { + id: String, + display_name: String, + description: String, + spritesheet_path: PathBuf, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct PetInfo { + id: String, + display_name: String, + description: String, +} + // A runtime that has spawned but may still be inside DesktopRuntime::start's // startup wait. Shares the child handle with the DesktopRuntime it becomes, // so a stop during that window kills the in-flight daemon instead of @@ -89,11 +113,21 @@ struct ApplicationState { fn main() { let builder = tauri::Builder::default() - .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { focus_main_window(app); + emit_deep_links(app, args.iter().map(String::as_str)); })) + .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_opener::init()) + .plugin({ + let builder = tauri_plugin_updater::Builder::new(); + match option_env!("OPENWORK_UPDATER_PUBLIC_KEY") { + Some(public_key) => builder.pubkey(public_key).build(), + None => builder.build(), + } + }) .on_menu_event(|app, event| { if event.id() == "local-control" { if let Err(error) = show_local_control_window(app) { @@ -101,6 +135,22 @@ fn main() { } } else if event.id() == "local-control-off" { stop_local_control(app); + } else if event.id() == "repository" { + let _ = open::that_detached("https://github.com/modelstudioai/openwork"); + } else if matches!( + event.id().as_ref(), + "new" + | "settings" + | "worktree" + | "shortcuts" + | "browser" + | "pet" + | "update" + | "zoom-in" + | "zoom-out" + | "zoom-reset" + ) { + let _ = app.emit_to("main", "openwork-menu", event.id().as_ref()); } }) .invoke_handler(tauri::generate_handler![ @@ -111,6 +161,21 @@ fn main() { disable_local_control, open_logs, restart_runtime, + set_interface_zoom, + read_openwork_client_state, + write_openwork_client_state, + browser_open, + browser_set_bounds, + browser_navigate, + browser_close, + notify_turn_complete, + proxy_status, + list_pets, + resolve_pet_sprite, + toggle_pet, + check_for_updates, + install_update, + take_pending_deep_links, ]) .setup(setup_app); @@ -198,19 +263,116 @@ fn main() { fn setup_app(app: &mut tauri::App) -> Result<(), Box> { let handle = app.handle().clone(); - let menu = Menu::default(&handle)?; + let menu = Menu::new(&handle)?; let local_control_menu = MenuItemBuilder::with_id("local-control", "Local Control: Off…").build(&handle)?; let local_control_off_menu = MenuItemBuilder::with_id("local-control-off", "Turn Off Local Control") .enabled(false) .build(&handle)?; + let new_task = MenuItemBuilder::with_id("new", "New Task") + .accelerator("CmdOrCtrl+N") + .build(&handle)?; + let settings = MenuItemBuilder::with_id("settings", "Settings…") + .accelerator("CmdOrCtrl+,") + .build(&handle)?; + let worktree = MenuItemBuilder::with_id("worktree", "New Worktree Project…").build(&handle)?; + let shortcuts = MenuItemBuilder::with_id("shortcuts", "Keyboard Shortcuts").build(&handle)?; + let browser = MenuItemBuilder::with_id("browser", "Browser Dock").build(&handle)?; + let pet = MenuItemBuilder::with_id("pet", "Desktop Pet").build(&handle)?; + let update = MenuItemBuilder::with_id("update", "Check for Updates…").build(&handle)?; + let repository = MenuItemBuilder::with_id("repository", "OpenWork on GitHub").build(&handle)?; + let zoom_in = MenuItemBuilder::with_id("zoom-in", "Zoom In") + .accelerator("CmdOrCtrl+=") + .build(&handle)?; + let zoom_out = MenuItemBuilder::with_id("zoom-out", "Zoom Out") + .accelerator("CmdOrCtrl+-") + .build(&handle)?; + let zoom_reset = MenuItemBuilder::with_id("zoom-reset", "Actual Size") + .accelerator("CmdOrCtrl+0") + .build(&handle)?; + let about = AboutMetadata { + name: Some("OpenWork".to_string()), + version: Some(env!("CARGO_PKG_VERSION").to_string()), + authors: Some(vec![ + "ModelStudio".to_string(), + "Qwen Code Team".to_string(), + ]), + comments: Some("OpenWork desktop, powered by the Qwen Code agent engine.".to_string()), + copyright: Some("Copyright © ModelStudio and Qwen Code contributors".to_string()), + license: Some("Apache-2.0".to_string()), + website: Some("https://github.com/modelstudioai/openwork".to_string()), + website_label: Some("OpenWork on GitHub".to_string()), + credits: Some("OpenWork by ModelStudio\nQwen Code agent engine by QwenLM".to_string()), + ..Default::default() + }; + #[cfg(target_os = "macos")] + menu.append( + &SubmenuBuilder::new(&handle, "OpenWork") + .about(Some(about.clone())) + .separator() + .services() + .separator() + .hide() + .hide_others() + .show_all() + .separator() + .quit() + .build()?, + )?; + menu.append( + &SubmenuBuilder::new(&handle, "File") + .item(&new_task) + .item(&worktree) + .item(&settings) + .separator() + .close_window() + .quit() + .build()?, + )?; + menu.append( + &SubmenuBuilder::new(&handle, "Edit") + .undo() + .redo() + .separator() + .cut() + .copy() + .paste() + .select_all() + .build()?, + )?; + menu.append( + &SubmenuBuilder::new(&handle, "View") + .item(&browser) + .item(&pet) + .item(&shortcuts) + .separator() + .item(&zoom_in) + .item(&zoom_out) + .item(&zoom_reset) + .separator() + .fullscreen() + .build()?, + )?; menu.append( &SubmenuBuilder::new(&handle, "Control") .item(&local_control_menu) .item(&local_control_off_menu) .build()?, )?; + menu.append( + &SubmenuBuilder::new(&handle, "Window") + .minimize() + .maximize() + .close_window() + .build()?, + )?; + let help = SubmenuBuilder::new(&handle, "Help") + .item(&repository) + .item(&update); + #[cfg(not(target_os = "macos"))] + let help = help.separator().about(Some(about)); + menu.append(&help.build()?)?; handle.set_menu(menu)?; let settings = SettingsStore::load(&handle).map_err(std::io::Error::other)?; let window_state = settings.window(); @@ -266,6 +428,15 @@ fn setup_app(app: &mut tauri::App) -> Result<(), Box> { }) .build()?; restore_window(&window, window_state.as_ref()); + let deep_link_handle = handle.clone(); + handle.deep_link().on_open_url(move |event| { + emit_deep_links( + &deep_link_handle, + event.urls().iter().map(|url| url.as_str()), + ); + }); + #[cfg(any(target_os = "linux", all(debug_assertions, target_os = "windows")))] + let _ = handle.deep_link().register_all(); handle.manage(ApplicationState { runtime: Mutex::new(None), @@ -304,6 +475,10 @@ fn bootstrap_state( require_bootstrap_origin(&webview)?; let starting = state.starting.load(Ordering::SeqCst) != 0; let running = lock(&state.runtime).is_some(); + let workspace = bootstrap_workspace( + lock(&state.last_workspace).clone(), + state.settings.workspace(), + ); Ok(BootstrapState { desktop_version: env!("CARGO_PKG_VERSION").to_string(), status: if running { @@ -313,14 +488,20 @@ fn bootstrap_state( } else { "idle" }, - workspace: state - .settings - .workspace() - .map(|path| path.to_string_lossy().into_owned()), + workspace: workspace.map(|path| path.to_string_lossy().into_owned()), error: lock(&state.last_error).clone(), }) } +fn bootstrap_workspace( + last_workspace: Option<(PathBuf, bool)>, + persisted_workspace: Option, +) -> Option { + last_workspace + .map(|(workspace, _)| workspace) + .or(persisted_workspace) +} + #[tauri::command] async fn choose_workspace( webview: WebviewWindow, @@ -422,6 +603,382 @@ fn open_logs(webview: WebviewWindow, state: State<'_, ApplicationState>) -> Resu .map_err(|error| format!("Failed to open desktop logs: {error}")) } +#[tauri::command] +fn set_interface_zoom( + webview: WebviewWindow, + state: State<'_, ApplicationState>, + percent: u16, +) -> Result<(), String> { + require_runtime_origin(&webview, &state)?; + if !(50..=200).contains(&percent) { + return Err("Zoom must be between 50 and 200 percent.".to_string()); + } + webview + .set_zoom(f64::from(percent) / 100.0) + .map_err(|error| format!("Failed to set zoom: {error}")) +} + +#[tauri::command] +fn read_openwork_client_state( + webview: WebviewWindow, + state: State<'_, ApplicationState>, +) -> Result { + require_runtime_origin(&webview, &state)?; + Ok(state.settings.openwork()) +} + +#[tauri::command] +fn write_openwork_client_state( + webview: WebviewWindow, + state: State<'_, ApplicationState>, + client_state: OpenWorkClientState, +) -> Result<(), String> { + require_runtime_origin(&webview, &state)?; + state.settings.set_openwork(client_state) +} + +#[tauri::command] +async fn browser_open( + webview: WebviewWindow, + state: State<'_, ApplicationState>, + url: String, +) -> Result<(), String> { + require_runtime_origin(&webview, &state)?; + let url = parse_browser_url(&url)?; + if let Some(browser) = webview.app_handle().get_webview("browser") { + return browser + .navigate(url) + .map_err(|error| format!("Failed to navigate browser: {error}")); + } + let size = webview + .inner_size() + .map_err(|error| format!("Failed to read window size: {error}"))?; + let scale = webview + .scale_factor() + .map_err(|error| format!("Failed to read display scale: {error}"))?; + let logical = size.to_logical::(scale); + let x = logical.width * 0.45; + let mut builder = WebviewBuilder::new("browser", WebviewUrl::External(url)) + .on_navigation(|url| is_safe_browser_url(url)) + .on_new_window(|url, _| { + if is_safe_browser_url(&url) { + let _ = open::that_detached(url.as_str()); + } + NewWindowResponse::Deny + }); + if let Some(proxy) = resolve_proxy_url() { + builder = builder.proxy_url(proxy); + } + webview + .as_ref() + .window() + .add_child( + builder, + LogicalPosition::new(x, 48.0), + LogicalSize::new(logical.width - x, (logical.height - 48.0).max(1.0)), + ) + .map(|_| ()) + .map_err(|error| format!("Failed to open browser dock: {error}")) +} + +#[tauri::command] +fn browser_set_bounds( + webview: WebviewWindow, + state: State<'_, ApplicationState>, + x: f64, + y: f64, + width: f64, + height: f64, +) -> Result<(), String> { + require_runtime_origin(&webview, &state)?; + let size = webview + .inner_size() + .map_err(|error| format!("Failed to read window size: {error}"))?; + let scale = webview + .scale_factor() + .map_err(|error| format!("Failed to read display scale: {error}"))?; + let logical = size.to_logical::(scale); + if !browser_bounds_fit(x, y, width, height, logical.width, logical.height) { + return Err("Invalid browser dock bounds.".to_string()); + } + let browser = webview + .app_handle() + .get_webview("browser") + .ok_or_else(|| "Browser dock is not open.".to_string())?; + browser + .set_position(LogicalPosition::new(x, y)) + .and_then(|_| browser.set_size(LogicalSize::new(width, height))) + .map_err(|error| format!("Failed to resize browser dock: {error}")) +} + +fn browser_bounds_fit( + x: f64, + y: f64, + width: f64, + height: f64, + max_width: f64, + max_height: f64, +) -> bool { + [x, y, width, height, max_width, max_height] + .iter() + .all(|value| value.is_finite()) + && x >= 0.0 + && y >= 0.0 + && width >= 1.0 + && height >= 1.0 + && x + width <= max_width + 2.0 + && y + height <= max_height + 2.0 +} + +#[tauri::command] +fn browser_navigate( + webview: WebviewWindow, + state: State<'_, ApplicationState>, + action: String, +) -> Result<(), String> { + require_runtime_origin(&webview, &state)?; + let script = match action.as_str() { + "back" => "history.back()", + "forward" => "history.forward()", + "reload" => "location.reload()", + _ => return Err("Unknown browser action.".to_string()), + }; + webview + .app_handle() + .get_webview("browser") + .ok_or_else(|| "Browser dock is not open.".to_string())? + .eval(script) + .map_err(|error| format!("Failed to control browser dock: {error}")) +} + +#[tauri::command] +fn browser_close(webview: WebviewWindow, state: State<'_, ApplicationState>) -> Result<(), String> { + require_runtime_origin(&webview, &state)?; + close_browser_dock(webview.app_handle()) +} + +fn close_browser_dock(app: &AppHandle) -> Result<(), String> { + match app.get_webview("browser") { + Some(browser) => browser + .close() + .map_err(|error| format!("Failed to close browser dock: {error}")), + None => Ok(()), + } +} + +#[tauri::command] +fn notify_turn_complete( + webview: WebviewWindow, + state: State<'_, ApplicationState>, + title: String, + body: String, +) -> Result<(), String> { + require_runtime_origin(&webview, &state)?; + webview + .app_handle() + .notification() + .builder() + .title(title.chars().take(80).collect::()) + .body(body.chars().take(240).collect::()) + .show() + .map_err(|error| format!("Failed to show notification: {error}")) +} + +#[tauri::command] +fn proxy_status( + webview: WebviewWindow, + state: State<'_, ApplicationState>, +) -> Result { + require_runtime_origin(&webview, &state)?; + Ok(resolve_proxy_url() + .map(|url| { + format!( + "Proxy: {}://{}", + url.scheme(), + url.host_str().unwrap_or("configured") + ) + }) + .unwrap_or_else(|| "Direct connection".to_string())) +} + +fn pets_root(app: &AppHandle) -> Result { + app.path() + .home_dir() + .map(|home| home.join(".qwen").join("pets")) + .map_err(|error| format!("Failed to resolve desktop pets: {error}")) +} + +fn load_pet(app: &AppHandle, id: &str) -> Result<(PetManifest, PathBuf), String> { + load_pet_from_root(&pets_root(app)?, id) +} + +fn load_pet_from_root(root: &Path, id: &str) -> Result<(PetManifest, PathBuf), String> { + if !valid_pet_id(id) || id == "qwen" { + return Err("Invalid custom desktop pet.".to_string()); + } + let directory = root.join(id); + let manifest: PetManifest = serde_json::from_str( + &fs::read_to_string(directory.join("pet.json")) + .map_err(|error| format!("Failed to read desktop pet {id}: {error}"))?, + ) + .map_err(|error| format!("Invalid desktop pet {id}: {error}"))?; + if manifest.id != id + || !valid_pet_text(&manifest.display_name, 80) + || !valid_pet_text(&manifest.description, 240) + { + return Err("Desktop pet manifest does not match its directory.".to_string()); + } + let directory = fs::canonicalize(directory) + .map_err(|error| format!("Failed to resolve desktop pet {id}: {error}"))?; + let sprite = fs::canonicalize(directory.join(&manifest.spritesheet_path)) + .map_err(|error| format!("Failed to resolve desktop pet spritesheet: {error}"))?; + if !sprite.starts_with(&directory) || !sprite.is_file() { + return Err("Desktop pet spritesheet escapes its pet directory.".to_string()); + } + Ok((manifest, sprite)) +} + +fn valid_pet_text(value: &str, max: usize) -> bool { + !value.trim().is_empty() && value.len() <= max && !value.chars().any(char::is_control) +} + +#[tauri::command] +fn list_pets( + webview: WebviewWindow, + state: State<'_, ApplicationState>, +) -> Result, String> { + require_runtime_origin(&webview, &state)?; + let app = webview.app_handle(); + let mut pets = fs::read_dir(pets_root(app)?) + .into_iter() + .flatten() + .filter_map(Result::ok) + .filter_map(|entry| { + let id = entry.file_name().to_string_lossy().into_owned(); + let (manifest, _) = load_pet(app, &id).ok()?; + Some(PetInfo { + id, + display_name: manifest.display_name, + description: manifest.description, + }) + }) + .collect::>(); + pets.sort_by(|left, right| left.display_name.cmp(&right.display_name)); + Ok(pets) +} + +#[tauri::command] +fn resolve_pet_sprite(webview: WebviewWindow, pet_id: String) -> Result, String> { + if webview.label() != "pet" { + return Err("Desktop pet assets are available only to the pet window.".to_string()); + } + if pet_id == "qwen" { + return Ok(None); + } + load_pet(webview.app_handle(), &pet_id) + .map(|(_, sprite)| Some(sprite.to_string_lossy().into_owned())) +} + +fn open_pet(app: &AppHandle, pet_id: &str) -> Result { + if pet_id != "qwen" { + load_pet(app, pet_id)?; + } + let encoded = url::form_urlencoded::byte_serialize(pet_id.as_bytes()).collect::(); + WebviewWindowBuilder::new( + app, + "pet", + WebviewUrl::App(format!("pet.html?pet={encoded}").into()), + ) + .title("OpenWork Pet") + .inner_size(144.0, 156.0) + .resizable(false) + .decorations(false) + .transparent(true) + .always_on_top(true) + .skip_taskbar(true) + .build() + .map(|_| true) + .map_err(|error| format!("Failed to open desktop pet: {error}")) +} + +#[tauri::command] +fn toggle_pet( + webview: WebviewWindow, + state: State<'_, ApplicationState>, + visible: Option, + pet_id: Option, +) -> Result { + require_runtime_origin(&webview, &state)?; + let app = webview.app_handle(); + if let Some(pet) = app.get_webview_window("pet") { + if visible == Some(true) && pet_id.is_none() { + return Ok(true); + } + pet.close() + .map_err(|error| format!("Failed to close desktop pet: {error}"))?; + if visible != Some(true) { + return Ok(false); + } + } + if visible == Some(false) { + return Ok(false); + } + let selected = pet_id.unwrap_or_else(|| state.settings.openwork().pet_id); + open_pet(app, &selected) +} + +#[tauri::command] +async fn check_for_updates( + webview: WebviewWindow, + state: State<'_, ApplicationState>, +) -> Result, String> { + require_runtime_origin(&webview, &state)?; + let updater = webview + .app_handle() + .updater() + .map_err(|error| format!("Updater unavailable: {error}"))?; + match updater + .check() + .await + .map_err(|error| format!("Update check failed: {error}"))? + { + Some(update) => Ok(Some(update.version)), + None => Ok(None), + } +} + +#[tauri::command] +async fn install_update( + webview: WebviewWindow, + state: State<'_, ApplicationState>, +) -> Result<(), String> { + require_runtime_origin(&webview, &state)?; + let app = webview.app_handle().clone(); + let update = app + .updater() + .map_err(|error| format!("Updater unavailable: {error}"))? + .check() + .await + .map_err(|error| format!("Update check failed: {error}"))? + .ok_or_else(|| "OpenWork is already up to date".to_string())?; + update + .download_and_install(|_, _| {}, || {}) + .await + .map_err(|error| format!("Update installation failed: {error}"))?; + app.restart() +} + +#[tauri::command] +fn take_pending_deep_links( + webview: WebviewWindow, + state: State<'_, ApplicationState>, +) -> Result, String> { + require_runtime_origin(&webview, &state)?; + Ok(std::mem::take(&mut *lock( + PENDING_DEEP_LINKS.get_or_init(|| Mutex::new(Vec::new())), + ))) +} + fn start_runtime_async(app: AppHandle, workspace: PathBuf, create_if_missing: bool) { stop_runtime(&app); let generation = { @@ -554,6 +1111,7 @@ fn emit_runtime_failure(app: &AppHandle, generation: u64, error: String) { } fn stop_runtime(app: &AppHandle) { + let _ = close_browser_dock(app); stop_local_control(app); let state = app.state::(); state.start_generation.fetch_add(1, Ordering::SeqCst); @@ -741,6 +1299,99 @@ fn require_bootstrap_origin(webview: &WebviewWindow) -> Result<(), String> { } } +fn require_runtime_origin(webview: &WebviewWindow, state: &ApplicationState) -> Result<(), String> { + let url = webview + .url() + .map_err(|error| format!("Failed to read calling webview URL: {error}"))?; + if lock(&state.origin) + .as_ref() + .is_some_and(|origin| is_same_origin(&url, origin)) + { + Ok(()) + } else { + Err("This command is only available to the active local runtime.".to_string()) + } +} + +fn emit_deep_links<'a>(app: &AppHandle, values: impl Iterator) { + for value in values { + let Ok(url) = Url::parse(value) else { + continue; + }; + if !is_safe_deep_link(&url) { + continue; + } + let value = url.to_string(); + let mut pending = lock(PENDING_DEEP_LINKS.get_or_init(|| Mutex::new(Vec::new()))); + if pending.len() == 16 { + pending.remove(0); + } + pending.push(value.clone()); + drop(pending); + let _ = app.emit_to("main", "openwork-deep-link", value); + } +} + +fn is_safe_deep_link(url: &Url) -> bool { + if url.scheme() != "openwork" + || !url.username().is_empty() + || url.password().is_some() + || url.port().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return false; + } + match url.host_str() { + Some("new") => matches!(url.path(), "" | "/"), + Some("session") => url.path().strip_prefix('/').is_some_and(is_safe_session_id), + _ => false, + } +} + +fn is_safe_session_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +fn parse_browser_url(value: &str) -> Result { + let url = Url::parse(value).map_err(|_| "Browser URL is invalid.".to_string())?; + if !is_safe_browser_url(&url) { + return Err("Browser URLs must use HTTP(S) without embedded credentials.".to_string()); + } + Ok(url) +} + +fn is_safe_browser_url(url: &Url) -> bool { + matches!(url.scheme(), "http" | "https") + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() +} + +fn resolve_proxy_url() -> Option { + [ + "OPENWORK_PROXY", + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + "HTTP_PROXY", + "http_proxy", + ] + .into_iter() + .filter_map(|key| std::env::var(key).ok()) + .find_map(|value| { + Url::parse(value.trim()).ok().filter(|url| { + matches!(url.scheme(), "http" | "https" | "socks5" | "socks5h") + && url.host_str().is_some() + }) + }) +} + fn is_allowed_navigation(url: &Url, origin: &Mutex>) -> bool { is_bootstrap_url(url) || lock(origin) @@ -792,16 +1443,17 @@ fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { #[cfg(test)] mod tests { + use super::{ + bootstrap_workspace, browser_bounds_fit, default_workspace_override_dir, + default_workspace_path, ensure_workspace_dir, is_allowed_navigation, is_bootstrap_url, + is_safe_deep_link, is_safe_external_url, is_same_origin, load_pet_from_root, origin_of, + parse_browser_url, BOOTSTRAP_URL, + }; #[cfg(target_os = "macos")] use super::{ cancel_pending_fullscreen_hide, should_restore_main_window, take_pending_fullscreen_hide, FULLSCREEN_HIDE_GENERATION, FULLSCREEN_HIDE_PENDING, }; - use super::{ - default_workspace_override_dir, default_workspace_path, ensure_workspace_dir, - is_allowed_navigation, is_bootstrap_url, is_safe_external_url, is_same_origin, origin_of, - BOOTSTRAP_URL, - }; use std::ffi::OsString; use std::fs; use std::path::PathBuf; @@ -810,6 +1462,21 @@ mod tests { use std::sync::Mutex; use url::Url; + #[test] + fn bootstrap_prefers_the_workspace_being_started() { + let attempted = PathBuf::from("/tmp/attempted"); + let persisted = PathBuf::from("/tmp/persisted"); + assert_eq!( + bootstrap_workspace(Some((attempted.clone(), false)), Some(persisted.clone())), + Some(attempted), + ); + assert_eq!( + bootstrap_workspace(None, Some(persisted.clone())), + Some(persisted) + ); + assert_eq!(bootstrap_workspace(None, None), None); + } + #[cfg(target_os = "macos")] #[test] fn fullscreen_hide_lifecycle_state() { @@ -996,6 +1663,63 @@ mod tests { )); } + #[test] + fn validates_desktop_external_inputs() { + assert!(is_safe_deep_link( + &Url::parse("openwork://session/123e4567-e89b-12d3-a456-426614174000") + .expect("session link") + )); + assert!(is_safe_deep_link( + &Url::parse("openwork://new").expect("new link") + )); + for value in [ + "openwork://session/one/two", + "openwork://session/id?token=secret", + "openwork://unknown/id", + "https://session/id", + ] { + assert!(!is_safe_deep_link( + &Url::parse(value).expect("invalid link") + )); + } + assert!(parse_browser_url("https://example.com/path").is_ok()); + assert!(parse_browser_url("https://user:secret@example.com").is_err()); + assert!(parse_browser_url("file:///etc/passwd").is_err()); + assert!(browser_bounds_fit(450.0, 48.0, 550.0, 752.0, 1000.0, 800.0)); + assert!(!browser_bounds_fit(-1.0, 48.0, 550.0, 752.0, 1000.0, 800.0)); + assert!(!browser_bounds_fit( + 450.0, 48.0, 700.0, 752.0, 1000.0, 800.0 + )); + } + + #[test] + fn desktop_pet_sprites_stay_inside_their_manifest_directory() { + let root = + std::env::temp_dir().join(format!("openwork-desktop-pet-test-{}", std::process::id())); + let pet = root.join("helper"); + fs::create_dir_all(&pet).expect("create pet directory"); + fs::write(pet.join("spritesheet.webp"), b"image").expect("write sprite"); + fs::write( + pet.join("pet.json"), + r#"{"id":"helper","displayName":"Helper","description":"A test pet","spritesheetPath":"spritesheet.webp"}"#, + ) + .expect("write pet manifest"); + let (_, sprite) = load_pet_from_root(&root, "helper").expect("valid pet"); + assert_eq!( + sprite, + fs::canonicalize(pet.join("spritesheet.webp")).unwrap() + ); + + fs::write(root.join("outside.webp"), b"outside").expect("write outside sprite"); + fs::write( + pet.join("pet.json"), + r#"{"id":"helper","displayName":"Helper","description":"A test pet","spritesheetPath":"../outside.webp"}"#, + ) + .expect("write traversal manifest"); + assert!(load_pet_from_root(&root, "helper").is_err()); + fs::remove_dir_all(root).expect("cleanup pet fixture"); + } + #[test] fn allows_bootstrap_but_not_a_runtime_url_before_origin_is_set() { let origin = Mutex::new(None); diff --git a/packages/desktop-shell/src-tauri/src/runtime.rs b/packages/desktop-shell/src-tauri/src/runtime.rs index b1e495f2f..e728b0d91 100644 --- a/packages/desktop-shell/src-tauri/src/runtime.rs +++ b/packages/desktop-shell/src-tauri/src/runtime.rs @@ -49,8 +49,15 @@ impl DesktopRuntime { ) -> Result { let id = NEXT_RUNTIME_ID.fetch_add(1, Ordering::Relaxed); let layout = RuntimeLayout::resolve(app)?; + run_legacy_migration(&layout)?; // Callers pass a workspace already resolved by resolve_workspace. let token = random_token(); + let mut runtime_path = vec![layout.tools_bin.clone(), layout.uv_dir.clone()]; + if let Some(path) = std::env::var_os("PATH") { + runtime_path.extend(std::env::split_paths(&path)); + } + let runtime_path = std::env::join_paths(runtime_path) + .map_err(|error| format!("Failed to configure document tools: {error}"))?; let mut command = Command::new(&layout.node); command .arg(&layout.entry) @@ -61,7 +68,11 @@ impl DesktopRuntime { .stderr(Stdio::piped()) .env("OPENWORK_DESKTOP", "1") .env("QWEN_CODE_DESKTOP", "1") - .env("QWEN_SERVER_TOKEN", &token); + .env("QWEN_SERVER_TOKEN", &token) + .env("CRAFT_IS_PACKAGED", "1") + .env("CRAFT_UV", &layout.uv) + .env("CRAFT_SCRIPTS", &layout.scripts) + .env("PATH", runtime_path); let mut child = command .group_spawn() @@ -152,6 +163,11 @@ impl Drop for DesktopRuntime { struct RuntimeLayout { node: PathBuf, entry: PathBuf, + tools_bin: PathBuf, + uv_dir: PathBuf, + uv: PathBuf, + scripts: PathBuf, + migration: PathBuf, } impl RuntimeLayout { @@ -171,10 +187,50 @@ impl RuntimeLayout { .join("openwork") }; let (node, entry) = layout_from_root(root); + let tools = entry + .parent() + .and_then(Path::parent) + .expect("runtime entry has a package root") + .join("tools"); + let tools_bin = dunce::simplified(&tools.join("bin")).to_path_buf(); + let uv_dir = dunce::simplified(&tools.join("uv")).to_path_buf(); + let uv = uv_dir.join(if cfg!(windows) { "uv.exe" } else { "uv" }); + let scripts = dunce::simplified(&tools.join("scripts")).to_path_buf(); + let migration = dunce::simplified(&tools.join("openwork-migrate.mjs")).to_path_buf(); require_file(&node, "Node.js runtime")?; require_file(&entry, "Qwen Code runtime entry")?; - Ok(Self { node, entry }) + require_file(&uv, "uv runtime")?; + require_file(&scripts.join("pdf_tool.py"), "document tool scripts")?; + require_file(&migration, "OpenWork data migration")?; + Ok(Self { + node, + entry, + tools_bin, + uv_dir, + uv, + scripts, + migration, + }) + } +} + +fn run_legacy_migration(layout: &RuntimeLayout) -> Result<(), String> { + if std::env::var_os("OPENWORK_DESKTOP_DISABLE_MIGRATION").as_deref() + == Some(std::ffi::OsStr::new("1")) + { + return Ok(()); } + let output = Command::new(&layout.node) + .arg(&layout.migration) + .output() + .map_err(|error| format!("Failed to start OpenWork data migration: {error}"))?; + if output.status.success() { + return Ok(()); + } + Err(format!( + "OpenWork data migration failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )) } fn layout_from_root(root: PathBuf) -> (PathBuf, PathBuf) { diff --git a/packages/desktop-shell/src-tauri/tauri.conf.json b/packages/desktop-shell/src-tauri/tauri.conf.json index 7c27231c3..44886e863 100644 --- a/packages/desktop-shell/src-tauri/tauri.conf.json +++ b/packages/desktop-shell/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenWork", - "version": "0.1.0", + "version": "0.2.0", "identifier": "com.alibaba.openwork", "build": { "beforeDevCommand": "node scripts/serve-bootstrap.js", @@ -10,10 +10,15 @@ }, "app": { "withGlobalTauri": true, + "macOSPrivateApi": true, "windows": [], "security": { - "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src ipc: http://ipc.localhost; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'", - "capabilities": ["bootstrap"] + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: asset: http://asset.localhost; connect-src ipc: http://ipc.localhost; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'", + "assetProtocol": { + "enable": true, + "scope": ["$HOME/.qwen/pets/**"] + }, + "capabilities": ["bootstrap", "runtime", "pet"] } }, "bundle": { @@ -53,8 +58,22 @@ }, "nsis": { "installMode": "currentUser", - "installerIcon": "icons/icon.ico" + "installerIcon": "icons/icon.ico", + "installerHooks": "windows/electron-migration.nsh" } } + }, + "plugins": { + "deep-link": { + "desktop": { + "schemes": ["openwork"] + } + }, + "updater": { + "endpoints": [ + "https://github.com/modelstudioai/openwork/releases/download/desktop-latest/latest.json" + ], + "pubkey": "" + } } } diff --git a/packages/desktop-shell/src-tauri/windows/electron-migration.nsh b/packages/desktop-shell/src-tauri/windows/electron-migration.nsh new file mode 100644 index 000000000..147b7a789 --- /dev/null +++ b/packages/desktop-shell/src-tauri/windows/electron-migration.nsh @@ -0,0 +1,14 @@ +!define ELECTRON_INSTALL_KEY "Software\d6bd5575-5bf2-5dad-acfe-35e3bbeefd68" +!define ELECTRON_UNINSTALL_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\d6bd5575-5bf2-5dad-acfe-35e3bbeefd68" + +!macro NSIS_HOOK_PREINSTALL + ReadRegStr $R0 HKCU "${ELECTRON_INSTALL_KEY}" "InstallLocation" + ReadRegStr $R1 HKCU "${ELECTRON_UNINSTALL_KEY}" "DisplayName" + ${If} $R0 != "" + ${AndIf} $R1 == "OpenWork" + ExecWait '"$R0\Uninstall OpenWork.exe" /currentuser /S --updated _?=$R0' $R2 + ${If} $R2 != 0 + Abort "Could not remove the previous OpenWork installation." + ${EndIf} + ${EndIf} +!macroend diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index d941d480b..e5ec148d8 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -99,7 +99,7 @@ type ChatEditorTestProps = { tokenCount?: number; contextWindow?: number; onShowContextUsage?: () => void; - onChatWidthModeChange?: (mode: '1000' | 'wide') => void; + onChatWidthModeChange?: (mode: '840' | '1100' | 'wide') => void; }; type AddWorkspaceDialogTestProps = { @@ -9990,6 +9990,31 @@ describe('App session callbacks', () => { ); }); + it('opens a recent session in its persisted workspace', async () => { + mockWorkspace.capabilities = { + workspaces: [ + { id: 'primary', cwd: '/work/primary', primary: true }, + { id: 'secondary', cwd: '/work/secondary', primary: false }, + ], + }; + renderApp(); + await flush(); + + await act(async () => { + window.dispatchEvent( + new CustomEvent('qwen:open-session', { + detail: { sessionId: 'secondary-session', workspaceId: 'secondary' }, + }), + ); + await Promise.resolve(); + }); + + expect(mockSessionActions.loadSession).toHaveBeenCalledWith( + 'secondary-session', + { workspaceCwd: '/work/secondary' }, + ); + }); + it('does not steal focus when an approval appears before deferred session focus', async () => { vi.useFakeTimers(); const { container, rerender } = renderApp(); @@ -13295,6 +13320,38 @@ describe('App session callbacks', () => { ).toBeNull(); }); + it('creates a named worktree session through the external shell ref', async () => { + mockWorkspace.capabilities = { + workspaces: [ + { id: 'primary', cwd: '/workspace', primary: true, trusted: true }, + ], + }; + mockSessionActions.clearSession.mockImplementationOnce(async () => { + mockConnection.sessionId = undefined; + }); + mockSessionActions.createSession.mockResolvedValueOnce({ + sessionId: 'worktree-session', + worktree: { + slug: 'feature-a', + path: '/workspace/.qwen/worktrees/feature-a', + branch: 'worktree-feature-a', + }, + }); + const shellRef = createRef(); + renderApp({ shellRef }); + await flush(); + + let created: boolean | undefined; + await act(async () => { + created = await shellRef.current?.createWorktreeSession('feature-a'); + }); + + expect(created).toBe(true); + expect(mockSessionActions.createSession).toHaveBeenCalledWith( + expect.objectContaining({ worktree: { slug: 'feature-a' } }), + ); + }); + it('reports a failed external new-session attempt through its boolean result', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); mockSessionActions.clearSession.mockRejectedValueOnce(new Error('boom')); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index df29c9ea5..cca5472a2 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -620,8 +620,14 @@ export interface WebShellApi { openSessionDrawer: () => void; /** Start a new session using the same lifecycle as the built-in New Chat action. */ createNewSession: () => Promise; + /** Start and attach a new session in Qwen Code's managed Git worktree. */ + createWorktreeSession: (slug?: string) => Promise; /** Open the right panel with a new side-task draft. */ createSideTask: () => boolean; + openSettings: () => void; + openSkills: () => void; + openChannels: () => void; + openShortcuts: () => void; } export type WebShellComposerPlaceholderState = ComposerPlaceholderState; @@ -658,7 +664,7 @@ export interface WebShellProps { /** Called when `/theme` changes the web-shell theme. */ onThemeChange?: (theme: WebShellTheme) => void; /** UI language for the web-shell. Defaults to `?language=` or browser language. */ - language?: 'en' | 'zh-CN' | 'zh' | 'zh-cn'; + language?: WebShellLanguage | 'zh' | 'zh-cn'; /** Called when `/language ui` changes the web-shell UI language. */ onLanguageChange?: (language: WebShellLanguage) => void; /** Additional CSS class name appended to the root element. */ @@ -667,7 +673,7 @@ export interface WebShellProps { style?: React.CSSProperties; /** Optional Shadow DOM isolation for plugin content and/or all portals. */ shadowDom?: WebShellShadowDom; - /** Maximum chat content width in regular mode. Defaults to 1000px. */ + /** Maximum chat content width in regular mode. Defaults to 1100px. */ chatMaxWidth?: number; /** Optional workspace sidebar. Disabled by default. */ sidebar?: boolean | WebShellSidebarOptions; @@ -851,7 +857,7 @@ const emptyComposerApi: WebShellComposerApi = { }; const EMPTY_BOTTOM_STATUS_ITEMS: readonly WebShellBottomStatusItem[] = []; -const DEFAULT_CHAT_MAX_WIDTH = 1000; +const DEFAULT_CHAT_MAX_WIDTH = 1100; const DEFAULT_CHAT_HEADER_ITEMS: readonly WebShellChatHeaderItem[] = [ 'title', 'environment', @@ -875,9 +881,11 @@ function imageTabId(src: string): string { } return `image:${hash.toString(36)}`; } -type ChatWidthMode = `${typeof DEFAULT_CHAT_MAX_WIDTH}` | 'wide'; +type ChatWidthMode = '840' | '1100' | 'wide'; const CHAT_WIDTH_STORAGE_KEY = 'qwen-code-web-shell-chat-width'; +const OPENWORK_CLIENT_STATE_EVENT = 'openwork:client-state-changed'; +const OPENWORK_HYDRATE_SHELL_EVENT = 'openwork:hydrate-shell-preferences'; const CHAT_SHELL_HORIZONTAL_PADDING = 40; const SIDEBAR_COLLAPSED_STORAGE_KEY = 'qwen-code-web-shell-sidebar-collapsed'; @@ -941,8 +949,9 @@ function getDefaultChatWidthMode(): ChatWidthMode { function readChatWidthMode(): ChatWidthMode { if (typeof window === 'undefined') return getDefaultChatWidthMode(); try { - return window.localStorage.getItem(CHAT_WIDTH_STORAGE_KEY) === 'wide' - ? 'wide' + const value = window.localStorage.getItem(CHAT_WIDTH_STORAGE_KEY); + return value === '840' || value === '1100' || value === 'wide' + ? value : getDefaultChatWidthMode(); } catch { return getDefaultChatWidthMode(); @@ -955,6 +964,7 @@ function writeChatWidthMode(mode: ChatWidthMode): void { } catch { // localStorage can be unavailable in private or embedded contexts. } + window.dispatchEvent(new Event(OPENWORK_CLIENT_STATE_EVENT)); } function getChatMaxWidth(value: number | undefined): number { @@ -967,7 +977,11 @@ function getChatWidthStyle( mode: ChatWidthMode, chatMaxWidth: number | undefined, ): CSSProperties { - const contentWidth = `${getChatMaxWidth(chatMaxWidth)}px`; + const width = + chatMaxWidth === undefined + ? Number(mode === 'wide' ? DEFAULT_CHAT_MAX_WIDTH : mode) + : getChatMaxWidth(chatMaxWidth); + const contentWidth = `${width}px`; const shellWidth = `calc(${contentWidth} + ${CHAT_SHELL_HORIZONTAL_PADDING}px)`; return { '--chat-regular-content-width': contentWidth, @@ -1649,6 +1663,18 @@ export function App({ }: AppProps = {}) { const [chatWidthMode, setChatWidthMode] = useState(readChatWidthMode); + useEffect(() => { + const handleHydration = (event: Event) => { + const value = (event as CustomEvent<{ chatWidth?: unknown }>).detail + ?.chatWidth; + if (value === '840' || value === '1100' || value === 'wide') { + setChatWidthMode(value); + } + }; + window.addEventListener(OPENWORK_HYDRATE_SHELL_EVENT, handleHydration); + return () => + window.removeEventListener(OPENWORK_HYDRATE_SHELL_EVENT, handleHydration); + }, []); const [selectedLanguage, setSelectedLanguage] = useState( () => providedLanguage === undefined @@ -7270,15 +7296,34 @@ export function App({ }, openSessionDrawer, createNewSession: () => createNewSession(), + createWorktreeSession: async (slug) => { + if (!(await createNewSession())) return false; + const intent: SessionGitIntent = { mode: 'worktree', slug }; + gitModeIntentRef.current = intent; + setGitModeIntent(intent); + try { + return Boolean(await ensureSessionForPrompt()); + } catch (error) { + reportError(error, 'Failed to create worktree session'); + return false; + } + }, createSideTask, + openSettings: () => openPanel('settings'), + openSkills: () => openPanel('skills'), + openChannels: () => openPanel('channels'), + openShortcuts: handleToggleShortcuts, }), [ closeMobileDrawer, createNewSession, createSideTask, + ensureSessionForPrompt, + handleToggleShortcuts, openPanel, openSessionDrawer, requestOpenSplitView, + reportError, ], ); useEffect(() => { @@ -7351,23 +7396,46 @@ export function App({ const handler = (e: Event) => { const detail = ( e as CustomEvent< - string | { sessionId?: unknown; workspaceCwd?: unknown } + | string + | { + sessionId?: unknown; + workspaceId?: unknown; + workspaceCwd?: unknown; + } > ).detail; const sessionId = typeof detail === 'string' ? detail : detail?.sessionId; - const workspaceCwd = + let workspaceCwd = typeof detail === 'object' && detail !== null && typeof detail.workspaceCwd === 'string' ? detail.workspaceCwd : undefined; + const workspaceId = + typeof detail === 'object' && + detail !== null && + typeof detail.workspaceId === 'string' + ? detail.workspaceId + : undefined; + if (workspaceCwd === undefined && workspaceId) { + workspaceCwd = workspaces.find( + (workspace) => workspace.id === workspaceId, + )?.cwd; + if (workspaceCwd === undefined) { + reportError( + new Error(`Workspace ${workspaceId} is no longer available.`), + 'Failed to open session', + ); + return; + } + } if (typeof sessionId === 'string' && sessionId) { handleOpenSessionFromOverview(sessionId, workspaceCwd); } }; window.addEventListener('qwen:open-session', handler); return () => window.removeEventListener('qwen:open-session', handler); - }, [handleOpenSessionFromOverview]); + }, [handleOpenSessionFromOverview, reportError, workspaces]); useEffect(() => { if ( diff --git a/packages/web-shell/client/components/ChatEditor.test.tsx b/packages/web-shell/client/components/ChatEditor.test.tsx index b9fa9e6bf..f682b95c4 100644 --- a/packages/web-shell/client/components/ChatEditor.test.tsx +++ b/packages/web-shell/client/components/ChatEditor.test.tsx @@ -99,6 +99,8 @@ const composerCoreState = vi.hoisted(() => ({ slashMenu: null as SlashMenuState | null, focus: vi.fn(), closeSlashMenu: vi.fn(), + submit: vi.fn(), + text: '', mobileComposer: null as unknown, openHistorySearch: vi.fn(), shellMode: false, @@ -130,7 +132,7 @@ vi.mock('../hooks/useComposerCore', async (importOriginal) => { focus: composerCoreState.focus, submitText: vi.fn(), clearText: vi.fn(), - getText: vi.fn(() => ''), + getText: vi.fn(() => composerCoreState.text), hasInput: vi.fn(() => false), hasAttachments: mockComposerCoreState.pastedImages.length > 0 || @@ -161,7 +163,7 @@ vi.mock('../hooks/useComposerCore', async (importOriginal) => { removeInlineTags: vi.fn(), insertText: vi.fn(), setText: vi.fn(), - submit: vi.fn(), + submit: composerCoreState.submit, clear: vi.fn(), retryLast: vi.fn(), replaceEditorText: vi.fn(), @@ -239,6 +241,8 @@ afterEach(() => { composerCoreState.shellMode = false; composerCoreState.focus.mockReset(); composerCoreState.closeSlashMenu.mockReset(); + composerCoreState.submit.mockReset(); + composerCoreState.text = ''; composerCoreState.mobileComposer = null; composerCoreState.openHistorySearch.mockReset(); voiceButtonState.onActiveChange = undefined; @@ -273,6 +277,7 @@ function renderChatEditor(props: { tokenCount?: number; contextWindow?: number; onShowContextUsage?: () => void; + onSubmit?: (text: string) => boolean | void; placeholderText?: string; animatePlaceholder?: boolean; disabled?: boolean; @@ -1079,6 +1084,32 @@ describe('ChatEditor toolbar popovers', () => { } }); + it('runs toolbar commands without submitting the composer draft', () => { + const onSubmit = vi.fn(); + composerCoreState.text = 'keep this draft'; + const container = renderChatEditor({ + onSubmit, + visibleToolbarActions: [], + customization: { + renderComposerToolbarEnd: ({ runCommand }) => ( + + ), + }, + }); + + act(() => { + Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'effort') + ?.click(); + }); + + expect(onSubmit).toHaveBeenCalledWith('/effort high'); + expect(composerCoreState.text).toBe('keep this draft'); + expect(composerCoreState.submit).not.toHaveBeenCalled(); + }); + it('opens a searchable model popover and selects the filtered model', () => { const onSelectModel = vi.fn(); const container = renderChatEditor({ diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index e5e820752..f0db6d2ec 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -167,7 +167,7 @@ interface ChatEditorProps { * from other panes' chips even when it collapses to an icon on a narrow split. */ workspaceColor?: DaemonSessionGroupPresetColor; - chatWidthMode?: '1000' | 'wide'; + chatWidthMode?: '840' | '1100' | 'wide'; showChatWidthToggle?: boolean; chatWidthToggleMin?: number; visibleToolbarActions?: readonly ComposerToolbarAction[]; @@ -195,7 +195,7 @@ interface ChatEditorProps { onCreateScratchWorkspace?: () => void; onOpenExistingWorkspace?: () => void; atWorkspaceCwd?: string; - onChatWidthModeChange?: (mode: '1000' | 'wide') => void; + onChatWidthModeChange?: (mode: '840' | '1100' | 'wide') => void; onFocusFooter?: () => boolean; dialogOpen?: boolean; followupState?: UseDaemonFollowupSuggestionReturn['followupState']; @@ -482,7 +482,7 @@ function TypewriterPlaceholder({ text }: { text: string }) { ); } -function WidthModeIcon({ mode }: { mode: '1000' | 'wide' }) { +function WidthModeIcon({ mode }: { mode: '840' | '1100' | 'wide' }) { if (mode === 'wide') { return ( diff --git a/packages/web-shell/client/components/RootErrorFallback.tsx b/packages/web-shell/client/components/RootErrorFallback.tsx index 2592e8c0a..abbe4ec47 100644 --- a/packages/web-shell/client/components/RootErrorFallback.tsx +++ b/packages/web-shell/client/components/RootErrorFallback.tsx @@ -17,7 +17,9 @@ interface FallbackCopy { // This surface renders OUTSIDE the in-app I18nProvider (the boundary wraps the // whole App, which owns that provider), so it cannot call useI18n. It carries // its own minimal copy instead of pulling the full translation table. -const COPY: Record = { +const COPY: Partial> & { + en: FallbackCopy; +} = { en: { title: 'Something went wrong', body: 'An unexpected error occurred and this content could not be displayed.', diff --git a/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx b/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx index 3c8dd2d38..016080157 100644 --- a/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx +++ b/packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx @@ -198,18 +198,18 @@ afterEach(() => { }); describe('ChannelsManagerPage', () => { - it('shows only the three enabled platforms and configured instances', async () => { + it('shows the catalog platforms and configured instances', async () => { await renderPage(); expect(container.textContent).toContain('DingTalk Bot'); - expect(container.textContent).not.toContain('Telegram Bot'); + expect(container.textContent).toContain('Telegram Bot'); expect( container.querySelectorAll('[data-testid^="channel-platform-"]'), - ).toHaveLength(3); + ).toHaveLength(4); expect(container.textContent).toContain('DingTalk'); expect(container.textContent).toContain('WeCom'); expect(container.textContent).toContain('Feishu'); - expect(container.textContent).not.toContain('Telegram'); + expect(container.textContent).toContain('Telegram'); }); it('starts a stopped Channel from its card', async () => { diff --git a/packages/web-shell/client/components/channels/channel-platform.test.ts b/packages/web-shell/client/components/channels/channel-platform.test.ts index ad627302a..ae2c4baa9 100644 --- a/packages/web-shell/client/components/channels/channel-platform.test.ts +++ b/packages/web-shell/client/components/channels/channel-platform.test.ts @@ -19,7 +19,7 @@ function descriptor( } describe('Channel platform availability', () => { - it('only exposes manageable DingTalk, WeCom, Feishu, GitHub, and GitLab channels', () => { + it('only exposes supported manageable channels', () => { expect( [ descriptor('dingtalk'), @@ -28,6 +28,7 @@ describe('Channel platform availability', () => { descriptor('github'), descriptor('gitlab'), descriptor('telegram'), + descriptor('whatsapp'), descriptor('weixin'), descriptor('dingtalk', false), descriptor('github', false), @@ -35,7 +36,15 @@ describe('Channel platform availability', () => { ] .filter(isChannelPlatformAvailable) .map((item) => item.type), - ).toEqual(['dingtalk', 'wecom', 'feishu', 'github', 'gitlab']); + ).toEqual([ + 'dingtalk', + 'wecom', + 'feishu', + 'github', + 'gitlab', + 'telegram', + 'whatsapp', + ]); }); it('uses the same allowlist for configured Channel instances', () => { @@ -44,7 +53,8 @@ describe('Channel platform availability', () => { expect(isSupportedChannelType('feishu')).toBe(true); expect(isSupportedChannelType('github')).toBe(true); expect(isSupportedChannelType('gitlab')).toBe(true); - expect(isSupportedChannelType('telegram')).toBe(false); + expect(isSupportedChannelType('telegram')).toBe(true); + expect(isSupportedChannelType('whatsapp')).toBe(true); expect(isSupportedChannelType(undefined)).toBe(false); }); }); diff --git a/packages/web-shell/client/components/channels/channel-platform.ts b/packages/web-shell/client/components/channels/channel-platform.ts index 38369522d..628a8800f 100644 --- a/packages/web-shell/client/components/channels/channel-platform.ts +++ b/packages/web-shell/client/components/channels/channel-platform.ts @@ -12,6 +12,8 @@ export const PLATFORM_MARKS: Record = { feishu: 'F', github: 'GH', gitlab: 'GL', + telegram: 'TG', + whatsapp: 'WA', }; const SUPPORTED_CHANNEL_TYPES = new Set([ @@ -20,11 +22,20 @@ const SUPPORTED_CHANNEL_TYPES = new Set([ 'feishu', 'github', 'gitlab', + 'telegram', + 'whatsapp', ]); export function isSupportedChannelType( type: unknown, -): type is 'dingtalk' | 'wecom' | 'feishu' | 'github' | 'gitlab' { +): type is + | 'dingtalk' + | 'wecom' + | 'feishu' + | 'github' + | 'gitlab' + | 'telegram' + | 'whatsapp' { return typeof type === 'string' && SUPPORTED_CHANNEL_TYPES.has(type); } diff --git a/packages/web-shell/client/components/dialogs/HelpDialog.test.tsx b/packages/web-shell/client/components/dialogs/HelpDialog.test.tsx new file mode 100644 index 000000000..997052bfb --- /dev/null +++ b/packages/web-shell/client/components/dialogs/HelpDialog.test.tsx @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { describe, expect, it } from 'vitest'; +import { I18nProvider } from '../../i18n'; +import { HelpDialog } from './HelpDialog'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +describe('HelpDialog search', () => { + it('filters keyboard shortcuts from the General tab', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render( + + + , + ); + }); + const search = container.querySelector( + 'input[placeholder="Search commands"]', + ); + if (!search) throw new Error('Shortcuts search not found'); + + act(() => { + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )?.set; + setter?.call(search, 'command palette'); + search.dispatchEvent(new Event('input', { bubbles: true })); + }); + + expect(container.textContent).toContain('Open the command palette'); + expect(container.textContent).toContain('Cmd/Ctrl+K'); + expect(container.textContent).not.toContain('Run shell commands'); + act(() => root.unmount()); + container.remove(); + }); +}); diff --git a/packages/web-shell/client/components/dialogs/HelpDialog.tsx b/packages/web-shell/client/components/dialogs/HelpDialog.tsx index a4a828fd2..0305e8a41 100644 --- a/packages/web-shell/client/components/dialogs/HelpDialog.tsx +++ b/packages/web-shell/client/components/dialogs/HelpDialog.tsx @@ -88,6 +88,7 @@ const GENERAL_SHORTCUTS: Array<[string, string]> = [ ['Shift+Tab', 'help.shortcut.approvals'], ['Alt+Left/Right', 'help.shortcut.altWords'], ['Up/Down', 'help.shortcut.history'], + ['Cmd/Ctrl+K', 'help.shortcut.commandPalette'], ]; function commandSignature(command: CommandInfo): string { @@ -131,12 +132,19 @@ function filterCommands( .sort((a, b) => a.name.localeCompare(b.name)); } -function GeneralHelp() { +function GeneralHelp({ query }: { query: string }) { const { t } = useI18n(); + const normalized = query.trim().toLowerCase(); + const shortcuts = GENERAL_SHORTCUTS.filter( + ([key, description]) => + !normalized || + key.toLowerCase().includes(normalized) || + t(description).toLowerCase().includes(normalized), + ); return (
- {GENERAL_SHORTCUTS.map(([key, description]) => ( + {shortcuts.map(([key, description]) => (
{t(description)} {key} @@ -235,7 +243,6 @@ export function HelpDialog({ commands }: HelpDialogProps) { const { t } = useI18n(); const [activeTab, setActiveTab] = useState('general'); const { filterValue: query, inputProps } = useFilterInput(); - const showSearch = activeTab !== 'general'; return (
@@ -254,17 +261,15 @@ export function HelpDialog({ commands }: HelpDialogProps) { ))}
- {showSearch && ( - - )} +
{activeTab === 'general' ? ( - + ) : ( )} diff --git a/packages/web-shell/client/components/messages/AssistantMessage.test.tsx b/packages/web-shell/client/components/messages/AssistantMessage.test.tsx index d9e1c5d25..1ef8b1076 100644 --- a/packages/web-shell/client/components/messages/AssistantMessage.test.tsx +++ b/packages/web-shell/client/components/messages/AssistantMessage.test.tsx @@ -30,6 +30,7 @@ afterEach(() => { } vi.useRealTimers(); vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); function render(node: ReactNode, language: 'en' | 'zh-CN' = 'en'): HTMLElement { @@ -463,3 +464,28 @@ describe('AssistantMessage markdown tables', () => { expect(container.textContent).not.toContain('Copy table'); }); }); + +describe('AssistantMessage copy feedback', () => { + it('copies raw Markdown and announces success or failure', async () => { + const writeText = vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('denied')); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + vi.useFakeTimers(); + const container = render( + , + ); + const button = container.querySelector( + 'button[aria-label="Copy"]', + ); + + await act(async () => button?.click()); + expect(writeText).toHaveBeenCalledWith('**raw**'); + expect(container.textContent).toContain('Copied'); + + await act(async () => button?.click()); + expect(container.textContent).toContain('Copy failed'); + expect(button?.title).toBe('Copy failed'); + }); +}); diff --git a/packages/web-shell/client/components/messages/AssistantMessage.tsx b/packages/web-shell/client/components/messages/AssistantMessage.tsx index 2f9c0f6c5..28a936baf 100644 --- a/packages/web-shell/client/components/messages/AssistantMessage.tsx +++ b/packages/web-shell/client/components/messages/AssistantMessage.tsx @@ -45,6 +45,7 @@ export const AssistantMessage = memo(function AssistantMessage({ const { t } = useI18n(); const { renderAssistantTurnFooter } = useWebShellCustomization(); const [copied, setCopied] = useState(false); + const [copyFailed, setCopyFailed] = useState(false); const showFooter = !!content && !isStreaming && showFooterActions; const customFooter = useMemo( () => @@ -56,14 +57,20 @@ export const AssistantMessage = memo(function AssistantMessage({ const handleCopy = useCallback(() => { const write = navigator.clipboard?.writeText(content); if (!write) { + setCopyFailed(true); return; } void write .then(() => { + setCopyFailed(false); setCopied(true); window.setTimeout(() => setCopied(false), 2000); }) - .catch(() => {}); + .catch(() => { + setCopied(false); + setCopyFailed(true); + window.setTimeout(() => setCopyFailed(false), 2000); + }); }, [content]); return (
@@ -90,12 +97,21 @@ export const AssistantMessage = memo(function AssistantMessage({ + + {copied + ? t('assistant.copied') + : copyFailed + ? t('assistant.copyFailed') + : ''} + {showBranchAction && onBranchSession && ( + + + ); + })} +
+
+
boolean; transformMarkdown?: ( markdown: string, context: MarkdownRenderContext, @@ -350,7 +352,7 @@ export interface WebShellComposerApi { submit(input?: WebShellComposerInput): void; } -export interface WebShellComposerToolbarRenderInfo { +export interface WebShellComposerRenderInfo { disabled: boolean; isRunning: boolean; currentMode: string; @@ -358,6 +360,13 @@ export interface WebShellComposerToolbarRenderInfo { sessionName?: string; } +export interface WebShellComposerToolbarRenderInfo + extends WebShellComposerRenderInfo { + text: string; + submit(input?: WebShellComposerInput): void; + runCommand(command: string): void; +} + export type WebShellComposerToolbarStartRenderInfo = WebShellComposerToolbarRenderInfo; @@ -373,11 +382,9 @@ export type ComposerToolbarEndRenderer = export type ComposerToolbarRightRenderer = ComponentType; -export type ComposerHeaderRenderer = - ComponentType; +export type ComposerHeaderRenderer = ComponentType; -export type ComposerFooterRenderer = - ComponentType; +export type ComposerFooterRenderer = ComponentType; // ---- Background task info (public type for footer renderer) ---- diff --git a/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx b/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx index 293be3476..e93d5867d 100644 --- a/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx +++ b/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx @@ -150,6 +150,15 @@ async function mount({ }; } +async function waitForImageIngestion() { + await vi.waitFor(async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(latest!.pendingImageBatchCount).toBe(0); + }); +} + afterEach(() => { act(() => root?.unmount()); vi.useRealTimers(); @@ -794,9 +803,7 @@ describe('useComposerCore paste', () => { expect(onSubmit).not.toHaveBeenCalled(); expect(latest!.pendingImageBatchCount).toBe(1); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 20)); - }); + await waitForImageIngestion(); expect(latest!.pendingImageBatchCount).toBe(0); expect(latest!.pastedImages).toMatchObject([{ media_type: 'image/png' }]); @@ -875,9 +882,7 @@ describe('useComposerCore paste', () => { drop([first, unsupported]); drop([second]); }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 30)); - }); + await waitForImageIngestion(); expect(latest!.pastedImages.map((image) => image.media_type)).toEqual([ 'image/bmp', @@ -910,9 +915,7 @@ describe('useComposerCore paste', () => { drop([new File(['text'], 'notes.txt', { type: 'text/plain' })]); drop([new File(['png'], 'photo.png', { type: 'image/png' })]); }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 30)); - }); + await waitForImageIngestion(); expect(onImageIngestionNotice).toHaveBeenCalledOnce(); expect(latest!.pastedImages).toMatchObject([{ media_type: 'image/png' }]); diff --git a/packages/web-shell/client/i18n.openwork.test.ts b/packages/web-shell/client/i18n.openwork.test.ts new file mode 100644 index 000000000..a5a5ce48b --- /dev/null +++ b/packages/web-shell/client/i18n.openwork.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { getTranslator, normalizeLanguage } from './i18n'; + +describe('OpenWork legacy locales', () => { + it('normalizes regional variants and falls back to English', () => { + expect(normalizeLanguage('de-DE')).toBe('de'); + expect(normalizeLanguage('ja_JP')).toBe('ja'); + expect(getTranslator('de')('openwork.action.settings')).toBe( + 'Einstellungen', + ); + expect(getTranslator('de')('openwork.appearance.pet')).toBe('Desktop pet'); + }); +}); diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 1891188b2..df208f1b5 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -4,8 +4,21 @@ import { useMemo, type PropsWithChildren, } from 'react'; +import DE from '../../desktop/packages/shared/src/i18n/locales/de.json'; +import ES from '../../desktop/packages/shared/src/i18n/locales/es.json'; +import HU from '../../desktop/packages/shared/src/i18n/locales/hu.json'; +import JA from '../../desktop/packages/shared/src/i18n/locales/ja.json'; +import PL from '../../desktop/packages/shared/src/i18n/locales/pl.json'; -export const WEB_SHELL_LANGUAGES = ['en', 'zh-CN'] as const; +export const WEB_SHELL_LANGUAGES = [ + 'en', + 'de', + 'es', + 'hu', + 'ja', + 'pl', + 'zh-CN', +] as const; export type WebShellLanguage = (typeof WEB_SHELL_LANGUAGES)[number]; @@ -518,6 +531,8 @@ const EN: Messages = { 'approval.option.allowAlwaysTool': 'Always allow for this tool', 'assistant.branch': 'Branch', 'assistant.copy': 'Copy', + 'assistant.copied': 'Copied', + 'assistant.copyFailed': 'Copy failed', 'at.category.extensions': 'Extensions', 'at.category.extensions.description': 'Reference active extensions', 'at.category.files': 'Files', @@ -1410,6 +1425,7 @@ const EN: Messages = { 'help.shortcut.commandMenu': 'Open command menu', 'help.shortcut.completion': 'Accept completion or switch help tabs', 'help.shortcut.history': 'Cycle prompt history or scroll lists', + 'help.shortcut.commandPalette': 'Open the command palette', 'help.shortcut.searchHistory': 'Search prompt history', 'help.shortcut.newline': 'Insert a newline', 'help.shortcut.pasteImages': 'Paste images', @@ -2776,8 +2792,67 @@ const EN: Messages = { 'settings.label.ui.chatWidth': 'Chat width', 'settings.description.ui.chatWidth': 'Frontend-only chat content width. Stored in this browser.', - 'settings.option.ui.chatWidth.1000': 'Regular', + 'settings.option.ui.chatWidth.840': 'Focused (840 px)', + 'settings.option.ui.chatWidth.1100': 'Comfortable (1100 px)', 'settings.option.ui.chatWidth.wide': 'Ultra wide', + 'openwork.starters.label': 'Starter suggestions', + 'openwork.starters.review': 'Review the current changes and flag risks', + 'openwork.starters.explain': + 'Explain this codebase and suggest the next task', + 'openwork.starters.fix': 'Find and fix the highest-impact issue', + 'openwork.appearance.theme': 'Color theme', + 'openwork.appearance.zoom': 'App zoom', + 'openwork.appearance.textSize': 'Chat text size', + 'openwork.appearance.compact': 'Compact', + 'openwork.appearance.default': 'Default', + 'openwork.appearance.large': 'Large', + 'openwork.appearance.contrast': 'High contrast', + 'openwork.appearance.reduceMotion': 'Reduce motion', + 'openwork.appearance.keepAwake': 'Keep awake while running', + 'openwork.appearance.pet': 'Desktop pet', + 'openwork.palette.label': 'OpenWork command palette', + 'openwork.palette.search': 'Search commands and recent tasks', + 'openwork.palette.recent': 'Recently used', + 'openwork.palette.recentTask': 'Recent', + 'openwork.action.new': 'New task', + 'openwork.action.settings': 'Settings', + 'openwork.action.shortcuts': 'Keyboard shortcuts', + 'openwork.action.skills': 'Skills marketplace', + 'openwork.action.channels': 'Channels', + 'openwork.action.worktree': 'Create permanent worktree project', + 'openwork.action.browser': 'Open browser dock', + 'openwork.action.pet': 'Toggle desktop pet', + 'openwork.action.update': 'Check for updates', + 'openwork.action.proxy': 'Show proxy status', + 'openwork.browser.address': 'Browser address', + 'openwork.browser.back': 'Go back', + 'openwork.browser.forward': 'Go forward', + 'openwork.browser.reload': 'Reload browser', + 'openwork.browser.close': 'Close browser dock', + 'openwork.worktree.prompt': 'Name for the permanent worktree', + 'openwork.worktree.creating': 'Creating permanent worktree…', + 'openwork.worktree.unavailable': 'Could not create the worktree session.', + 'openwork.worktree.created': (v) => `Created and opened ${v?.branch ?? ''}`, + 'openwork.update.checking': 'Checking for updates…', + 'openwork.update.unavailable': 'Updater unavailable', + 'openwork.update.available': (v) => + `OpenWork ${v?.version ?? ''} is available`, + 'openwork.update.upToDate': 'OpenWork is up to date', + 'openwork.update.installPrompt': (v) => + `${v?.status ?? ''}. Download and install it now?`, + 'openwork.update.installing': 'Downloading and installing update…', + 'openwork.proxy.direct': 'Direct connection', + 'openwork.skills.marketplace': 'OpenWork marketplace', + 'openwork.skills.marketplaceDescription': + 'Curated skills from ModelStudioAI. Installs into this workspace.', + 'openwork.skills.install': 'Install', + 'openwork.skills.installed': 'Installed', + 'openwork.skills.bailian-cli': + 'Run Model Studio text, image, video, speech, and file workflows.', + 'openwork.skills.bailian-docs-llm-wiki': + 'Look up current Bailian models, APIs, quotas, and error codes.', + 'openwork.skills.spark-video-episode': + 'Produce video episodes from script through reviewed final render.', 'settings.label.visionModel': 'Vision Model', 'settings.description.visionModel': 'Image-capable model used as the vision bridge. Leave empty to auto-select.', @@ -3338,6 +3413,8 @@ const ZH: Messages = { 'approval.option.allowAlwaysTool': '对此工具始终允许', 'assistant.branch': '分叉', 'assistant.copy': '复制', + 'assistant.copied': '已复制', + 'assistant.copyFailed': '复制失败', 'at.category.extensions': '扩展', 'at.category.extensions.description': '引用已启用扩展', 'at.category.files': '文件', @@ -4172,6 +4249,7 @@ const ZH: Messages = { 'help.shortcut.commandMenu': '打开命令菜单', 'help.shortcut.completion': '接受补全或切换帮助标签', 'help.shortcut.history': '切换历史 prompt 或滚动列表', + 'help.shortcut.commandPalette': '打开命令面板', 'help.shortcut.searchHistory': '搜索历史 prompt', 'help.shortcut.newline': '插入换行', 'help.shortcut.pasteImages': '粘贴图片', @@ -5442,8 +5520,64 @@ const ZH: Messages = { 'settings.label.ui.chatWidth': '屏宽', 'settings.description.ui.chatWidth': '纯前端的聊天内容宽度设置,保存在当前浏览器中。', - 'settings.option.ui.chatWidth.1000': '常规', + 'settings.option.ui.chatWidth.840': '聚焦(840 px)', + 'settings.option.ui.chatWidth.1100': '舒适(1100 px)', 'settings.option.ui.chatWidth.wide': '超宽', + 'openwork.starters.label': '快捷建议', + 'openwork.starters.review': '检查当前改动并指出风险', + 'openwork.starters.explain': '解释这个代码库并建议下一项任务', + 'openwork.starters.fix': '查找并修复影响最大的问题', + 'openwork.appearance.theme': '颜色主题', + 'openwork.appearance.zoom': '应用缩放', + 'openwork.appearance.textSize': '聊天文字大小', + 'openwork.appearance.compact': '紧凑', + 'openwork.appearance.default': '默认', + 'openwork.appearance.large': '大', + 'openwork.appearance.contrast': '高对比度', + 'openwork.appearance.reduceMotion': '减少动态效果', + 'openwork.appearance.keepAwake': '任务运行时保持唤醒', + 'openwork.appearance.pet': '桌面宠物', + 'openwork.palette.label': 'OpenWork 命令面板', + 'openwork.palette.search': '搜索命令和最近任务', + 'openwork.palette.recent': '最近使用', + 'openwork.palette.recentTask': '最近任务', + 'openwork.action.new': '新建任务', + 'openwork.action.settings': '设置', + 'openwork.action.shortcuts': '键盘快捷键', + 'openwork.action.skills': '技能市场', + 'openwork.action.channels': '频道', + 'openwork.action.worktree': '创建永久 Worktree 项目', + 'openwork.action.browser': '打开浏览器侧栏', + 'openwork.action.pet': '切换桌面宠物', + 'openwork.action.update': '检查更新', + 'openwork.action.proxy': '显示代理状态', + 'openwork.browser.address': '浏览器地址', + 'openwork.browser.back': '后退', + 'openwork.browser.forward': '前进', + 'openwork.browser.reload': '重新加载浏览器', + 'openwork.browser.close': '关闭浏览器侧栏', + 'openwork.worktree.prompt': '请输入永久 Worktree 名称', + 'openwork.worktree.creating': '正在创建永久 Worktree…', + 'openwork.worktree.unavailable': '无法创建 Worktree 会话。', + 'openwork.worktree.created': (v) => `已创建并打开 ${v?.branch ?? ''}`, + 'openwork.update.checking': '正在检查更新…', + 'openwork.update.unavailable': '更新服务不可用', + 'openwork.update.available': (v) => `OpenWork ${v?.version ?? ''} 可用`, + 'openwork.update.upToDate': 'OpenWork 已是最新版本', + 'openwork.update.installPrompt': (v) => + `${v?.status ?? ''}。现在下载并安装吗?`, + 'openwork.update.installing': '正在下载并安装更新…', + 'openwork.proxy.direct': '直连', + 'openwork.skills.marketplace': 'OpenWork 技能市场', + 'openwork.skills.marketplaceDescription': + '由 ModelStudioAI 精选,安装到当前工作区。', + 'openwork.skills.install': '安装', + 'openwork.skills.installed': '已安装', + 'openwork.skills.bailian-cli': '运行百炼文本、图像、视频、语音和文件工作流。', + 'openwork.skills.bailian-docs-llm-wiki': + '查询最新百炼模型、API、配额和错误码。', + 'openwork.skills.spark-video-episode': + '从脚本到审核完成的最终渲染,制作视频剧集。', 'settings.category.General': '通用', 'settings.category.UI': '界面', 'settings.category.Privacy': '隐私', @@ -5553,13 +5687,59 @@ const ZH: Messages = { 'welcome.tipLabel': '提示:', }; +const LEGACY_MESSAGE_ALIASES: Record = { + 'openwork.appearance.theme': 'settings.appearance.colorTheme', + 'openwork.palette.label': 'commands.title', + 'openwork.palette.search': 'commands.searchCommands', + 'openwork.action.new': 'session.newSession', + 'openwork.action.settings': 'sidebar.settings', + 'openwork.action.shortcuts': 'menu.keyboardShortcuts', + 'openwork.action.skills': 'common.skill', + 'openwork.action.channels': 'settings.messaging.title', + 'openwork.action.browser': 'link.openInBuiltInBrowser', + 'openwork.action.update': 'menu.checkForUpdates', + 'openwork.browser.address': 'browser.urlPlaceholder', + 'openwork.browser.close': 'common.close', + 'openwork.update.checking': 'settings.about.checkNow', + 'openwork.update.available': 'settings.about.updateReady', +}; + +function legacyMessages(catalog: Record): Messages { + const messages: Messages = {}; + const format = (value: string): MessageValue => + value.includes('{{') + ? (vars) => + value.replace(/\{\{(\w+)\}\}/g, (_, key: string) => + String(vars?.[key] ?? ''), + ) + : value; + for (const key of Object.keys(EN)) { + if (catalog[key]) messages[key] = format(catalog[key]); + } + for (const [key, legacyKey] of Object.entries(LEGACY_MESSAGE_ALIASES)) { + const value = catalog[legacyKey]; + if (value) messages[key] = format(value); + } + return messages; +} + const MESSAGES: Record = { en: EN, + de: legacyMessages(DE), + es: legacyMessages(ES), + hu: legacyMessages(HU), + ja: legacyMessages(JA), + pl: legacyMessages(PL), 'zh-CN': ZH, }; const LANGUAGE_LABELS: Record = { en: 'English [en]', + de: 'Deutsch [de]', + es: 'Español [es]', + hu: 'Magyar [hu]', + ja: '日本語 [ja]', + pl: 'Polski [pl]', 'zh-CN': '中文 [zh-CN]', }; @@ -5574,12 +5754,18 @@ const Context = createContext<{ export function normalizeLanguage( value: string | undefined | null, ): WebShellLanguage { - const normalized = value?.trim().toLowerCase(); - if (!normalized) return 'en'; - if (normalized === 'zh' || normalized === 'zh-cn' || normalized === 'zh_cn') { + return parseLanguage(value) ?? 'en'; +} + +function parseLanguage(value: string | undefined | null) { + const normalized = value?.trim().toLowerCase().replace(/_/g, '-'); + if (!normalized) return undefined; + if (normalized === 'zh' || normalized === 'zh-cn' || normalized === 'zh-hans') return 'zh-CN'; - } - return 'en'; + const base = normalized.split('-')[0]; + return WEB_SHELL_LANGUAGES.find( + (language) => language.toLowerCase() === normalized || language === base, + ); } export function languageSettingToWebShellLanguage( @@ -5593,22 +5779,9 @@ export function languageSettingToWebShellLanguage( typeof navigator !== 'undefined' ? navigator.language : undefined, ); } - if ( - normalized === 'zh' || - normalized === 'zh-cn' || - normalized === 'chinese' || - normalized === '中文' - ) { - return 'zh-CN'; - } - if ( - normalized === 'en' || - normalized === 'en-us' || - normalized === 'english' - ) { - return 'en'; - } - return undefined; + if (normalized === 'chinese' || normalized === '中文') return 'zh-CN'; + if (normalized === 'english') return 'en'; + return parseLanguage(normalized); } export function languageLabel(language: WebShellLanguage): string { diff --git a/packages/web-shell/client/main.tsx b/packages/web-shell/client/main.tsx index aa26c3a44..fdf38d79a 100644 --- a/packages/web-shell/client/main.tsx +++ b/packages/web-shell/client/main.tsx @@ -1,7 +1,10 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; -import { useCallback, useEffect, useState } from 'react'; -import { DaemonWorkspaceProvider } from '@qwen-code/webui/daemon-react-sdk'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + DaemonWorkspaceProvider, + type DaemonStreamingState, +} from '@qwen-code/webui/daemon-react-sdk'; import { ErrorBoundary } from './components/ErrorBoundary'; import { RootErrorFallback } from './components/RootErrorFallback'; import { WorkspaceSessionProvider } from './components/WorkspaceSessionProvider'; @@ -14,6 +17,15 @@ import { import { normalizeLanguage, type WebShellLanguage } from './i18n'; import { WebShellThemeId, type WebShellTheme } from './themeContext'; import { buildSessionPathname, parseSessionId } from './utils/sessionPath'; +import type { WebShellApi } from './App'; +import type { WebShellComposerApi } from './customization'; +import { + notifyOpenWorkTurnComplete, + OpenWorkDesktopLayer, + OpenWorkWelcomeFooter, + openInOpenWorkBrowser, + recordOpenWorkSession, +} from './openwork/OpenWorkDesktopLayer'; import 'katex/dist/katex.min.css'; import './styles/standalone.css'; @@ -21,6 +33,8 @@ const DAEMON_BASE_URL = getDaemonBaseUrl(); const LANGUAGE_STORAGE_KEY = 'qwen-code-web-shell-language'; const THEME_STORAGE_KEY = 'qwen-code-web-shell-theme'; +const OPENWORK_CLIENT_STATE_EVENT = 'openwork:client-state-changed'; +const OPENWORK_HYDRATE_SHELL_EVENT = 'openwork:hydrate-shell-preferences'; function parseTheme(value: string | null): WebShellTheme | undefined { if (value === WebShellThemeId.Dark || value === WebShellThemeId.Light) { @@ -48,6 +62,7 @@ function storeTheme(theme: WebShellTheme): void { } catch { // Ignore storage failures in private browsing or locked-down browsers. } + window.dispatchEvent(new Event(OPENWORK_CLIENT_STATE_EVENT)); } function getInitialTheme(): WebShellTheme { @@ -69,6 +84,7 @@ function storeLanguage(language: WebShellLanguage): void { } catch { // Ignore storage failures in private browsing or locked-down browsers. } + window.dispatchEvent(new Event(OPENWORK_CLIENT_STATE_EVENT)); } function getInitialLanguage(): WebShellLanguage { @@ -112,6 +128,8 @@ function replaceStandaloneSessionUrl( } function StandaloneApp({ daemonToken }: { daemonToken?: string }) { + const shellRef = useRef(null); + const composerRef = useRef(null); const [theme, setTheme] = useState(() => getInitialTheme()); const [language, setLanguage] = useState(() => getInitialLanguage(), @@ -120,7 +138,25 @@ function StandaloneApp({ daemonToken }: { daemonToken?: string }) { const [workspaceId] = useState(() => getWorkspaceIdFromUrl(), ); + const [streamingState, setStreamingState] = + useState('idle'); const baseUrl = DAEMON_BASE_URL || window.location.origin; + useEffect(() => { + const handleHydration = (event: Event) => { + const detail = ( + event as CustomEvent<{ theme?: unknown; language?: unknown }> + ).detail; + const nextTheme = parseTheme( + typeof detail?.theme === 'string' ? detail.theme : null, + ); + if (nextTheme) setTheme(nextTheme); + if (typeof detail?.language === 'string') + setLanguage(normalizeLanguage(detail.language)); + }; + window.addEventListener(OPENWORK_HYDRATE_SHELL_EVENT, handleHydration); + return () => + window.removeEventListener(OPENWORK_HYDRATE_SHELL_EVENT, handleHydration); + }, []); // Keep the theme class and in sync with // the React theme so mobile status bars / overscroll backgrounds stay // consistent when the user toggles or when ?theme= lands via URL. @@ -145,6 +181,7 @@ function StandaloneApp({ daemonToken }: { daemonToken?: string }) { const handleSessionIdChange = useCallback( (nextSessionId?: string, nextWorkspaceId?: string) => { replaceStandaloneSessionUrl(nextSessionId, nextWorkspaceId); + if (nextSessionId) recordOpenWorkSession(nextSessionId, nextWorkspaceId); }, [], ); @@ -166,6 +203,14 @@ function StandaloneApp({ daemonToken }: { daemonToken?: string }) { language, onLanguageChange: handleLanguageChange, onSessionIdChange: handleSessionIdChange, + shellRef, + composerRef, + onSessionChange: (event) => { + if (event.type === 'turn_complete') { + if (document.hidden) notifyOpenWorkTurnComplete(); + } + }, + onStreamingStateChange: setStreamingState, sidebar: true, header: { items: ['title', 'environment', 'rightPanel'], @@ -178,8 +223,18 @@ function StandaloneApp({ daemonToken }: { daemonToken?: string }) { }, compactThinking: true, markdownTableMode: 'advanced', + markdown: { + onOpenLink: openInOpenWorkBrowser, + }, + renderWelcomeFooter: () => ( + + ), }} /> + ); diff --git a/packages/web-shell/client/openwork/OpenWorkDesktopLayer.module.css b/packages/web-shell/client/openwork/OpenWorkDesktopLayer.module.css new file mode 100644 index 000000000..942b3f394 --- /dev/null +++ b/packages/web-shell/client/openwork/OpenWorkDesktopLayer.module.css @@ -0,0 +1,158 @@ +.starters { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px; + width: min(720px, 100%); + margin: 12px auto 0; +} + +.starters button, +.browserToolbar button { + border: 1px solid var(--border); + border-radius: 8px; + background: var(--background); + color: var(--foreground); +} + +.starters button { + padding: 8px 12px; + cursor: pointer; +} + +.appearanceSettings { + display: grid; +} + +.preferenceRow { + display: flex; + min-height: 64px; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 12px 20px; + border-top: 1px solid var(--border); +} + +.preferenceRow select { + min-width: 150px; + min-height: 32px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--background); + color: var(--foreground); +} + +.browserToolbar { + position: fixed; + z-index: 80; + top: 0; + right: 0; + display: grid; + grid-template-columns: 36px 36px 36px minmax(180px, 1fr) 36px; + align-items: center; + gap: 6px; + width: 55vw; + height: 48px; + padding: 6px; + border-bottom: 1px solid var(--border); + border-left: 1px solid var(--border); + background: var(--background); +} + +.browserToolbar form, +.browserToolbar input { + width: 100%; +} + +.browserToolbar input { + box-sizing: border-box; + height: 34px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--secondary); + color: var(--foreground); +} + +.browserToolbar button { + height: 34px; +} + +.paletteBackdrop { + position: fixed; + z-index: 100; + inset: 0; + display: grid; + place-items: start center; + padding-top: min(16vh, 140px); + background: rgb(0 0 0 / 45%); + backdrop-filter: blur(4px); +} + +.palette { + width: min(640px, calc(100vw - 32px)); + overflow: hidden; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--background); + box-shadow: 0 24px 80px rgb(0 0 0 / 45%); +} + +.palette > input { + box-sizing: border-box; + width: 100%; + height: 52px; + padding: 0 16px; + border: 0; + border-bottom: 1px solid var(--border); + background: transparent; + color: var(--foreground); + font: inherit; + font-size: 15px; + outline: none; +} + +.paletteList { + display: flex; + max-height: min(56vh, 440px); + flex-direction: column; + gap: 2px; + overflow: auto; + padding: 8px; +} + +.paletteList button { + min-height: 38px; + padding: 8px 10px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--foreground); + cursor: pointer; + font: inherit; + text-align: left; +} + +.paletteList button:hover, +.paletteList button:focus-visible { + background: var(--secondary); +} + +.paletteMessage { + padding: 8px 16px 12px; + color: var(--muted-foreground); + font-size: 12px; +} + +@media (max-width: 760px) { + .browserToolbar { + width: 100vw; + } + + .preferenceRow { + align-items: flex-start; + flex-direction: column; + gap: 8px; + } +} diff --git a/packages/web-shell/client/openwork/OpenWorkDesktopLayer.test.ts b/packages/web-shell/client/openwork/OpenWorkDesktopLayer.test.ts new file mode 100644 index 000000000..1532542f1 --- /dev/null +++ b/packages/web-shell/client/openwork/OpenWorkDesktopLayer.test.ts @@ -0,0 +1,51 @@ +/** @vitest-environment jsdom */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + drainOpenWorkDeepLinks, + openInOpenWorkBrowser, +} from './OpenWorkDesktopLayer'; + +describe('OpenWork browser links', () => { + afterEach(() => { + delete (window as Window & { __TAURI__?: unknown }).__TAURI__; + }); + + it('only intercepts HTTP links in the Tauri desktop shell', () => { + expect(openInOpenWorkBrowser('https://qwen.ai/docs')).toBe(false); + + (window as Window & { __TAURI__?: unknown }).__TAURI__ = { + core: { invoke: vi.fn() }, + }; + const opened: string[] = []; + window.addEventListener( + 'openwork:open-browser', + (event) => opened.push((event as CustomEvent).detail), + { once: true }, + ); + + expect(openInOpenWorkBrowser('mailto:help@qwen.ai')).toBe(false); + expect(openInOpenWorkBrowser('https://user:secret@qwen.ai')).toBe(false); + expect(openInOpenWorkBrowser('https://qwen.ai/docs')).toBe(true); + expect(opened).toEqual(['https://qwen.ai/docs']); + }); + + it('processes concurrent deep-link drains exactly once', async () => { + const pending = ['openwork://new', 'openwork://session/session-1']; + const take = vi.fn(async () => pending.splice(0)); + const open = vi.fn(); + + await Promise.all([ + drainOpenWorkDeepLinks(take, open), + drainOpenWorkDeepLinks(take, open), + ]); + + expect(open.mock.calls.map(([value]) => value)).toEqual([ + 'openwork://new', + 'openwork://session/session-1', + ]); + + await drainOpenWorkDeepLinks(async () => undefined, open); + expect(open).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/web-shell/client/openwork/OpenWorkDesktopLayer.tsx b/packages/web-shell/client/openwork/OpenWorkDesktopLayer.tsx new file mode 100644 index 000000000..ad465d67a --- /dev/null +++ b/packages/web-shell/client/openwork/OpenWorkDesktopLayer.tsx @@ -0,0 +1,949 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type MutableRefObject, + type ReactNode, +} from 'react'; +import type { WebShellApi } from '../App'; +import type { WebShellComposerApi } from '../customization'; +import { + WEB_SHELL_LANGUAGES, + normalizeLanguage, + useI18n, + type WebShellLanguage, +} from '../i18n'; +import { + readRecentCommands, + recordRecentCommand, + replaceRecentCommands, +} from './command-recents'; +import { + applyOpenWorkPreferences, + notifyOpenWorkClientStateChanged, + OPENWORK_ZOOM_LEVELS, + readOpenWorkPreferences, + subscribeOpenWorkPreferences, + writeOpenWorkPreferences, + type OpenWorkPreferences, + type OpenWorkTextScale, +} from './preferences'; +import { OPENWORK_THEME_IDS, OPENWORK_THEMES } from './themes'; +import styles from './OpenWorkDesktopLayer.module.css'; + +interface TauriEvent { + payload: T; +} + +interface TauriGlobal { + core?: { + invoke(command: string, args?: Record): Promise; + }; + event?: { + listen( + event: string, + handler: (event: TauriEvent) => void, + ): Promise<() => void>; + }; +} + +interface RecentSession { + id: string; + workspaceId?: string; + visitedAt: number; +} + +interface BrowserState { + url: string; + open: boolean; +} + +interface PetInfo { + id: string; + displayName: string; + description: string; +} + +interface OpenWorkClientState { + preferences: OpenWorkPreferences; + chatWidth: '840' | '1100' | 'wide'; + theme?: 'dark' | 'light'; + language?: WebShellLanguage; + recentCommands: string[]; + recentSessions: RecentSession[]; + petEnabled: boolean; + petId: string; +} + +const RECENTS_KEY = 'openwork-recent-sessions'; +const PET_KEY = 'openwork-desktop-pet-enabled'; +const PET_ID_KEY = 'openwork-desktop-pet-id'; +const CHAT_WIDTH_KEY = 'qwen-code-web-shell-chat-width'; +const THEME_KEY = 'qwen-code-web-shell-theme'; +const LANGUAGE_KEY = 'qwen-code-web-shell-language'; +const CLIENT_STATE_EVENT = 'openwork:client-state-changed'; +const HYDRATE_SHELL_EVENT = 'openwork:hydrate-shell-preferences'; + +function tauri(): TauriGlobal | undefined { + return (window as Window & { __TAURI__?: TauriGlobal }).__TAURI__; +} + +export async function invokeOpenWork( + command: string, + args?: Record, +): Promise { + return tauri()?.core?.invoke(command, args); +} + +function readStorage(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +function readRecents(): RecentSession[] { + try { + const value = JSON.parse(localStorage.getItem(RECENTS_KEY) ?? '[]'); + return Array.isArray(value) + ? value + .filter( + (item): item is RecentSession => + typeof item?.id === 'string' && + /^[A-Za-z0-9._-]{1,128}$/.test(item.id) && + typeof item?.visitedAt === 'number' && + (item.workspaceId === undefined || + typeof item.workspaceId === 'string'), + ) + .slice(0, 6) + : []; + } catch { + return []; + } +} + +function writeRecents(recents: readonly RecentSession[]): void { + try { + localStorage.setItem(RECENTS_KEY, JSON.stringify(recents.slice(0, 6))); + } catch { + // Recents are convenience-only. + } + notifyOpenWorkClientStateChanged(); +} + +function setPetEnabled(enabled: boolean): void { + try { + localStorage.setItem(PET_KEY, String(enabled)); + } catch { + // The pet can still be toggled for the current run. + } + notifyOpenWorkClientStateChanged(); +} + +function setPetId(id: string): void { + if (!/^[a-z0-9-]{1,64}$/.test(id)) return; + try { + localStorage.setItem(PET_ID_KEY, id); + } catch { + // The selected pet can still be previewed for the current run. + } + notifyOpenWorkClientStateChanged(); +} + +export function recordOpenWorkSession(id: string, workspaceId?: string): void { + writeRecents([ + { id, workspaceId, visitedAt: Date.now() }, + ...readRecents().filter((item) => item.id !== id), + ]); +} + +function usePreferences(): [ + OpenWorkPreferences, + (patch: Partial) => void, +] { + const [preferences, setPreferences] = useState(readOpenWorkPreferences); + useEffect(() => subscribeOpenWorkPreferences(setPreferences), []); + const update = useCallback( + (patch: Partial) => { + writeOpenWorkPreferences({ ...preferences, ...patch }); + }, + [preferences], + ); + return [preferences, update]; +} + +function resizeBrowserDock(): void { + const x = Math.round(window.innerWidth * 0.45); + void invokeOpenWork('browser_set_bounds', { + x, + y: 48, + width: window.innerWidth - x, + height: window.innerHeight - 48, + }).catch(() => undefined); +} + +function openSession(id: string, workspaceId?: string): void { + window.dispatchEvent( + new CustomEvent('qwen:open-session', { + detail: workspaceId ? { sessionId: id, workspaceId } : id, + }), + ); +} + +function parseDeepLink(value: string): void { + try { + const url = new URL(value); + if ( + url.protocol !== 'openwork:' || + url.username || + url.password || + url.port || + url.search || + url.hash + ) + return; + if (url.hostname === 'session') { + const sessionId = url.pathname.replace(/^\//, ''); + if (/^[A-Za-z0-9._-]{1,128}$/.test(sessionId)) openSession(sessionId); + } else if (url.hostname === 'new' && /^\/?$/.test(url.pathname)) { + window.dispatchEvent(new Event('openwork:new-session')); + } + } catch { + // Ignore invalid external input at the URL boundary. + } +} + +export async function drainOpenWorkDeepLinks( + take: () => Promise, + open: (value: string) => void, +): Promise { + (await take())?.forEach(open); +} + +export function openInOpenWorkBrowser(url: string): boolean { + let safe = false; + try { + const parsed = new URL(url); + safe = + (parsed.protocol === 'http:' || parsed.protocol === 'https:') && + Boolean(parsed.hostname) && + !parsed.username && + !parsed.password; + } catch { + // Fall through to the host's normal link handling. + } + if (!safe || !tauri()?.core?.invoke) return false; + window.dispatchEvent( + new CustomEvent('openwork:open-browser', { detail: url }), + ); + return true; +} + +export function notifyOpenWorkTurnComplete(): void { + void invokeOpenWork('notify_turn_complete', { + title: 'OpenWork', + body: 'Task completed', + }).catch(() => undefined); +} + +export function OpenWorkWelcomeFooter({ + composerRef, +}: { + composerRef: MutableRefObject; +}) { + const { t } = useI18n(); + const starters = [ + t('openwork.starters.review'), + t('openwork.starters.explain'), + t('openwork.starters.fix'), + ]; + return ( +
+ {starters.map((starter) => ( + + ))} +
+ ); +} + +function PreferenceRow({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + return ( + + ); +} + +export function OpenWorkAppearanceSettings() { + const { t } = useI18n(); + const [preferences, update] = usePreferences(); + const [pets, setPets] = useState([]); + const [petId, setSelectedPetId] = useState( + () => readStorage(PET_ID_KEY) ?? 'qwen', + ); + useEffect(() => { + void invokeOpenWork('list_pets') + .then((items) => setPets(items ?? [])) + .catch(() => undefined); + }, []); + return ( +
+ + + + + + + + + + + update({ highContrast: event.target.checked })} + /> + + + update({ reduceMotion: event.target.checked })} + /> + + + update({ keepAwake: event.target.checked })} + /> + + + + +
+ ); +} + +export function OpenWorkDesktopLayer({ + shellRef, + turnActive, +}: { + shellRef: MutableRefObject; + turnActive: boolean; +}) { + const { t } = useI18n(); + const [paletteOpen, setPaletteOpen] = useState(false); + const [query, setQuery] = useState(''); + const [message, setMessage] = useState(''); + const [clientStateReady, setClientStateReady] = useState(false); + const [clientStateRevision, setClientStateRevision] = useState(0); + const [browser, setBrowser] = useState({ + url: 'https://qwenlm.github.io/qwen-code-docs/', + open: false, + }); + const [preferences, updatePreferences] = usePreferences(); + const wakeLockRef = useRef(null); + + const openBrowser = useCallback((url: string) => { + setBrowser({ url, open: true }); + void invokeOpenWork('browser_open', { url }) + .then(resizeBrowserDock) + .catch((error) => { + setBrowser((current) => ({ ...current, open: false })); + setMessage(String(error)); + }); + }, []); + + const navigateBrowser = useCallback( + (action: 'back' | 'forward' | 'reload') => { + void invokeOpenWork('browser_navigate', { action }).catch((error) => + setMessage(String(error)), + ); + }, + [], + ); + + const checkForUpdates = useCallback(async () => { + setPaletteOpen(true); + setMessage(t('openwork.update.checking')); + try { + const version = await invokeOpenWork('check_for_updates'); + const message = + version === undefined + ? t('openwork.update.unavailable') + : version + ? t('openwork.update.available', { version }) + : t('openwork.update.upToDate'); + setMessage(message); + if ( + version && + window.confirm(t('openwork.update.installPrompt', { status: message })) + ) { + setMessage(t('openwork.update.installing')); + await invokeOpenWork('install_update'); + } + } catch (error) { + setMessage(String(error)); + } + }, [t]); + + const createPermanentWorktree = useCallback(async () => { + const name = window.prompt(t('openwork.worktree.prompt'))?.trim(); + if (!name) return; + setPaletteOpen(true); + setMessage(t('openwork.worktree.creating')); + try { + const created = await shellRef.current?.createWorktreeSession(name); + setMessage( + created + ? t('openwork.worktree.created', { branch: `worktree-${name}` }) + : t('openwork.worktree.unavailable'), + ); + } catch (error) { + setMessage(String(error)); + } + }, [shellRef, t]); + + const togglePet = useCallback(async () => { + try { + const open = await invokeOpenWork('toggle_pet'); + if (typeof open === 'boolean') { + setPetEnabled(open); + } + } catch (error) { + setMessage(String(error)); + } + }, []); + + const adjustZoom = useCallback( + (direction: -1 | 0 | 1) => { + const current = OPENWORK_ZOOM_LEVELS.indexOf( + preferences.zoom as (typeof OPENWORK_ZOOM_LEVELS)[number], + ); + const zoom = + direction === 0 + ? 100 + : (OPENWORK_ZOOM_LEVELS[ + Math.min( + OPENWORK_ZOOM_LEVELS.length - 1, + Math.max(0, current + direction), + ) + ] ?? 100); + updatePreferences({ zoom }); + }, + [preferences.zoom, updatePreferences], + ); + + useEffect(() => { + applyOpenWorkPreferences(preferences); + void invokeOpenWork('set_interface_zoom', { + percent: preferences.zoom, + }).catch(() => undefined); + }, [preferences]); + + useEffect(() => { + const onChange = () => { + setClientStateRevision((revision) => revision + 1); + applyOpenWorkPreferences(readOpenWorkPreferences()); + }; + window.addEventListener(CLIENT_STATE_EVENT, onChange); + return () => window.removeEventListener(CLIENT_STATE_EVENT, onChange); + }, []); + + useEffect(() => { + let cancelled = false; + void invokeOpenWork('read_openwork_client_state') + .then((state) => { + if (cancelled) return; + if (state) { + writeOpenWorkPreferences(state.preferences); + replaceRecentCommands([ + ...readRecentCommands(), + ...state.recentCommands, + ]); + const localRecents = readRecents(); + writeRecents([ + ...localRecents, + ...state.recentSessions.filter( + (session) => + !localRecents.some((local) => local.id === session.id), + ), + ]); + setPetEnabled(state.petEnabled); + setPetId(state.petId); + try { + localStorage.setItem(CHAT_WIDTH_KEY, state.chatWidth); + if (state.theme) localStorage.setItem(THEME_KEY, state.theme); + if (state.language) + localStorage.setItem( + LANGUAGE_KEY, + normalizeLanguage(state.language), + ); + } catch { + // The live values still apply when storage is unavailable. + } + notifyOpenWorkClientStateChanged(); + window.dispatchEvent( + new CustomEvent(HYDRATE_SHELL_EVENT, { + detail: { + chatWidth: state.chatWidth, + theme: state.theme, + language: state.language, + }, + }), + ); + } + setClientStateReady(true); + }) + .catch(() => setClientStateReady(true)); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!clientStateReady) return; + const chatWidth = readStorage(CHAT_WIDTH_KEY); + const theme = readStorage(THEME_KEY); + const language = readStorage(LANGUAGE_KEY); + void invokeOpenWork('write_openwork_client_state', { + clientState: { + preferences: readOpenWorkPreferences(), + chatWidth: + chatWidth === '840' || chatWidth === 'wide' ? chatWidth : '1100', + theme: theme === 'dark' || theme === 'light' ? theme : undefined, + language: WEB_SHELL_LANGUAGES.find( + (candidate) => candidate === language, + ), + recentCommands: readRecentCommands(), + recentSessions: readRecents(), + petEnabled: readStorage(PET_KEY) === 'true', + petId: + readStorage(PET_ID_KEY)?.match(/^[a-z0-9-]{1,64}$/)?.[0] ?? 'qwen', + } satisfies OpenWorkClientState, + }).catch(() => undefined); + }, [clientStateReady, clientStateRevision]); + + useEffect(() => { + if (clientStateReady && readStorage(PET_KEY) === 'true') { + void invokeOpenWork('toggle_pet', { visible: true }).catch( + () => undefined, + ); + } + }, [clientStateReady]); + + useEffect(() => { + if (!preferences.keepAwake || !turnActive || !('wakeLock' in navigator)) { + void wakeLockRef.current?.release(); + wakeLockRef.current = null; + return; + } + let active = true; + const request = () => { + if ( + document.hidden || + (wakeLockRef.current && !wakeLockRef.current.released) + ) + return; + void navigator.wakeLock + .request('screen') + .then((lock) => { + if (active) wakeLockRef.current = lock; + else void lock.release(); + }) + .catch(() => undefined); + }; + const handleVisibility = () => { + if (document.hidden) { + void wakeLockRef.current?.release(); + wakeLockRef.current = null; + } else { + request(); + } + }; + request(); + document.addEventListener('visibilitychange', handleVisibility); + return () => { + active = false; + document.removeEventListener('visibilitychange', handleVisibility); + void wakeLockRef.current?.release(); + wakeLockRef.current = null; + }; + }, [preferences.keepAwake, turnActive]); + + useEffect(() => { + const onOpenBrowser = (event: Event) => { + const url = (event as CustomEvent).detail; + if (/^https?:\/\//i.test(url)) openBrowser(url); + }; + const onNewSession = () => void shellRef.current?.createNewSession(); + const onResize = () => browser.open && resizeBrowserDock(); + window.addEventListener('openwork:open-browser', onOpenBrowser); + window.addEventListener('openwork:new-session', onNewSession); + window.addEventListener('resize', onResize); + return () => { + window.removeEventListener('openwork:open-browser', onOpenBrowser); + window.removeEventListener('openwork:new-session', onNewSession); + window.removeEventListener('resize', onResize); + }; + }, [browser.open, openBrowser, shellRef]); + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + const command = event.metaKey || event.ctrlKey; + if (command && event.key.toLowerCase() === 'k') { + event.preventDefault(); + setPaletteOpen(true); + } + if (command && ['+', '=', '-', '0'].includes(event.key)) { + event.preventDefault(); + adjustZoom(event.key === '0' ? 0 : event.key === '-' ? -1 : 1); + } + if (event.key === 'Escape') setPaletteOpen(false); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [adjustZoom]); + + useEffect(() => { + const listen = tauri()?.event?.listen; + if (!listen) return; + let disposed = false; + const unlisteners: Array<() => void> = []; + const drainPendingDeepLinks = async () => { + await drainOpenWorkDeepLinks( + () => invokeOpenWork('take_pending_deep_links'), + (value) => { + if (!disposed) parseDeepLink(value); + }, + ); + }; + void (async () => { + const unlisten = await listen('openwork-deep-link', () => { + void drainPendingDeepLinks().catch(() => undefined); + }); + if (disposed) return unlisten(); + unlisteners.push(unlisten); + await drainPendingDeepLinks(); + })().catch(() => undefined); + void listen('openwork-menu', (event) => { + const action = event.payload; + if (action === 'new') void shellRef.current?.createNewSession(); + if (action === 'settings') shellRef.current?.openSettings(); + if (action === 'worktree') void createPermanentWorktree(); + if (action === 'shortcuts') shellRef.current?.openShortcuts(); + if (action === 'browser') openBrowser(browser.url); + if (action === 'pet') void togglePet(); + if (action === 'update') void checkForUpdates(); + if (action === 'zoom-in') adjustZoom(1); + if (action === 'zoom-out') adjustZoom(-1); + if (action === 'zoom-reset') adjustZoom(0); + }) + .then((unlisten) => { + if (disposed) return unlisten(); + unlisteners.push(unlisten); + }) + .catch(() => undefined); + return () => { + disposed = true; + unlisteners.forEach((unlisten) => unlisten()); + }; + }, [ + adjustZoom, + browser.url, + checkForUpdates, + createPermanentWorktree, + openBrowser, + shellRef, + togglePet, + ]); + + interface PaletteAction { + id: string; + label: string; + keepOpen?: boolean; + run(): void; + } + const actions = useMemo( + () => [ + { + id: 'new', + label: t('openwork.action.new'), + run: () => void shellRef.current?.createNewSession(), + }, + { + id: 'settings', + label: t('openwork.action.settings'), + run: () => shellRef.current?.openSettings(), + }, + { + id: 'shortcuts', + label: t('openwork.action.shortcuts'), + run: () => shellRef.current?.openShortcuts(), + }, + { + id: 'skills', + label: t('openwork.action.skills'), + run: () => shellRef.current?.openSkills(), + }, + { + id: 'channels', + label: t('openwork.action.channels'), + run: () => shellRef.current?.openChannels(), + }, + { + id: 'worktree', + label: t('openwork.action.worktree'), + keepOpen: true, + run: () => void createPermanentWorktree(), + }, + { + id: 'browser', + label: t('openwork.action.browser'), + run: () => openBrowser(browser.url), + }, + { + id: 'pet', + label: t('openwork.action.pet'), + run: () => void togglePet(), + }, + { + id: 'update', + label: t('openwork.action.update'), + keepOpen: true, + run: () => void checkForUpdates(), + }, + { + id: 'proxy', + label: t('openwork.action.proxy'), + keepOpen: true, + run: () => + void invokeOpenWork('proxy_status') + .then((value) => setMessage(value ?? t('openwork.proxy.direct'))) + .catch((error) => setMessage(String(error))), + }, + ], + [ + browser.url, + checkForUpdates, + createPermanentWorktree, + openBrowser, + shellRef, + t, + togglePet, + ], + ); + const normalized = query.trim().toLowerCase(); + const visibleActions = actions.filter((action) => + action.label.toLowerCase().includes(normalized), + ); + const recentActions = normalized + ? [] + : readRecentCommands().flatMap((id) => { + const action = actions.find((candidate) => candidate.id === id); + return action ? [action] : []; + }); + const recents = readRecents().filter((item) => + item.id.toLowerCase().includes(normalized), + ); + + return ( + <> + {browser.open && ( +
+ + + +
{ + event.preventDefault(); + const url = browser.url.includes('://') + ? browser.url + : `https://${browser.url}`; + openBrowser(url); + }} + > + + setBrowser({ open: true, url: event.target.value }) + } + /> +
+ +
+ )} + {paletteOpen && ( +
setPaletteOpen(false)} + > +
event.stopPropagation()} + > + setQuery(event.target.value)} + /> +
+ {recentActions.map((action) => ( + + ))} + {visibleActions.map((action) => ( + + ))} + {recents.map((recent) => ( + + ))} +
+ {message && ( +
+ {message} +
+ )} +
+
+ )} + + ); +} diff --git a/packages/web-shell/client/openwork/command-recents.test.ts b/packages/web-shell/client/openwork/command-recents.test.ts new file mode 100644 index 000000000..28c3e5a46 --- /dev/null +++ b/packages/web-shell/client/openwork/command-recents.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { pushRecentCommand } from './command-recents'; + +describe('OpenWork recent commands', () => { + it('deduplicates, orders, and caps commands', () => { + expect(pushRecentCommand(['a', 'b', 'c'], 'b')).toEqual(['b', 'a', 'c']); + expect(pushRecentCommand(['a', 'b', 'c', 'd', 'e', 'f'], 'g')).toEqual([ + 'g', + 'a', + 'b', + 'c', + 'd', + 'e', + ]); + }); +}); diff --git a/packages/web-shell/client/openwork/command-recents.ts b/packages/web-shell/client/openwork/command-recents.ts new file mode 100644 index 000000000..e68321d1e --- /dev/null +++ b/packages/web-shell/client/openwork/command-recents.ts @@ -0,0 +1,50 @@ +import { notifyOpenWorkClientStateChanged } from './preferences'; + +const STORAGE_KEY = 'openwork-command-palette-recents'; +const MAX_RECENTS = 6; + +export function pushRecentCommand( + commands: readonly string[], + id: string, +): string[] { + return [id, ...commands.filter((command) => command !== id)].slice( + 0, + MAX_RECENTS, + ); +} + +export function readRecentCommands(): string[] { + try { + const value = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]'); + if (!Array.isArray(value)) return []; + const commands: string[] = []; + for (const entry of value) { + if (typeof entry === 'string' && entry && !commands.includes(entry)) { + commands.push(entry); + } + if (commands.length === MAX_RECENTS) break; + } + return commands; + } catch { + return []; + } +} + +export function replaceRecentCommands(commands: readonly string[]): void { + const next = commands + .filter( + (command, index) => + Boolean(command) && commands.indexOf(command) === index, + ) + .slice(0, MAX_RECENTS); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + // Command history is convenience-only. + } + notifyOpenWorkClientStateChanged(); +} + +export function recordRecentCommand(id: string): void { + replaceRecentCommands(pushRecentCommand(readRecentCommands(), id)); +} diff --git a/packages/web-shell/client/openwork/preferences.test.ts b/packages/web-shell/client/openwork/preferences.test.ts new file mode 100644 index 000000000..1c85c195b --- /dev/null +++ b/packages/web-shell/client/openwork/preferences.test.ts @@ -0,0 +1,48 @@ +/** @vitest-environment jsdom */ + +import { beforeEach, describe, expect, it } from 'vitest'; +import { + applyOpenWorkPreferences, + DEFAULT_OPENWORK_PREFERENCES, + readOpenWorkPreferences, + writeOpenWorkPreferences, +} from './preferences'; + +describe('OpenWork desktop preferences', () => { + beforeEach(() => localStorage.clear()); + + it('keeps supported values and resets invalid input', () => { + expect(readOpenWorkPreferences()).toEqual(DEFAULT_OPENWORK_PREFERENCES); + writeOpenWorkPreferences({ + presetTheme: 'nord', + zoom: 125, + textScale: 1.15, + highContrast: true, + reduceMotion: true, + keepAwake: true, + }); + expect(readOpenWorkPreferences()).toEqual({ + presetTheme: 'nord', + zoom: 125, + textScale: 1.15, + highContrast: true, + reduceMotion: true, + keepAwake: true, + }); + localStorage.setItem('openwork-desktop-preferences', '{broken'); + expect(readOpenWorkPreferences()).toEqual(DEFAULT_OPENWORK_PREFERENCES); + }); + + it('applies the selected preset to the Web Shell root', () => { + document.body.innerHTML = '
'; + applyOpenWorkPreferences({ + ...DEFAULT_OPENWORK_PREFERENCES, + presetTheme: 'nord', + }); + expect( + document + .querySelector('[data-web-shell-root]') + ?.style.getPropertyValue('--background'), + ).toBe('#2e3440'); + }); +}); diff --git a/packages/web-shell/client/openwork/preferences.ts b/packages/web-shell/client/openwork/preferences.ts new file mode 100644 index 000000000..abf2d5648 --- /dev/null +++ b/packages/web-shell/client/openwork/preferences.ts @@ -0,0 +1,109 @@ +import { + applyOpenWorkTheme, + isOpenWorkThemeId, + type OpenWorkThemeId, +} from './themes'; + +export type OpenWorkTextScale = 0.9 | 1 | 1.15; +export const OPENWORK_ZOOM_LEVELS = [ + 50, 67, 80, 90, 100, 110, 125, 150, 175, 200, +] as const; + +export interface OpenWorkPreferences { + presetTheme: OpenWorkThemeId; + zoom: number; + textScale: OpenWorkTextScale; + highContrast: boolean; + reduceMotion: boolean; + keepAwake: boolean; +} + +const STORAGE_KEY = 'openwork-desktop-preferences'; +const EVENT_NAME = 'openwork:preferences'; +const CLIENT_STATE_EVENT = 'openwork:client-state-changed'; + +export const DEFAULT_OPENWORK_PREFERENCES: OpenWorkPreferences = { + presetTheme: 'default', + zoom: 100, + textScale: 1, + highContrast: false, + reduceMotion: false, + keepAwake: true, +}; + +export function sanitizeOpenWorkPreferences( + value: unknown, +): OpenWorkPreferences { + const input = value && typeof value === 'object' ? value : {}; + const data = input as Partial; + const zoom = Number(data.zoom); + return { + presetTheme: isOpenWorkThemeId(data.presetTheme) + ? data.presetTheme + : 'default', + zoom: + Number.isFinite(zoom) && + OPENWORK_ZOOM_LEVELS.includes( + zoom as (typeof OPENWORK_ZOOM_LEVELS)[number], + ) + ? zoom + : 100, + textScale: + data.textScale === 0.9 || data.textScale === 1 || data.textScale === 1.15 + ? data.textScale + : 1, + highContrast: data.highContrast === true, + reduceMotion: data.reduceMotion === true, + keepAwake: typeof data.keepAwake === 'boolean' ? data.keepAwake : true, + }; +} + +export function notifyOpenWorkClientStateChanged(): void { + window.dispatchEvent(new Event(CLIENT_STATE_EVENT)); +} + +export function readOpenWorkPreferences(): OpenWorkPreferences { + try { + return sanitizeOpenWorkPreferences( + JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}'), + ); + } catch { + return DEFAULT_OPENWORK_PREFERENCES; + } +} + +export function writeOpenWorkPreferences( + preferences: OpenWorkPreferences, +): void { + const next = sanitizeOpenWorkPreferences(preferences); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + // The live preference still applies when storage is unavailable. + } + window.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: next })); + notifyOpenWorkClientStateChanged(); +} + +export function subscribeOpenWorkPreferences( + listener: (preferences: OpenWorkPreferences) => void, +): () => void { + const handle = (event: Event) => { + listener((event as CustomEvent).detail); + }; + window.addEventListener(EVENT_NAME, handle); + return () => window.removeEventListener(EVENT_NAME, handle); +} + +export function applyOpenWorkPreferences( + preferences: OpenWorkPreferences, +): void { + const root = document.documentElement; + root.style.setProperty( + '--openwork-chat-text-scale', + String(preferences.textScale), + ); + root.toggleAttribute('data-openwork-high-contrast', preferences.highContrast); + root.toggleAttribute('data-openwork-reduce-motion', preferences.reduceMotion); + applyOpenWorkTheme(preferences.presetTheme); +} diff --git a/packages/web-shell/client/openwork/themes.ts b/packages/web-shell/client/openwork/themes.ts new file mode 100644 index 000000000..3ed900ba8 --- /dev/null +++ b/packages/web-shell/client/openwork/themes.ts @@ -0,0 +1,117 @@ +import catppuccin from '../../../desktop/apps/electron/resources/themes/catppuccin.json'; +import defaultTheme from '../../../desktop/apps/electron/resources/themes/default.json'; +import dracula from '../../../desktop/apps/electron/resources/themes/dracula.json'; +import ghostty from '../../../desktop/apps/electron/resources/themes/ghostty.json'; +import github from '../../../desktop/apps/electron/resources/themes/github.json'; +import gruvbox from '../../../desktop/apps/electron/resources/themes/gruvbox.json'; +import haze from '../../../desktop/apps/electron/resources/themes/haze.json'; +import nightOwl from '../../../desktop/apps/electron/resources/themes/night-owl.json'; +import nord from '../../../desktop/apps/electron/resources/themes/nord.json'; +import oneDarkPro from '../../../desktop/apps/electron/resources/themes/one-dark-pro.json'; +import pierre from '../../../desktop/apps/electron/resources/themes/pierre.json'; +import rosePine from '../../../desktop/apps/electron/resources/themes/rose-pine.json'; +import solarized from '../../../desktop/apps/electron/resources/themes/solarized.json'; +import tokyoNight from '../../../desktop/apps/electron/resources/themes/tokyo-night.json'; +import vitesse from '../../../desktop/apps/electron/resources/themes/vitesse.json'; + +interface ThemeColors { + background: string; + foreground: string; + accent: string; + info: string; + success: string; + destructive: string; +} + +interface ThemeDefinition extends ThemeColors { + name: string; + dark?: ThemeColors; +} + +export const OPENWORK_THEMES = { + catppuccin, + default: defaultTheme, + dracula, + ghostty, + github, + gruvbox, + haze, + 'night-owl': nightOwl, + nord, + 'one-dark-pro': oneDarkPro, + pierre, + 'rose-pine': rosePine, + solarized, + 'tokyo-night': tokyoNight, + vitesse, +} satisfies Record; + +export type OpenWorkThemeId = keyof typeof OPENWORK_THEMES; +export const OPENWORK_THEME_IDS = Object.keys( + OPENWORK_THEMES, +) as OpenWorkThemeId[]; + +export function isOpenWorkThemeId(value: unknown): value is OpenWorkThemeId { + return ( + typeof value === 'string' && + Object.prototype.hasOwnProperty.call(OPENWORK_THEMES, value) + ); +} + +export function applyOpenWorkTheme(id: OpenWorkThemeId): void { + const theme: ThemeDefinition = OPENWORK_THEMES[id]; + let dark = true; + try { + dark = localStorage.getItem('qwen-code-web-shell-theme') !== 'light'; + } catch { + // Keep the dark default when storage is unavailable. + } + const colors = dark && theme.dark ? theme.dark : theme; + const secondary = `color-mix(in srgb, ${colors.background} 92%, ${colors.foreground})`; + const border = `color-mix(in srgb, ${colors.foreground} 18%, ${colors.background})`; + const muted = `color-mix(in srgb, ${colors.foreground} 62%, ${colors.background})`; + const variables: Record = { + '--background': colors.background, + '--foreground': colors.foreground, + '--card': colors.background, + '--card-foreground': colors.foreground, + '--popover': colors.background, + '--popover-foreground': colors.foreground, + '--primary': colors.accent, + '--primary-foreground': colors.background, + '--secondary': secondary, + '--secondary-foreground': colors.foreground, + '--muted': secondary, + '--muted-foreground': muted, + '--accent': secondary, + '--accent-foreground': colors.foreground, + '--border': border, + '--ring': colors.accent, + '--sidebar-background': colors.background, + '--sidebar-foreground': colors.foreground, + '--sidebar-primary': colors.accent, + '--sidebar-primary-foreground': colors.background, + '--sidebar-accent': secondary, + '--sidebar-accent-foreground': colors.foreground, + '--sidebar-border': border, + '--sidebar-ring': colors.accent, + '--success-color': colors.success, + '--warning-color': colors.info, + '--error-color': colors.destructive, + '--chat-editor-bg-primary': secondary, + '--chat-editor-bg-tertiary': colors.background, + '--chat-editor-border-color': border, + '--chat-editor-text-primary': colors.foreground, + '--chat-editor-text-secondary': muted, + '--chat-editor-accent-color': colors.accent, + }; + document + .querySelectorAll( + '[data-web-shell-root], [data-web-shell-portal-root]', + ) + .forEach((root) => { + for (const [name, value] of Object.entries(variables)) { + root.style.setProperty(name, value); + } + }); +} diff --git a/packages/web-shell/client/styles/standalone.css b/packages/web-shell/client/styles/standalone.css index cc50f76a4..694e51f61 100644 --- a/packages/web-shell/client/styles/standalone.css +++ b/packages/web-shell/client/styles/standalone.css @@ -40,6 +40,25 @@ html.theme-dark body { color-scheme: dark; } +html[data-openwork-high-contrast] [data-web-shell-root], +html[data-openwork-high-contrast] [data-web-shell-portal-root] { + --border: color-mix(in srgb, currentColor 58%, transparent) !important; + --muted-foreground: color-mix( + in srgb, + currentColor 82%, + transparent + ) !important; +} + +html[data-openwork-reduce-motion] *, +html[data-openwork-reduce-motion] *::before, +html[data-openwork-reduce-motion] *::after { + scroll-behavior: auto !important; + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; +} + /* * Native-app feel (P0): * diff --git a/scripts/build.js b/scripts/build.js index 501b6b6e3..87c2aa3b4 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -55,6 +55,7 @@ const buildOrder = [ 'packages/web-templates', 'packages/channels/base', 'packages/channels/telegram', + 'packages/channels/whatsapp', 'packages/channels/weixin', 'packages/channels/dingtalk', 'packages/channels/wecom', diff --git a/scripts/clean-package-build-artifacts.js b/scripts/clean-package-build-artifacts.js index e29edeaac..284559b3d 100644 --- a/scripts/clean-package-build-artifacts.js +++ b/scripts/clean-package-build-artifacts.js @@ -17,6 +17,7 @@ const CLI_BUILD_PACKAGE_PATHS = [ 'packages/web-templates', 'packages/channels/base', 'packages/channels/telegram', + 'packages/channels/whatsapp', 'packages/channels/weixin', 'packages/channels/dingtalk', 'packages/channels/wecom', diff --git a/scripts/tests/no-ak-integration-ci.test.js b/scripts/tests/no-ak-integration-ci.test.js index 2efbbb9b9..083025c16 100644 --- a/scripts/tests/no-ak-integration-ci.test.js +++ b/scripts/tests/no-ak-integration-ci.test.js @@ -121,6 +121,18 @@ describe('no-AK integration CI wiring', () => { expect(windowsJob).not.toContain(NO_AK_SCRIPT); }); + it('does not run the model-backed integration job in OpenWork', () => { + const workflow = readFileSync( + path.join(ROOT, '.github/workflows/ci.yml'), + 'utf8', + ); + const integrationJob = getWorkflowJob(workflow, 'integration_cli'); + + expect(integrationJob).toContain( + `if: "\${{ !cancelled() && github.repository == 'QwenLM/qwen-code' && github.event_name == 'merge_group' }}"`, + ); + }); + it('checks out the immutable PR head ref instead of the lagging merge ref', () => { const workflow = readFileSync( path.join(ROOT, '.github/workflows/ci.yml'), @@ -219,22 +231,21 @@ describe('no-AK integration CI wiring', () => { expect(guardCalls.integration_cli).not.toContain('if:'); }); - it('pins the Windows gate kill-switch routing, tuning, and Node split', () => { + it('pins the Windows gate repository routing, tuning, and Node split', () => { const workflow = readFileSync( path.join(ROOT, '.github/workflows/ci.yml'), 'utf8', ); const windowsJob = getWorkflowJob(workflow, 'test_windows'); - // The runs-on expression is the Windows gate's escape hatch. Pin the - // whole line so a variable typo, a quoting regression in the nested - // ''true'' escapes, or an && / || regrouping fails here instead of - // surfacing only when the switch is flipped. + // Qwen Code may use its ECS runner, while OpenWork must stay hosted. + // Pin the whole expression so an upstream merge cannot drop the repository + // boundary and leave OpenWork's merge queue waiting for a missing runner. const windowsRunsOn = windowsJob .split('\n') .find((line) => line.startsWith(' runs-on:')); expect(windowsRunsOn).toBe( - ` runs-on: '\${{ vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && fromJSON(''["self-hosted", "Windows", "X64", "ecs-win"]'') || fromJSON(''["windows-2022"]'') }}'`, + ` runs-on: '\${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "Windows", "X64", "ecs-win"]'') || fromJSON(''["windows-2022"]'') }}'`, ); expect(windowsJob.split('\n')).toContain(' timeout-minutes: 60'); @@ -245,8 +256,8 @@ describe('no-AK integration CI wiring', () => { "expected_sha: '${{ github.event.merge_group.head_sha }}'", ); - // The self-hosted-only tuning comes from the composite action shared with - // windows-runner-smoke.yml, and only runs on self-hosted machines. + // The self-hosted-only tuning remains available to Qwen Code and only runs + // on self-hosted machines. const configure = getWorkflowStep( windowsJob, 'Configure self-hosted Windows test environment', @@ -307,63 +318,8 @@ describe('no-AK integration CI wiring', () => { expect(configureAction).toContain( '$gitBash | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append', ); - // The runner-validation smoke must consume the same action, or it - // validates a different configuration than the gate actually uses. - const smokeWorkflow = readFileSync( - path.join(ROOT, '.github/workflows/windows-runner-smoke.yml'), - 'utf8', - ); - expect(smokeWorkflow).toContain( - "uses: './.github/actions/configure-windows-runner'", - ); - expect(smokeWorkflow).toContain('npm run test:ci'); - expect(smokeWorkflow).not.toContain( - 'npm run test:ci --workspaces --if-present --parallel', - ); - // Same ordering as the gate: autocrlf off before the checkout, the `./` - // configure action after it. - const smokeCheckoutIndex = smokeWorkflow.indexOf("name: 'Checkout'"); - const smokeAutocrlfIndex = smokeWorkflow.indexOf( - 'git config --global core.autocrlf false', - ); - expect(smokeCheckoutIndex).toBeGreaterThanOrEqual(0); - expect(smokeAutocrlfIndex).toBeGreaterThanOrEqual(0); - expect(smokeAutocrlfIndex).toBeLessThan(smokeCheckoutIndex); - expect( - smokeWorkflow.indexOf( - "uses: './.github/actions/configure-windows-runner'", - ), - ).toBeGreaterThan(smokeCheckoutIndex); - // The smoke runs behind the same caching egress proxy as the gate, so it - // takes the same stale-checkout guard, pinned to the dispatched head. - expect( - smokeWorkflow.indexOf("uses: './.github/actions/verify-checkout-head'"), - ).toBeGreaterThan(smokeCheckoutIndex); - expect(smokeWorkflow).toContain("expected_sha: '${{ github.sha }}'"); - // The smoke is self-hosted-only, so it must take the same Node path as - // the gate's self-hosted side: the pre-installed Node, never a nodejs.org - // download the ECS egress proxy cannot reach. - expect(smokeWorkflow).toContain( - "uses: './.github/actions/self-hosted-node'", - ); - expect(smokeWorkflow).not.toContain('actions/setup-node'); - // The gate's run steps inherit ci.yml's workflow-level bash default, so - // the smoke must execute these commands under the same shell; a - // powershell pin there would validate a shell the gate never runs. - const smokeJob = getWorkflowJob(smokeWorkflow, 'validate'); - for (const stepName of [ - 'Configure persistent npm cache (self-hosted)', - 'Configure npm for rate limiting', - 'Install dependencies', - 'Run tests and generate reports', - ]) { - expect(getWorkflowStep(smokeJob, stepName)).toContain("shell: 'bash'"); - } - // Both workflows declare the persistent npm cache step; the gate's - // self-hosted path exports NPM_CONFIG_CACHE for every later npm command. - expect( - getWorkflowStep(smokeJob, 'Configure persistent npm cache (self-hosted)'), - ).toContain('NPM_CONFIG_CACHE='); + // The gate's self-hosted path exports NPM_CONFIG_CACHE for every later npm + // command. OpenWork takes the hosted path and skips this step. const gateNpmCache = getWorkflowStep( windowsJob, 'Configure persistent npm cache (self-hosted)', @@ -421,10 +377,10 @@ describe('no-AK integration CI wiring', () => { 'utf8', ); - // The Windows gate and the smoke workflow are pinned above; these three - // call sites must be pinned too, or a revert to the inline pre-PR script - // keeps the suite green and only surfaces when a self-hosted machine - // lacks Node on PATH and runs without the preflight's fail-fast error. + // The Windows gate is pinned above; these three call sites must be pinned + // too, or a revert to the inline pre-PR script keeps the suite green and + // only surfaces when a self-hosted machine lacks Node on PATH and runs + // without the preflight's fail-fast error. const nodeCalls = { test: getWorkflowStep( getWorkflowJob(workflow, 'test'),