mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-07 07:45:54 +00:00
* feat(desktop): add Web Shell Tauri proof of concept * feat(desktop): prepare Web Shell shell for release * fix(desktop): make release dry runs portable * fix(desktop): harden cross-platform release smoke * fix(desktop): stabilize Windows and Linux CI * fix(desktop): scope bootstrap env to daemon * fix(desktop): stabilize packaged app smoke * fix(desktop): diagnose Linux packaged startup * fix(desktop): address release readiness review * fix(desktop): address follow-up review findings * fix(desktop): address runtime review blockers * fix(desktop): gate cookie auth acceptance behind desktop bootstrap flag - Cookie→Bearer translation middleware now only active when desktopShellBootstrap is enabled - Use timing-safe comparison for bootstrap token validation * fix(desktop): replace cookie handshake with URL fragment auth - Navigate the desktop WebView to /#token=<token>; the fragment never reaches the server, so drop the desktop cookie bootstrap middleware, its cookie->bearer translation, and the related serve tests - Skip the deferred-runtime auth gate for pre-auth Web Shell routes (GET|HEAD / and /assets/*): a document navigation cannot carry an Authorization header, so the fast-path window used to answer the first desktop navigation with 401 Unauthorized until a manual reload - Poll /health?deep=true before navigating: deep health stays 503 (reason: bootstrap) until the runtime app that mounts the Web Shell is ready, so readiness can no longer race the deferred window - Run the folder picker off the main thread and only store the runtime after the WebView navigation succeeds - Enable withGlobalTauri plus a bootstrap capability so the bootstrap page can subscribe to desktop lifecycle events - Update smoke-packaged to assert the fragment contract (unauthenticated root navigation 200, no cookies minted, API routes still 401) and sync the release design doc * fix(desktop): fix Linux smoke log path, add runtime .gitkeep, correct README (#8132) * fix(desktop): close release readiness gaps * fix(cli): keep deferred serve auth gate closed when web shell unmounted (#8132) * fix(desktop): address review feedback on auth gates and runtime bundle (#8132) - Cover the method guard in isPreAuthWebShellRequest: assert unauthenticated POST to / and /assets/* is still 401 during the deferred runtime window. - Add unit tests for is_allowed_navigation covering the unset origin, set origin, and bootstrap-after-origin cases. - Drop DEV:'true' from the release bundle step so the esbuild metafile is no longer shipped as dead weight in the desktop runtime. * fix(desktop): address review feedback on runtime extraction and release workflow (#8132) - Extract .zip Node archives with unzip so Linux cross-builds for win32-x64 no longer crash on GNU tar. - Build the Windows signing config with ConvertTo-Json instead of backslash escapes, which PowerShell treats as a parse error. - Fetch the runtime Web Shell without a bearer token so the smoke test exercises the pre-auth navigation path the shell relies on. - Make GitHub release creation idempotent so a re-run after a partial publish uploads assets instead of failing on the existing tag. * fix(desktop): normalize artifact filenames to prevent updater 404s (#8132) GitHub rewrites spaces to dots when release assets are uploaded, but the updater manifest encoded spaces as %20 via encodeURIComponent. This caused every platform's auto-update URL to 404 on published releases. Replace spaces with hyphens in the Collect artifacts step for all platforms so the local filename, the manifest URL, and the published asset name agree by construction. Update test-release.js fixtures to match and assert no artifact name contains a space. * fix(desktop): address review feedback on security, lint, and code quality (#8132) * fix(desktop): address review feedback on smoke test, error UX, and window state (#8132) * fix(desktop): address review feedback on crate build, recovery UX, auth gate, and CI (#8132) * fix(desktop): address review feedback on settings race, version script, and log growth (#8132) * fix(desktop): address review feedback on retry, auth gate, and release clobber (#8132) * fix(desktop): gate commands to bootstrap origin and show native update dialog (#8132) * fix(desktop): use matches! instead of PartialEq on JoinError result (#8132) * fix(desktop): wait for deferred runtime in smoke tests and sync release flags on clobber (#8132) --------- Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com> Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com> Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com> Co-authored-by: Qwen Code Autofix <qwen-code-autofix@users.noreply.github.com>
284 lines
8.5 KiB
JavaScript
Executable file
284 lines
8.5 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
|
|
|
import { execFileSync } 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 { pipeline } from 'node:stream/promises';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const packageDir = path.resolve(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
'..',
|
|
);
|
|
const repoRoot = path.resolve(packageDir, '../..');
|
|
const sourceRoot = process.env.QWEN_CODE_ROOT
|
|
? path.resolve(process.env.QWEN_CODE_ROOT)
|
|
: repoRoot;
|
|
const runtimeDir = path.join(packageDir, 'runtime');
|
|
const packageRoot = path.join(runtimeDir, 'qwen-code');
|
|
const libDir = path.join(packageRoot, 'lib');
|
|
const nodeDir = path.join(packageRoot, 'node');
|
|
const qwenCodeVersion = JSON.parse(
|
|
fs.readFileSync(path.join(sourceRoot, 'package.json'), 'utf8'),
|
|
).version;
|
|
const desktopVersion = JSON.parse(
|
|
fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8'),
|
|
).version;
|
|
const binDir = path.join(packageRoot, 'bin');
|
|
|
|
const target = desktopTarget();
|
|
const skipBuild = process.env.QWEN_DESKTOP_SKIP_BUILD === '1';
|
|
|
|
const npm = process.env.npm_execpath;
|
|
if (!npm) throw new Error('npm_execpath is unavailable. Run through npm.');
|
|
|
|
if (!skipBuild) {
|
|
execFileSync(process.execPath, [npm, 'run', 'build', '--', '--cli-only'], {
|
|
cwd: sourceRoot,
|
|
stdio: 'inherit',
|
|
});
|
|
execFileSync(
|
|
process.execPath,
|
|
[npm, 'run', 'build', '--workspace=packages/webui'],
|
|
{
|
|
cwd: sourceRoot,
|
|
stdio: 'inherit',
|
|
},
|
|
);
|
|
execFileSync(
|
|
process.execPath,
|
|
[npm, 'run', 'build', '--workspace=packages/web-shell'],
|
|
{
|
|
cwd: sourceRoot,
|
|
stdio: 'inherit',
|
|
},
|
|
);
|
|
execFileSync(process.execPath, [npm, 'run', 'bundle'], {
|
|
cwd: sourceRoot,
|
|
stdio: 'inherit',
|
|
});
|
|
execFileSync(process.execPath, [npm, 'run', 'prepare:package'], {
|
|
cwd: sourceRoot,
|
|
stdio: 'inherit',
|
|
});
|
|
}
|
|
|
|
const distDir = path.join(sourceRoot, 'dist');
|
|
for (const required of [
|
|
'cli.js',
|
|
'cli-entry.js',
|
|
'web-shell/index.html',
|
|
'web-shell/assets',
|
|
]) {
|
|
const candidate = path.join(distDir, required);
|
|
if (!fs.existsSync(candidate)) {
|
|
throw new Error(`Missing bundled runtime asset: ${candidate}`);
|
|
}
|
|
}
|
|
|
|
fs.rmSync(runtimeDir, { recursive: true, force: true });
|
|
fs.mkdirSync(libDir, { recursive: true });
|
|
fs.writeFileSync(path.join(packageRoot, '.gitkeep'), '');
|
|
fs.mkdirSync(binDir, { recursive: true });
|
|
copyDirectory(distDir, libDir);
|
|
await installNodeRuntime(nodeDir, target);
|
|
writeLaunchers(target);
|
|
copyRequiredFile(
|
|
path.join(sourceRoot, 'LICENSE'),
|
|
path.join(packageRoot, 'LICENSE'),
|
|
);
|
|
copyRequiredFile(
|
|
path.join(packageDir, 'NOTICE'),
|
|
path.join(packageRoot, 'NOTICE'),
|
|
);
|
|
const nodeLicense = path.join(nodeDir, 'LICENSE');
|
|
if (!fs.existsSync(nodeLicense)) {
|
|
throw new Error(`Bundled Node.js license is missing: ${nodeLicense}`);
|
|
}
|
|
fs.writeFileSync(
|
|
path.join(packageRoot, 'manifest.json'),
|
|
`${JSON.stringify(
|
|
{
|
|
name: '@qwen-code/qwen-code',
|
|
desktopVersion,
|
|
qwenCodeVersion,
|
|
qwenCodeCommit: process.env.QWEN_CODE_COMMIT || gitCommit(sourceRoot),
|
|
target,
|
|
node: `v${process.versions.node}`,
|
|
builtAt: new Date().toISOString(),
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
);
|
|
writeChecksums();
|
|
console.log(
|
|
`Prepared desktop runtime at ${path.relative(repoRoot, packageRoot)}`,
|
|
);
|
|
|
|
async function installNodeRuntime(destination, desktopTarget) {
|
|
const nvmrc = fs.readFileSync(path.join(repoRoot, '.nvmrc'), 'utf8').trim();
|
|
const nodeVersion = process.versions.node;
|
|
if (!nodeVersion.startsWith(`${nvmrc}.`)) {
|
|
throw new Error(
|
|
`Node ${nodeVersion} does not match .nvmrc major version ${nvmrc}. ` +
|
|
'Run the correct Node or update .nvmrc.',
|
|
);
|
|
}
|
|
const archiveName = nodeArchiveName(nodeVersion, desktopTarget);
|
|
const downloadRoot = `https://nodejs.org/dist/v${nodeVersion}`;
|
|
const temporaryRoot = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'qwen-desktop-node-'),
|
|
);
|
|
try {
|
|
const checksumsPath = path.join(temporaryRoot, 'SHASUMS256.txt');
|
|
const archivePath = path.join(temporaryRoot, archiveName);
|
|
await download(`${downloadRoot}/SHASUMS256.txt`, checksumsPath);
|
|
await download(`${downloadRoot}/${archiveName}`, archivePath);
|
|
verifyChecksum(
|
|
archivePath,
|
|
archiveName,
|
|
fs.readFileSync(checksumsPath, 'utf8'),
|
|
);
|
|
extractNodeArchive(archivePath, temporaryRoot);
|
|
const extractedRoot = path.join(
|
|
temporaryRoot,
|
|
archiveName.replace(/\.(tar\.gz|tar\.xz|zip)$/, ''),
|
|
);
|
|
if (!fs.existsSync(extractedRoot)) {
|
|
throw new Error(`Extracted Node runtime is missing: ${extractedRoot}`);
|
|
}
|
|
copyDirectory(extractedRoot, destination);
|
|
} finally {
|
|
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function desktopTarget() {
|
|
const target =
|
|
process.env.QWEN_DESKTOP_TARGET || `${process.platform}-${process.arch}`;
|
|
const aliases = {
|
|
'aarch64-apple-darwin': 'darwin-arm64',
|
|
'x86_64-apple-darwin': 'darwin-x64',
|
|
'aarch64-unknown-linux-gnu': 'linux-arm64',
|
|
'x86_64-unknown-linux-gnu': 'linux-x64',
|
|
'x86_64-pc-windows-msvc': 'win32-x64',
|
|
};
|
|
const resolved = aliases[target] || target;
|
|
if (
|
|
![
|
|
'darwin-arm64',
|
|
'darwin-x64',
|
|
'linux-arm64',
|
|
'linux-x64',
|
|
'win32-x64',
|
|
].includes(resolved)
|
|
) {
|
|
throw new Error(`Unsupported desktop target: ${target}`);
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
function nodeArchiveName(version, desktopTarget) {
|
|
const nodeTarget = desktopTarget === 'win32-x64' ? 'win-x64' : desktopTarget;
|
|
const extension = desktopTarget.startsWith('darwin-')
|
|
? 'tar.gz'
|
|
: desktopTarget.startsWith('linux-')
|
|
? 'tar.xz'
|
|
: 'zip';
|
|
return `node-v${version}-${nodeTarget}.${extension}`;
|
|
}
|
|
|
|
async function download(url, destination) {
|
|
const response = await fetch(url, { signal: AbortSignal.timeout(120_000) });
|
|
if (!response.ok || !response.body) {
|
|
throw new Error(`Failed to download ${url}: HTTP ${response.status}`);
|
|
}
|
|
await pipeline(response.body, fs.createWriteStream(destination));
|
|
}
|
|
|
|
function verifyChecksum(archivePath, archiveName, checksums) {
|
|
const expected = checksums
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim().split(/\s+/))
|
|
.find(([, fileName]) => fileName === archiveName)?.[0];
|
|
if (!expected) {
|
|
throw new Error(`Node checksums do not list ${archiveName}`);
|
|
}
|
|
const actual = crypto
|
|
.createHash('sha256')
|
|
.update(fs.readFileSync(archivePath))
|
|
.digest('hex');
|
|
if (actual !== expected) {
|
|
throw new Error(`Node runtime checksum mismatch for ${archiveName}`);
|
|
}
|
|
}
|
|
|
|
function extractNodeArchive(archivePath, destination) {
|
|
execFileSync('tar', ['-xf', archivePath, '-C', destination]);
|
|
}
|
|
|
|
function writeLaunchers(desktopTarget) {
|
|
if (desktopTarget.startsWith('win32-')) {
|
|
fs.writeFileSync(
|
|
path.join(binDir, 'qwen.cmd'),
|
|
'@echo off\r\nsetlocal\r\nset "ROOT=%~dp0.."\r\n"%ROOT%\\node\\node.exe" "%ROOT%\\lib\\cli-entry.js" %*\r\nexit /b %ERRORLEVEL%\r\n',
|
|
);
|
|
return;
|
|
}
|
|
const launcher =
|
|
'#!/usr/bin/env sh\nset -e\nROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"\nexec "$ROOT/node/bin/node" "$ROOT/lib/cli-entry.js" "$@"\n';
|
|
const launcherPath = path.join(binDir, 'qwen');
|
|
fs.writeFileSync(launcherPath, launcher);
|
|
fs.chmodSync(launcherPath, 0o755);
|
|
}
|
|
|
|
function copyRequiredFile(source, destination) {
|
|
if (!fs.statSync(source, { throwIfNoEntry: false })?.isFile()) {
|
|
throw new Error(`Required desktop runtime file is missing: ${source}`);
|
|
}
|
|
fs.copyFileSync(source, destination);
|
|
}
|
|
|
|
function gitCommit(directory) {
|
|
return execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
cwd: directory,
|
|
encoding: 'utf8',
|
|
}).trim();
|
|
}
|
|
|
|
function writeChecksums() {
|
|
const checksums = {};
|
|
for (const file of runtimeFiles(packageRoot)) {
|
|
const relative = path.relative(packageRoot, file).split(path.sep).join('/');
|
|
if (relative === 'checksums.json') continue;
|
|
checksums[relative] = crypto
|
|
.createHash('sha256')
|
|
.update(fs.readFileSync(file))
|
|
.digest('hex');
|
|
}
|
|
fs.writeFileSync(
|
|
path.join(packageRoot, 'checksums.json'),
|
|
`${JSON.stringify(checksums, null, 2)}\n`,
|
|
);
|
|
}
|
|
|
|
function runtimeFiles(directory) {
|
|
return fs
|
|
.readdirSync(directory, { withFileTypes: true })
|
|
.flatMap((entry) => {
|
|
const absolute = path.join(directory, entry.name);
|
|
return entry.isDirectory() ? runtimeFiles(absolute) : [absolute];
|
|
})
|
|
.sort();
|
|
}
|
|
|
|
function copyDirectory(source, destination) {
|
|
fs.cpSync(source, destination, {
|
|
recursive: true,
|
|
dereference: true,
|
|
filter: (entry) => path.basename(entry) !== '.DS_Store',
|
|
});
|
|
}
|