diff --git a/packages/desktop-shell/bootstrap/bootstrap.js b/packages/desktop-shell/bootstrap/bootstrap.js index c728fb35735..8545ca3bf17 100644 --- a/packages/desktop-shell/bootstrap/bootstrap.js +++ b/packages/desktop-shell/bootstrap/bootstrap.js @@ -13,6 +13,8 @@ const logs = document.querySelector('#logs'); const update = document.querySelector('#update'); const version = document.querySelector('#version'); +let currentWorkspace = ''; +let snapshotOverrideStatus; let updateVersion; function setWorkspace(path) { @@ -21,6 +23,7 @@ function setWorkspace(path) { } function setStatus(kind, heading, message, failure = '') { + document.body.dataset.state = kind; title.textContent = heading; detail.textContent = message; pulse.className = `pulse ${kind === 'starting' ? '' : kind}`; @@ -29,10 +32,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', @@ -40,7 +45,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) { @@ -55,6 +60,7 @@ async function chooseWorkspace() { async function retryRuntime() { if (!invoke) return; + snapshotOverrideStatus = 'starting'; setStatus( 'starting', 'Restarting Qwen Code', @@ -123,7 +129,8 @@ async function initialize() { await Promise.all([ listen('runtime-starting', ({ payload }) => { - setWorkspace(payload); + snapshotOverrideStatus = 'starting'; + currentWorkspace = String(payload || ''); setStatus( 'starting', 'Starting Qwen Code', @@ -131,6 +138,7 @@ async function initialize() { ); }), listen('runtime-failed', ({ payload }) => { + snapshotOverrideStatus = 'failed'; setStatus( 'error', 'Qwen Code could not start', @@ -147,7 +155,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 26ae4310f8d..f7a1de00777 100644 --- a/packages/desktop-shell/bootstrap/index.html +++ b/packages/desktop-shell/bootstrap/index.html @@ -80,16 +80,75 @@ gap: 18px; } .mark { - display: grid; - place-items: center; width: 58px; height: 58px; - border-radius: 18px; - background: linear-gradient(145deg, #7f6dff, #4d34df); - box-shadow: 0 14px 34px rgba(88, 63, 226, 0.38); - font: - 700 26px/1 ui-monospace, - monospace; + 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'] .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; @@ -105,7 +164,7 @@ } .status { display: grid; - grid-template-columns: 22px 1fr; + grid-template-columns: 22px minmax(0, 1fr); gap: 14px; margin-top: 40px; padding: 22px 24px; @@ -232,12 +291,12 @@ } - +
-
Q
-
+ Qwen Code +

Qwen Code

Desktop workspace

diff --git a/packages/desktop-shell/bootstrap/qwen-code-logo.svg b/packages/desktop-shell/bootstrap/qwen-code-logo.svg new file mode 100644 index 00000000000..bd100cf394e --- /dev/null +++ b/packages/desktop-shell/bootstrap/qwen-code-logo.svg @@ -0,0 +1,6 @@ + + + diff --git a/packages/desktop-shell/scripts/test-release.js b/packages/desktop-shell/scripts/test-release.js index 4951f0dd604..7837f00cb46 100755 --- a/packages/desktop-shell/scripts/test-release.js +++ b/packages/desktop-shell/scripts/test-release.js @@ -6,6 +6,7 @@ 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'; import { resolveLogRoot, sliceNewLog } from './resolve-log-root.js'; @@ -39,6 +40,7 @@ const root = fs.mkdtempSync( ); try { testBootstrapBridgeConfiguration(); + await testBootstrapWorkspaceVisibility(); testLegacyApplicationIdentity(); testElectronBridgeWorkflow(); testDesktopReleaseSigningWorkflow(); @@ -53,6 +55,138 @@ try { fs.rmSync(root, { recursive: true, force: true }); } +async function testBootstrapWorkspaceVisibility() { + const bootstrapHtml = fs.readFileSync( + path.join(packageDir, 'bootstrap', 'index.html'), + 'utf8', + ); + assert.match(bootstrapHtml, /class="mark" src="qwen-code-logo\.svg"/); + assert.ok( + fs.existsSync(path.join(packageDir, 'bootstrap', 'qwen-code-logo.svg')), + 'The bootstrap splash mark must ship with the frontendDist directory.', + ); + assert.doesNotMatch(bootstrapHtml, /class="mark">Q setImmediate(resolve)); + assert.equal(element('#title').textContent, 'Restarting Qwen Code'); + assert.equal( + element('#workspace').hidden, + true, + 'A stale bootstrap snapshot must not overwrite a newer recovery action.', + ); + + const failed = await createBootstrapHarness(); + failed.listeners['runtime-failed']({ payload: 'runtime failed' }); + failed.resolveBootstrapState({ + desktopVersion: '0.2.0', + status: 'idle', + workspace: '/Users/example/Documents/Qwen', + error: 'runtime failed', + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(failed.element('#workspace').hidden, false); + assert.equal( + failed.element('#workspace').textContent, + '/Users/example/Documents/Qwen', + ); + + const cancelled = await createBootstrapHarness(); + cancelled.listeners['runtime-starting']({ + payload: '/Users/example/Documents/Qwen', + }); + cancelled.listeners['runtime-failed']({ payload: 'runtime failed' }); + await cancelled.element('#choose').listeners.click(); + assert.equal(cancelled.body.dataset.state, 'idle'); + assert.equal(cancelled.element('#workspace').hidden, false); + assert.equal( + cancelled.element('#workspace').textContent, + '/Users/example/Documents/Qwen', + ); +} + +async function createBootstrapHarness() { + const elements = {}; + const element = (selector) => { + elements[selector] ??= { + addEventListener(event, listener) { + this.listeners ??= {}; + this.listeners[event] = listener; + }, + style: {}, + }; + return elements[selector]; + }; + const listeners = {}; + const commands = []; + const body = { dataset: {} }; + let resolveBootstrapState; + const tauri = { + core: { + invoke: async (command) => { + commands.push(command); + if (command === 'bootstrap_state') { + return new Promise((resolve) => { + resolveBootstrapState = resolve; + }); + } + if (command === 'open_logs') throw new Error('no file handler'); + if (command === 'choose_workspace') return null; + if (command === 'restart_runtime') return new Promise(() => {}); + throw new Error(`Unexpected desktop command: ${command}`); + }, + }, + event: { + listen: async (event, listener) => { + listeners[event] = listener; + }, + }, + }; + vm.runInNewContext( + fs.readFileSync(path.join(packageDir, 'bootstrap', 'bootstrap.js'), 'utf8'), + { + document: { body, querySelector: element }, + window: { __TAURI__: tauri }, + }, + { timeout: 5000 }, + ); + await new Promise((resolve) => setImmediate(resolve)); + return { + body, + commands, + element, + listeners, + resolveBootstrapState: (state) => resolveBootstrapState(state), + }; +} + function testLegacyApplicationIdentity() { const config = JSON.parse( fs.readFileSync( diff --git a/packages/desktop-shell/src-tauri/src/main.rs b/packages/desktop-shell/src-tauri/src/main.rs index 26d43d82b8f..190f2fcb2ce 100755 --- a/packages/desktop-shell/src-tauri/src/main.rs +++ b/packages/desktop-shell/src-tauri/src/main.rs @@ -309,6 +309,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 { @@ -318,14 +322,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, @@ -899,9 +909,9 @@ mod tests { 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, + bootstrap_workspace, 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; @@ -911,6 +921,25 @@ 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(Some((PathBuf::from("/tmp/first-launch"), true)), None), + Some(PathBuf::from("/tmp/first-launch")), + ); + assert_eq!(bootstrap_workspace(None, None), None); + } + #[cfg(target_os = "macos")] #[test] fn fullscreen_hide_lifecycle_state() {