diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d64329d472..03e04e28dc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1069,10 +1069,14 @@ jobs: # catches compile failures (e.g. a moved-value error); fmt/clippy are not run # here because the release pipeline does not gate on them either. desktop_shell: - name: 'Desktop Shell (ubuntu-22.04)' + name: 'Desktop Shell (${{ matrix.os }})' needs: 'classify_pr' if: "${{ !cancelled() && github.event_name != 'push' && needs.classify_pr.outputs.skip_ci != 'true' }}" - runs-on: 'ubuntu-22.04' + strategy: + fail-fast: false + matrix: + os: ['ubuntu-22.04', 'windows-2022'] + runs-on: '${{ matrix.os }}' timeout-minutes: 45 permissions: contents: 'read' @@ -1126,13 +1130,13 @@ jobs: # cargo test links the Tauri/wry webview, so the WebKit/GTK dev headers # must be present (mirrors the Linux build job in desktop-release.yml). - name: 'Install Linux dependencies' - if: "${{ steps.filter.outputs.changed == 'true' }}" + if: "${{ steps.filter.outputs.changed == 'true' && runner.os == 'Linux' }}" run: | sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libatk-bridge2.0-0 at-spi2-core dbus-x11 patchelf libfuse2 xdg-utils - uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 - if: "${{ steps.filter.outputs.changed == 'true' }}" + if: "${{ steps.filter.outputs.changed == 'true' && runner.os == 'Linux' }}" with: node-version: '22.x' @@ -1150,6 +1154,6 @@ jobs: run: 'cargo test --manifest-path src-tauri/Cargo.toml' - name: 'Run desktop release tests' - if: "${{ steps.filter.outputs.changed == 'true' }}" + if: "${{ steps.filter.outputs.changed == 'true' && runner.os == 'Linux' }}" working-directory: 'packages/desktop-shell' run: 'node scripts/test-release.js' diff --git a/packages/desktop-shell/src-tauri/Cargo.lock b/packages/desktop-shell/src-tauri/Cargo.lock index 8ec31e4efd2..75373b6e282 100644 --- a/packages/desktop-shell/src-tauri/Cargo.lock +++ b/packages/desktop-shell/src-tauri/Cargo.lock @@ -2730,6 +2730,7 @@ name = "qwen-code-desktop" version = "0.0.1" dependencies = [ "command-group", + "dunce", "open", "rand", "serde", diff --git a/packages/desktop-shell/src-tauri/Cargo.toml b/packages/desktop-shell/src-tauri/Cargo.toml index 3495c0256de..be904ebcb24 100644 --- a/packages/desktop-shell/src-tauri/Cargo.toml +++ b/packages/desktop-shell/src-tauri/Cargo.toml @@ -12,6 +12,7 @@ tauri-build = { version = "2.4.1", features = [] } [dependencies] command-group = "5.0.1" +dunce = "1.0.5" open = "5.4.0" rand = "0.9.2" serde = { version = "1.0", features = ["derive"] } diff --git a/packages/desktop-shell/src-tauri/src/main.rs b/packages/desktop-shell/src-tauri/src/main.rs index 91bfd575920..bf7af37ed89 100755 --- a/packages/desktop-shell/src-tauri/src/main.rs +++ b/packages/desktop-shell/src-tauri/src/main.rs @@ -4,7 +4,7 @@ mod desktop_state; mod runtime; use desktop_state::{default_window_size, restore_window, SettingsStore}; -use runtime::DesktopRuntime; +use runtime::{resolve_workspace, DesktopRuntime}; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; @@ -307,22 +307,10 @@ fn start_runtime_async(app: AppHandle, workspace: PathBuf) { let _ = app.emit("runtime-starting", workspace.to_string_lossy().into_owned()); tauri::async_runtime::spawn_blocking(move || { let state = app.state::(); - let canonical = match fs::canonicalize(&workspace) { - Ok(path) if path.is_dir() => path, - Ok(path) => { - emit_runtime_failure( - &app, - generation, - format!("Workspace is not a directory: {}", path.display()), - ); - return; - } + let canonical = match resolve_workspace(&workspace) { + Ok(path) => path, Err(error) => { - emit_runtime_failure( - &app, - generation, - format!("Failed to open workspace {}: {error}", workspace.display()), - ); + emit_runtime_failure(&app, generation, error); return; } }; diff --git a/packages/desktop-shell/src-tauri/src/runtime.rs b/packages/desktop-shell/src-tauri/src/runtime.rs index b559df10aa1..1231c1e3738 100644 --- a/packages/desktop-shell/src-tauri/src/runtime.rs +++ b/packages/desktop-shell/src-tauri/src/runtime.rs @@ -3,7 +3,7 @@ use rand::RngCore; use std::ffi::OsString; use std::fs::{self, File}; use std::io::{BufRead, BufReader, Read, Write}; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::{ atomic::{AtomicBool, Ordering}, @@ -44,13 +44,13 @@ impl DesktopRuntime { pub fn start(app: &AppHandle, workspace: &Path, log_path: &Path) -> Result { let id = NEXT_RUNTIME_ID.fetch_add(1, Ordering::Relaxed); let layout = RuntimeLayout::resolve(app)?; - let workspace = resolve_workspace(workspace)?; + // Callers pass a workspace already resolved by resolve_workspace. let token = random_token(); let mut command = Command::new(&layout.node); command .arg(&layout.entry) - .args(runtime_arguments(&workspace)) - .current_dir(&workspace) + .args(runtime_arguments(workspace)) + .current_dir(workspace) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -176,13 +176,28 @@ fn require_file(path: &Path, description: &str) -> Result<(), String> { Err(format!("{description} is missing at {}", path.display())) } -fn resolve_workspace(configured: &Path) -> Result { - let workspace = fs::canonicalize(configured).map_err(|error| { +fn ensure_supported_workspace_path(path: &Path) -> Result<(), String> { + if matches!( + path.components().next(), + Some(Component::Prefix(prefix)) if prefix.kind().is_verbatim() + ) { + return Err(format!( + "Desktop workspace path uses an unsupported Windows extended-length form: {}. Choose a local drive path; network (UNC) shares, paths over 260 characters, and names ending in a dot or space are not supported.", + path.display() + )); + } + Ok(()) +} + +pub(crate) fn resolve_workspace(configured: &Path) -> Result { + // dunce::canonicalize strips the Windows `\\?\` prefix when safe (#8615). + let workspace = dunce::canonicalize(configured).map_err(|error| { format!( "Failed to resolve desktop workspace {}: {error}", configured.display() ) })?; + ensure_supported_workspace_path(&workspace)?; if workspace.is_dir() { Ok(workspace) } else { @@ -459,13 +474,61 @@ fn runtime_arguments(workspace: &Path) -> Vec { #[cfg(test)] mod tests { use super::{ - append_failure_output, parse_listening_url, runtime_arguments, DesktopRuntime, - RuntimeStopped, FAILURE_OUTPUT_LIMIT, + append_failure_output, parse_listening_url, resolve_workspace, runtime_arguments, + DesktopRuntime, RuntimeStopped, FAILURE_OUTPUT_LIMIT, }; use std::path::Path; + #[cfg(windows)] + use std::path::PathBuf; use std::sync::Mutex; use url::Url; + #[test] + fn resolve_workspace_strips_windows_verbatim_prefix() { + let dir = std::env::temp_dir().join(format!("qwen-desktop-ws-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("create temp workspace"); + let resolved = resolve_workspace(&dir).expect("resolve workspace"); + std::fs::remove_dir_all(&dir).expect("cleanup temp workspace"); + let resolved = resolved.to_string_lossy(); + assert!( + !resolved.starts_with("\\\\?\\"), + "workspace keeps the verbatim prefix: {resolved}" + ); + } + + #[cfg(windows)] + #[test] + fn rejects_residual_windows_verbatim_workspace_paths() { + for path in [ + r"\\?\C:\workspace", + r"\\?\UNC\server\share", + r"\\?\GLOBALROOT\Device\HarddiskVolume1", + ] { + let error = super::ensure_supported_workspace_path(Path::new(path)) + .expect_err("reject residual verbatim path"); + assert!(error.contains("unsupported Windows extended-length form")); + } + } + + #[cfg(windows)] + #[test] + fn resolve_workspace_rejects_residual_verbatim_paths() { + use std::os::windows::ffi::OsStrExt; + + let base = + std::env::temp_dir().join(format!("qwen-desktop-long-ws-{}", std::process::id())); + let mut workspace = PathBuf::from(format!(r"\\?\{}", base.display())); + while workspace.as_os_str().encode_wide().count() <= 270 { + workspace.push("long-workspace-component"); + } + std::fs::create_dir_all(&workspace).expect("create long workspace"); + let result = resolve_workspace(&workspace); + std::fs::remove_dir_all(PathBuf::from(format!(r"\\?\{}", base.display()))) + .expect("cleanup long workspace"); + let error = result.expect_err("reject long workspace"); + assert!(error.contains("unsupported Windows extended-length form")); + } + #[test] fn parses_loopback_listening_line() { let url = parse_listening_url(