diff --git a/README.md b/README.md index 5d294f8..f3362ce 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Tauri 2 + Vue 3 + TypeScript + Sass + vue-router + vue-i18n + Arco Design Vue. - **Close to tray** (can be disabled in Settings). - **Launch at login** (Settings toggle, registered for real via the autostart plugin). - **i18n**: Simplified Chinese / English; JSON locale files are discovered, hot-reloaded, and precompiled by `@intlify/unplugin-vue-i18n` through Vite. +- **WSL2 instances (experimental)**: create DSH instances inside a WSL2 distro — Node.js / pnpm / the pinned version are installed inside the distro automatically and the instance's DSH_HOME lives under the distro's `~/.dsh-launcher/homes`. Once created, HOME works like a local instance (profiles CRUD, plugin install/enable/disable/uninstall, skills, MCP, icons, logs, modpack export/import, embedded terminal); files are accessed through the `\\wsl$\` UNC share and a stopped distro is booted automatically. ## Interface @@ -47,6 +48,8 @@ Tauri 2 + Vue 3 + TypeScript + Sass + vue-router + vue-i18n + Arco Design Vue. - **Instance settings → Plugins**: filter by profile, enable/disable plugins, multi-select batch enable/disable (`@deepseek-ai/*` core plugins hidden). - **Settings**: language, close to tray, launch at login, DSH_HOME management. +> For WSL instances, plugin and modpack dependency installs run pnpm inside the distro (pulling Linux platform binaries); reading/writing files from the Windows side through UNC requires the distro to be running, which the launcher starts automatically. + ## Development Prerequisites: Node ≥ 20, pnpm, stable Rust (MSVC toolchain), WebView2. diff --git a/README.zh_CN.md b/README.zh_CN.md index 88eca01..f0839bb 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -38,6 +38,7 @@ Tauri 2 + Vue 3 + TypeScript + Sass + vue-router + vue-i18n + Arco Design Vue。 - **关闭最小化到托盘**(可在设置关闭)。 - **开机自启**(设置页开关,经 autostart 插件真正注册)。 - **i18n**:简体中文 / English,JSON 语言文件由 `@intlify/unplugin-vue-i18n` 经 Vite 发现、热重载并预编译。 +- **WSL2 实例(实验性)**:在 WSL2 发行版内创建 DSH 实例——自动安装发行版内 Node.js / pnpm / 指定版本,实例的 DSH_HOME 位于发行版 `~/.dsh-launcher/homes` 下;创建后可像本地实例一样操作 HOME(Profile 增删改、插件安装/启停/卸载、Skills、MCP、图标、日志、整合包导入导出、内置终端),文件访问经 `\\wsl$\` UNC 共享,发行版未运行时自动启动。 ## 界面 @@ -47,6 +48,8 @@ Tauri 2 + Vue 3 + TypeScript + Sass + vue-router + vue-i18n + Arco Design Vue。 - **实例设置 → 插件页**:筛选 Profile 查看插件、启用/禁用、多选批量启用/禁用(`@deepseek-ai/*` 核心插件不显示)。 - **设置页**:语言、关闭到托盘、开机自启、DSH_HOME 管理。 +> WSL 实例的插件安装/整合包依赖安装会在发行版内执行 pnpm(拉取 Linux 平台二进制);从 Windows 侧经 UNC 读写文件要求发行版处于运行状态,启动器会自动拉起。 + ## 开发 前置:Node ≥ 20、pnpm、Rust stable(含 MSVC 工具链)、WebView2。 diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index bb4b9f3..bc71073 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -402,16 +402,6 @@ pub fn copy_instance( if !cfg.versions.iter().any(|v| v.id == source.version_id) { return Err("DSH 版本不存在".to_string()); } - // WSL 实例(issue #19):复制为新的专属 HOME 需要在发行版内创建文件, - // 暂不支持;共享源 HOME 的纯记录复制不受限。 - if input.new_home - && cfg - .homes - .iter() - .any(|h| h.id == source.home_id && h.wsl.is_some()) - { - return Err("WSL 实例暂不支持复制到新的专属 HOME".to_string()); - } // Resolve the DSH_HOME: reuse the source's, or create a dedicated one. let home_id = if input.new_home { @@ -464,17 +454,18 @@ pub fn copy_instance( }; // A local icon is stored per instance id; copy the file for the clone, // falling back to the launcher default when it cannot be carried over. + // WSL source homes map through \\wsl$\ (issue #19 follow-up). if source.icon.as_deref() == Some("local") { let src_home = cfg .homes .iter() .find(|h| h.id == source.home_id) - .map(|h| h.path.clone()); + .map(crate::wsl::home_fs_path); let dst_home = cfg .homes .iter() .find(|h| h.id == inst.home_id) - .map(|h| h.path.clone()); + .map(crate::wsl::home_fs_path); let copied = match (src_home, dst_home) { (Some(src_home), Some(dst_home)) => { let src_icon = crate::icons::local_icon_path(&src_home, &source.id); @@ -503,7 +494,9 @@ pub fn list_profiles(state: State<'_, AppState>, home_id: String) -> Result "web", process::InstanceKind::Tui => "tui", process::InstanceKind::Other => "other", @@ -588,7 +584,7 @@ pub fn create_profile( .iter() .find(|h| h.id == home_id) .ok_or_else(|| "DSH_HOME 不存在".to_string())?; - home.path.join("profiles") + crate::wsl::home_fs_path(home).join("profiles") }; let temp_dir = profiles_dir.join("__temp__"); @@ -624,7 +620,7 @@ pub fn copy_profile( .iter() .find(|h| h.id == home_id) .ok_or_else(|| "DSH_HOME 不存在".to_string())?; - home.path.join("profiles") + crate::wsl::home_fs_path(home).join("profiles") }; let from = profiles_dir.join(&source); @@ -676,7 +672,7 @@ pub fn rename_profile( .iter() .find(|h| h.id == home_id) .ok_or_else(|| "DSH_HOME 不存在".to_string())?; - home.path.join("profiles") + crate::wsl::home_fs_path(home).join("profiles") }; let from = profiles_dir.join(&old_name); @@ -725,7 +721,7 @@ pub fn delete_profile( .iter() .find(|h| h.id == home_id) .ok_or_else(|| "DSH_HOME 不存在".to_string())?; - home.path.join("profiles") + crate::wsl::home_fs_path(home).join("profiles") }; let target = profiles_dir.join(&name); @@ -890,8 +886,8 @@ fn resolve_instance_paths( .find(|v| v.id == inst.version_id) .ok_or_else(|| "版本不存在".to_string())?; Ok(( - home.path.clone(), - version.dir.clone(), + crate::wsl::home_fs_path(home), + crate::wsl::version_fs_path(version), version.version.clone(), )) } @@ -1271,7 +1267,7 @@ fn read_tail(path: &std::path::Path, n: usize) -> Vec { /// Opens the DSH_HOME directory of one instance in the file manager. #[tauri::command] -pub fn open_instance_directory( +pub async fn open_instance_directory( state: State<'_, AppState>, instance_id: String, ) -> Result { @@ -1290,7 +1286,9 @@ pub fn open_instance_directory( (h.path.clone(), h.wsl.clone()) }; // WSL home (issue #19): open the distro's \\wsl$\ UNC share instead. + // Ensure the distro is running first so the share is reachable. if let Some(distro) = wsl { + crate::wsl::ensure_distro_running(&state, &distro).await?; let unc = crate::wsl::unc_path(&distro, &home.to_string_lossy()); crate::log_info!( "在文件管理器中打开实例 {instance_id} 的 WSL DSH_HOME {}", @@ -1354,7 +1352,7 @@ pub async fn create_launch_shortcut( .homes .iter() .find(|h| h.id == inst.home_id) - .map(|h| h.path.clone()) + .map(crate::wsl::home_fs_path) .ok_or_else(|| "DSH_HOME 不存在".to_string())?; (inst.name.clone(), inst.icon.clone(), home) }; diff --git a/src-tauri/src/icons.rs b/src-tauri/src/icons.rs index 04a6dd2..f140468 100644 --- a/src-tauri/src/icons.rs +++ b/src-tauri/src/icons.rs @@ -102,7 +102,7 @@ fn instance_home(state: &AppState, instance_id: &str) -> Result cfg.homes .iter() .find(|h| h.id == inst.home_id) - .map(|h| h.path.clone()) + .map(crate::wsl::home_fs_path) .ok_or_else(|| "DSH_HOME 不存在".to_string()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 624c9ca..67e49d3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -43,6 +43,9 @@ pub struct AppState { /// from `terminals` (the settings-page shell): different lifecycle, /// different status wiring. pub tui_sessions: tokio::sync::Mutex>, + /// WSL distros recently verified running (`wsl.rs::ensure_distro_running` + /// TTL cache): distro → last successful boot/probe timestamp. + pub distro_ready: tokio::sync::Mutex>, } /// Extracts a `dsh-launcher://…` deep link from process arguments (Windows @@ -151,6 +154,7 @@ pub fn run() { last_focused_instance: StdMutex::new(None), terminals: tokio::sync::Mutex::new(HashMap::new()), tui_sessions: tokio::sync::Mutex::new(HashMap::new()), + distro_ready: tokio::sync::Mutex::new(HashMap::new()), }); // System tray with dynamic menu. diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index ba80891..741a338 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -116,7 +116,7 @@ fn home_path_of(state: &AppState, home_id: &str) -> Result { .homes .iter() .find(|h| h.id == home_id) - .map(|h| h.path.clone()) + .map(crate::wsl::home_fs_path) .ok_or_else(|| "DSH_HOME 不存在".to_string()) } diff --git a/src-tauri/src/modpack.rs b/src-tauri/src/modpack.rs index aef73e7..08eea1f 100644 --- a/src-tauri/src/modpack.rs +++ b/src-tauri/src/modpack.rs @@ -224,7 +224,7 @@ fn home_path_of(state: &AppState, home_id: &str) -> Result { .homes .iter() .find(|h| h.id == home_id) - .map(|h| h.path.clone()) + .map(crate::wsl::home_fs_path) .ok_or_else(|| "DSH_HOME 不存在".to_string()) } @@ -1538,6 +1538,83 @@ async fn pnpm_install_profile( Ok(()) } +/// WSL variant of `pnpm_install_profile` (issue #19 follow-up): the profile +/// files live inside the distro (written through the `\\wsl$\` UNC share by +/// the caller), so pnpm must run inside the distro with the distro's managed +/// node/pnpm and the distro-side store — Windows pnpm would build a tree the +/// Linux runtime cannot use. +async fn pnpm_install_profile_wsl( + app: &AppHandle, + state: &State<'_, AppState>, + task_id: &str, + distro: &str, + dest_linux: &Path, + has_lock: bool, +) -> Result<(), String> { + crate::wsl::ensure_distro_running(state, distro).await?; + let root = crate::wsl::WslRoot::resolve(distro).await?; + crate::wsl::ensure_node(app, state, task_id, distro, &root).await?; + let pnpm = crate::wsl::ensure_pnpm(app, state, task_id, distro, &root).await?; + + let attempts: &[&[&str]] = if has_lock { + &[&["--frozen-lockfile"], &["--no-frozen-lockfile"]] + } else { + &[&["--no-frozen-lockfile"]] + }; + let mut last_err = String::new(); + for (i, extra) in attempts.iter().enumerate() { + if i > 0 { + crate::tasks::push_task_log_pub( + app, + state, + task_id, + "锁定文件与依赖清单不完全匹配,改用普通安装(锁定版本仍会被优先采用)…", + ) + .await; + } + let mut script = format!( + "export PATH={0}:\"$PATH\"; export CI=true; cd {1} && {2} install", + crate::wsl::sh_quote(&root.node_bin_dir()), + crate::wsl::sh_quote(&dest_linux.to_string_lossy()), + crate::wsl::sh_quote(&pnpm), + ); + for e in extra.iter() { + script.push(' '); + script.push_str(e); + } + script.push_str(" --store-dir "); + script.push_str(&crate::wsl::sh_quote(&root.pnpm_store())); + script.push_str(" --loglevel=http --fetch-timeout 300000 --fetch-retries 5 --fetch-retry-maxtimeout 120000 --network-concurrency 4"); + if let Ok(registry) = std::env::var("DSH_NPM_REGISTRY") { + let registry = registry.trim().to_string(); + if !registry.is_empty() { + script.push_str(" --registry "); + script.push_str(&crate::wsl::sh_quote(®istry)); + } + } + let cmd = crate::wsl::wsl_bash(distro, &script); + match crate::tasks::run_streamed_command( + app, + state, + task_id, + cmd, + "pnpm install(整合包,WSL)", + ) + .await + { + Ok(()) => { + last_err.clear(); + break; + } + Err(e) => last_err = e, + } + } + if !last_err.is_empty() { + return Err(last_err); + } + Ok(()) +} + /// Converts manifest dependency coordinates into package.json deps: github /// coords become install specs keyed by a derived package name, everything /// else passes through. @@ -1805,7 +1882,7 @@ async fn do_import_modpack( None => None, }; - let (version_record, home, target_instance_id) = match existing_target { + let (version_record, home_linux, distro, target_instance_id) = match existing_target { Some((inst, home, ver)) => { crate::tasks::push_task_log_pub( app, @@ -1814,7 +1891,7 @@ async fn do_import_modpack( &format!("导入到现有实例「{}」(DSH {})", inst.name, ver.version), ) .await; - (ver, home.path, Some(inst.id)) + (ver, home.path.clone(), home.wsl.clone(), Some(inst.id)) } None => { // Fresh instance: resolve the pinned version (exact), falling @@ -1883,12 +1960,22 @@ async fn do_import_modpack( &version_record, ) .await?; - (version_record, home.path, None) + (version_record, home.path.clone(), None, None) } }; // 4. Materialize the pack profile directory inside the HOME. - let dest = crate::plugins::profile_dir_pub(&home, &profile_name); + // WSL (issue #19 follow-up): ensure the distro is running first so UNC + // file writes work, then go through the \\wsl$\ share; pnpm install runs + // inside the distro on the Linux path. + if let Some(d) = &distro { + crate::wsl::ensure_distro_running(state, d).await?; + } + let fs_home = match &distro { + Some(d) => crate::wsl::unc_path(d, &home_linux.to_string_lossy()), + None => home_linux.clone(), + }; + let dest = crate::plugins::profile_dir_pub(&fs_home, &profile_name); if dest.exists() { if !input.force { return Err(format!("Profile「{profile_name}」已存在,勾选覆盖后重试")); @@ -1923,7 +2010,21 @@ async fn do_import_modpack( } // 4. Install dependencies (frozen when the pack ships a lockfile). - if let Err(e) = pnpm_install_profile(app, state, task_id, &dest, has_lock).await { + let install_result = match &distro { + Some(d) => { + pnpm_install_profile_wsl( + app, + state, + task_id, + d, + &home_linux.join("profiles").join(&profile_name), + has_lock, + ) + .await + } + None => pnpm_install_profile(app, state, task_id, &dest, has_lock).await, + }; + if let Err(e) = install_result { let _ = std::fs::remove_dir_all(&dest); return Err(e); } @@ -1946,7 +2047,7 @@ async fn do_import_modpack( // copied onto the $DSH_HOME root with the same overwrite semantics. let home_dir = unpacked.join("home"); if home_dir.is_dir() { - let count = copy_tree(&home_dir, &home, None)?; + let count = copy_tree(&home_dir, &fs_home, None)?; if count > 0 { crate::tasks::push_task_log_pub( app, @@ -1986,7 +2087,8 @@ async fn do_import_modpack( id: new_id("i"), name: instance_name.clone(), version_id: version_record.id.clone(), - home_id: home_id_of_path(&cfg, &home).ok_or_else(|| "DSH_HOME 记录缺失".to_string())?, + home_id: home_id_of_path(&cfg, &home_linux) + .ok_or_else(|| "DSH_HOME 记录缺失".to_string())?, env_overrides: Default::default(), default_profile: Some(profile_name.clone()), last_profile: None, @@ -2002,7 +2104,7 @@ async fn do_import_modpack( // local icon; an http(s) manifest icon stays a remote reference. An // existing instance keeps an icon it already has. let imported_icon: Option = if unpacked.join("icon.png").exists() { - let dest = crate::icons::local_icon_path(&home, &instance_id); + let dest = crate::icons::local_icon_path(&fs_home, &instance_id); if let Some(parent) = dest.parent() { std::fs::create_dir_all(parent).ok(); } diff --git a/src-tauri/src/plugins.rs b/src-tauri/src/plugins.rs index 1730e25..83ad453 100644 --- a/src-tauri/src/plugins.rs +++ b/src-tauri/src/plugins.rs @@ -971,7 +971,12 @@ fn disabled_ids(raw: &str) -> Vec { out } -/// Resolve an instance to (home_path, version_dir). +/// Resolve an instance to (home_path, version_dir) as Windows-fs-accessible +/// paths: local homes pass through, WSL homes map to their `\\wsl$\` UNC +/// share (distro must be running — callers doing UNC work should ensure it +/// via `wsl::ensure_distro_running` first). In-distro commands (plugin +/// install/remove, which run pnpm inside the distro) need the *Linux* paths +/// instead: use `resolve_instance_linux` for those. pub(crate) fn resolve_instance( state: &State<'_, AppState>, instance_id: &str, @@ -992,7 +997,37 @@ pub(crate) fn resolve_instance( .iter() .find(|v| v.id == inst.version_id) .ok_or_else(|| "版本不存在".to_string())?; - Ok((home.path.clone(), version.dir.clone())) + Ok(( + crate::wsl::home_fs_path(home), + crate::wsl::version_fs_path(version), + )) +} + +/// Resolve an instance to its *Linux* (in-distro) paths plus the distro +/// name, for commands that must execute inside the WSL distro (pnpm installs +/// pull Linux platform binaries). The distro is `None` for local Windows +/// instances, where callers keep using `resolve_instance`. +pub(crate) fn resolve_instance_linux( + state: &State<'_, AppState>, + instance_id: &str, +) -> Result<(std::path::PathBuf, std::path::PathBuf, Option), String> { + let cfg = state.config.lock().unwrap(); + let inst: &DshInstance = cfg + .instances + .iter() + .find(|i| i.id == instance_id) + .ok_or_else(|| "实例不存在".to_string())?; + let home = cfg + .homes + .iter() + .find(|h| h.id == inst.home_id) + .ok_or_else(|| "DSH_HOME 不存在".to_string())?; + let version = cfg + .versions + .iter() + .find(|v| v.id == inst.version_id) + .ok_or_else(|| "版本不存在".to_string())?; + Ok((home.path.clone(), version.dir.clone(), home.wsl.clone())) } // --------------------------------------------------------------------------- @@ -1175,8 +1210,15 @@ pub async fn uninstall_plugin( state: State<'_, AppState>, input: UninstallPluginInput, ) -> Result<(), String> { - let (home_path, version_dir) = resolve_instance(&state, &input.instance_id)?; - let dir = profile_dir(&home_path, &input.profile); + let (home_path, version_dir, wsl) = resolve_instance_linux(&state, &input.instance_id)?; + let fs_home = match &wsl { + Some(distro) => { + crate::wsl::ensure_distro_running(&state, distro).await?; + crate::wsl::unc_path(distro, &home_path.to_string_lossy()) + } + None => home_path.clone(), + }; + let dir = profile_dir(&fs_home, &input.profile); if !dir.exists() { return Err(format!("Profile「{}」不存在", input.profile)); } @@ -1198,6 +1240,7 @@ pub async fn uninstall_plugin( version_dir: &version_dir, home_path: &home_path, profile: &input.profile, + wsl: wsl.as_deref(), }, &PluginCliOp { subcommand: "remove", @@ -1663,8 +1706,29 @@ pub async fn start_install_plugin_file_task( if !std::path::Path::new(&path).is_file() { return Err(format!("插件包不存在: {path}")); } - // pnpm treats Windows paths more reliably with forward slashes. - let spec_path = path.replace('\\', "/"); + // WSL (issue #19 follow-up): pnpm inside the distro cannot read a Windows + // path. Copy the tarball into the distro's managed temp dir through the + // \\wsl$\ UNC share, then reference the Linux path in the spec. + let spec_path = if let Ok((_, _, Some(distro))) = resolve_instance_linux(&state, &instance_id) { + crate::wsl::ensure_distro_running(&state, &distro).await?; + let root = crate::wsl::WslRoot::resolve(&distro).await?; + let tmp_dir = format!("{}/tmp", root.0); + crate::wsl::wsl_output(&distro, &["mkdir".into(), "-p".into(), tmp_dir.clone()]).await?; + let base = std::path::Path::new(&path) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "plugin.tgz".to_string()); + // A unique suffix avoids colliding with a concurrent install of a + // same-named tarball; the file stays in the launcher's managed tmp + // scratch dir inside the distro (recreated on demand). + let linux_target = format!("{tmp_dir}/{0}-{1}", base, uuid::Uuid::new_v4()); + let unc_target = crate::wsl::unc_path(&distro, &linux_target); + std::fs::copy(&path, &unc_target).map_err(|e| format!("复制插件包到 WSL 失败: {e}"))?; + linux_target + } else { + // pnpm treats Windows paths more reliably with forward slashes. + path.replace('\\', "/") + }; let input = InstallPluginInput { plugin_id: format!("tgz:{spec_path}"), version: "local".to_string(), @@ -1725,8 +1789,18 @@ async fn do_install_plugin( task_id: &str, input: &InstallPluginInput, ) -> Result<(), String> { - let (home_path, version_dir) = resolve_instance(state, &input.instance_id)?; - let dir = profile_dir(&home_path, &input.profile); + let (home_path, version_dir, wsl) = resolve_instance_linux(state, &input.instance_id)?; + // Post-install reads (package.json, bundles, cordis rows) run through the + // Windows-visible path: \\wsl$\ for WSL instances, the path itself for + // local ones. + let fs_home = match &wsl { + Some(distro) => { + crate::wsl::ensure_distro_running(state, distro).await?; + crate::wsl::unc_path(distro, &home_path.to_string_lossy()) + } + None => home_path.clone(), + }; + let dir = profile_dir(&fs_home, &input.profile); // Spec: tarball ids (`tgz:`) install the URL/path verbatim; npm packages // use @; git-hosted (github:) plugins install the repo at a @@ -1801,6 +1875,7 @@ async fn do_install_plugin( version_dir: &version_dir, home_path: &home_path, profile: &input.profile, + wsl: wsl.as_deref(), }, &PluginCliOp { subcommand: "add", @@ -1998,11 +2073,17 @@ async fn task_cancelled(state: &State<'_, AppState>, task_id: &str) -> bool { .unwrap_or(false) } -/// Which instance/profile a `dsh plugin` invocation targets. +/// Which instance/profile a `dsh plugin` invocation targets. Paths are the +/// instance's *Linux* (in-distro) paths; for local Windows instances they +/// equal the Windows paths. `wsl` carries the distro when the instance runs +/// inside one (issue #19 follow-up): the CLI then executes through wsl.exe +/// with the distro's managed node/pnpm, and the pre/post file preparation +/// runs against the `\\wsl$\` UNC share. struct PluginCliTarget<'a> { version_dir: &'a std::path::Path, home_path: &'a std::path::Path, profile: &'a str, + wsl: Option<&'a str>, } /// What the invocation does: a pnpm subcommand (`add` / `remove`), its @@ -2033,13 +2114,43 @@ async fn run_dsh_plugin( ) -> Result<(), String> { let (version_dir, home_path, profile) = (target.version_dir, target.home_path, target.profile); let (subcommand, spec, loglevel) = (op.subcommand, op.spec, op.loglevel); - let dir = profile_dir(home_path, profile); + + // WSL (issue #19 follow-up): file preparation runs against the distro's + // \\wsl$\ UNC share, the CLI runs inside the distro with the distro's + // managed node/pnpm, and pnpm uses the distro-side store. + let fs_home = match target.wsl { + Some(distro) => { + crate::wsl::ensure_distro_running(state, distro).await?; + crate::wsl::unc_path(distro, &home_path.to_string_lossy()) + } + None => home_path.to_path_buf(), + }; + let dir = profile_dir(&fs_home, profile); std::fs::create_dir_all(&dir).map_err(|e| format!("创建 profile 目录失败: {e}"))?; ensure_build_scripts_allowed(&dir)?; // Never let a plugin's peers pull a second copy of a core package in. ensure_profile_npmrc(&dir)?; - let pnpm_prog = ensure_pnpm_for_plugins(app, state, task_id).await?; + // Resolve the CLI's node + pnpm: local uses the launcher-managed Windows + // pnpm; WSL ensures the distro's node/pnpm are installed first. + let (node_exe, pnpm_prog, linux_store) = match target.wsl { + Some(distro) => { + let root = crate::wsl::WslRoot::resolve(distro).await?; + crate::wsl::ensure_node(app, state, task_id, distro, &root).await?; + let pnpm = crate::wsl::ensure_pnpm(app, state, task_id, distro, &root).await?; + let node = std::path::PathBuf::from(root.node_exe()); + (node, std::path::PathBuf::from(pnpm), root.pnpm_store()) + } + None => ( + std::path::PathBuf::from(crate::process::node()), + ensure_pnpm_for_plugins(app, state, task_id).await?, + state + .data_dir + .join(".pnpm-store") + .to_string_lossy() + .to_string(), + ), + }; let what = format!("dsh plugin {subcommand}"); // A node_modules tree linked from a *different* pnpm store (e.g. the @@ -2048,9 +2159,8 @@ async fn run_dsh_plugin( // ERR_PNPM_UNEXPECTED_STORE. Detect the mismatch up front — with // `--loglevel=warn` (removals) pnpm prints nothing the log matcher could // catch, so the log-based retry below would never fire — and relink. - let store_dir = state.data_dir.join(".pnpm-store"); if let Some(linked) = linked_store_dir(&dir) { - if !store_paths_match(&linked, &store_dir.to_string_lossy()) { + if !store_paths_match(&linked, &linux_store) { crate::tasks::push_task_log_pub( app, state, @@ -2060,14 +2170,23 @@ async fn run_dsh_plugin( ), ) .await; - relink_profile_store(app, state, task_id, target, &pnpm_prog).await?; + relink_profile_store(app, state, task_id, target, &node_exe, &pnpm_prog).await?; } } for attempt in 1..=2 { let mut args: Vec = vec![subcommand.to_string(), spec.to_string()]; - args.extend(forwarded_pnpm_flags(state, loglevel, subcommand)); - let cmd = dsh_plugin_command(version_dir, home_path, profile, &args, &pnpm_prog)?; + args.extend(forwarded_pnpm_flags(loglevel, subcommand, &linux_store)); + let cmd = dsh_plugin_command( + target.wsl, + version_dir, + home_path, + profile, + &args, + &node_exe, + &pnpm_prog, + ) + .await?; match crate::tasks::run_streamed_command(app, state, task_id, cmd, &what).await { Ok(()) => return Ok(()), @@ -2089,7 +2208,7 @@ async fn run_dsh_plugin( "pnpm 报告 store 位置不一致,正在重新链接 node_modules 后重试…", ) .await; - relink_profile_store(app, state, task_id, target, &pnpm_prog).await?; + relink_profile_store(app, state, task_id, target, &node_exe, &pnpm_prog).await?; } Err(e) => return Err(e), } @@ -2157,17 +2276,29 @@ async fn relink_profile_store( state: &State<'_, AppState>, task_id: &str, target: &PluginCliTarget<'_>, + node_exe: &std::path::Path, pnpm_prog: &std::path::Path, ) -> Result<(), String> { + let linux_store = match target.wsl { + Some(distro) => crate::wsl::WslRoot::resolve(distro).await?.pnpm_store(), + None => state + .data_dir + .join(".pnpm-store") + .to_string_lossy() + .to_string(), + }; let mut args: Vec = vec!["install".to_string()]; - args.extend(forwarded_pnpm_flags(state, "warn", "install")); + args.extend(forwarded_pnpm_flags("warn", "install", &linux_store)); let cmd = dsh_plugin_command( + target.wsl, target.version_dir, target.home_path, target.profile, &args, + node_exe, pnpm_prog, - )?; + ) + .await?; crate::tasks::run_streamed_command( app, state, @@ -2209,21 +2340,59 @@ fn task_log_mentions_ignored_builds(state: &State<'_, AppState>, task_id: &str) /// The CLI resolves pnpm from PATH, so the launcher's pinned pnpm /// (`REQUIRED_PNPM_MAJOR`) is prepended to PATH: the pin then also applies /// inside the CLI's own pnpm invocation. -fn dsh_plugin_command( +async fn dsh_plugin_command( + distro: Option<&str>, version_dir: &std::path::Path, home_path: &std::path::Path, profile: &str, pnpm_args: &[String], + node_exe: &std::path::Path, pnpm_prog: &std::path::Path, ) -> Result { let bin = crate::process::version_bin(version_dir); - if !crate::process::version_bin_ready(version_dir) { + let bin_ready = match distro { + // WSL: the file lives inside the distro; probe through wsl.exe. + Some(d) => crate::wsl::wsl_test(d, "-s", &bin.to_string_lossy()).await, + None => crate::process::version_bin_ready(version_dir), + }; + if !bin_ready { return Err(format!( "版本安装不完整(缺少 {}),请重新安装该 DSH 版本", bin.display() )); } + // WSL instances run the CLI through wsl.exe with the distro's managed + // node/pnpm and Linux paths (issue #19 follow-up). + if let Some(distro) = distro { + let node_dir = node_exe + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + let pnpm_dir = pnpm_prog + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + // {0}=node_dir {1}=pnpm_dir {2}=home {3}=node {4}=bin {5}=profile + let mut script = format!( + "export PATH={0}:{1}:\"$PATH\"; export CI=true; DSH_HOME={2} {3} {4} plugin --profile {5}", + crate::wsl::sh_quote(&node_dir), + crate::wsl::sh_quote(&pnpm_dir), + crate::wsl::sh_quote(&home_path.to_string_lossy()), + crate::wsl::sh_quote(&node_exe.to_string_lossy()), + crate::wsl::sh_quote(&bin.to_string_lossy()), + crate::wsl::sh_quote(profile), + ); + for a in pnpm_args { + script.push(' '); + script.push_str(&crate::wsl::sh_quote(a)); + } + let mut cmd = crate::wsl::wsl_bash(distro, &script); + cmd.stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + return Ok(cmd); + } + let mut cmd = tokio::process::Command::new(crate::process::node()); crate::process::hide_console(&mut cmd); cmd.arg(&bin) @@ -2269,15 +2438,10 @@ fn dsh_plugin_command( /// The fetch/network flags only exist on download commands (`add` / /// `install`): `pnpm remove` rejects them outright ("Unknown options: /// 'fetch-timeout', …") and would fail before touching anything. -fn forwarded_pnpm_flags( - state: &State<'_, AppState>, - loglevel: &str, - subcommand: &str, -) -> Vec { - let store_dir = state.data_dir.join(".pnpm-store"); +fn forwarded_pnpm_flags(loglevel: &str, subcommand: &str, store_dir: &str) -> Vec { let mut args: Vec = vec![ "--store-dir".to_string(), - store_dir.to_string_lossy().to_string(), + store_dir.to_string(), format!("--loglevel={loglevel}"), ]; if subcommand != "remove" { diff --git a/src-tauri/src/skills.rs b/src-tauri/src/skills.rs index 9493d04..583ec5d 100644 --- a/src-tauri/src/skills.rs +++ b/src-tauri/src/skills.rs @@ -42,7 +42,7 @@ fn home_path_of(state: &AppState, home_id: &str) -> Result { .homes .iter() .find(|h| h.id == home_id) - .map(|h| h.path.clone()) + .map(crate::wsl::home_fs_path) .ok_or_else(|| "DSH_HOME 不存在".to_string()) } diff --git a/src-tauri/src/tasks.rs b/src-tauri/src/tasks.rs index 83a6fac..bf1eaab 100644 --- a/src-tauri/src/tasks.rs +++ b/src-tauri/src/tasks.rs @@ -1747,15 +1747,6 @@ pub async fn start_copy_instance_task( .find(|i| i.id == input.source_id) .cloned() .ok_or_else(|| "源实例不存在".to_string())?; - let home = cfg - .homes - .iter() - .find(|h| h.id == source.home_id) - .cloned() - .ok_or_else(|| "DSH_HOME 不存在".to_string())?; - if home.wsl.is_some() { - return Err("WSL 实例暂不支持复制到新的专属 HOME".to_string()); - } source.name }; { @@ -1872,6 +1863,16 @@ async fn do_copy_instance( .ok_or_else(|| "DSH_HOME 不存在".to_string())?; (source, home) }; + // WSL source (issue #19 follow-up): the Linux path is not directly + // readable on Windows — read through the \\wsl$\ UNC share (distro must + // be running). The copy target stays a local Windows HOME. + let src_fs = match &src_home.wsl { + Some(distro) => { + crate::wsl::ensure_distro_running(state, distro).await?; + crate::wsl::unc_path(distro, &src_home.path.to_string_lossy()) + } + None => src_home.path.clone(), + }; let dest = state .data_dir .join("homes") @@ -1879,7 +1880,7 @@ async fn do_copy_instance( // Pass 1: count files for a meaningful percent. push_task_log(app, state, task_id, "正在统计源 DSH_HOME 文件…").await; - let total = crate::commands::count_tree_files(&src_home.path).max(1); + let total = crate::commands::count_tree_files(&src_fs).max(1); push_task_log( app, state, @@ -1892,7 +1893,7 @@ async fn do_copy_instance( // emit percent/log progress. let copied = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); let copy_result = { - let src = src_home.path.clone(); + let src = src_fs.clone(); let dst = dest.clone(); let counter = copied.clone(); tauri::async_runtime::spawn_blocking(move || { diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs index 1ec7208..4809eff 100644 --- a/src-tauri/src/terminal.rs +++ b/src-tauri/src/terminal.rs @@ -103,7 +103,7 @@ pub fn kill_all(state: &AppState) { fn terminal_env( state: &State<'_, AppState>, instance_id: &str, - shim_dir: &std::path::Path, + shim_dir: Option<&std::path::Path>, ) -> Result, String> { let cfg = state.config.lock().unwrap(); let inst = cfg @@ -134,12 +134,16 @@ fn terminal_env( crate::proxy::override_env(&mut env, &cfg.settings); } - let existing = std::env::var_os("PATH").unwrap_or_default(); - let mut entries = vec![shim_dir.to_path_buf()]; - entries.extend(std::env::split_paths(&existing)); - match std::env::join_paths(entries) { - Ok(joined) => env.push(("PATH".to_string(), joined.to_string_lossy().to_string())), - Err(e) => crate::log_warn!("拼接 PATH 失败,沿用系统 PATH: {e}"), + // WSL terminals (issue #19 follow-up): the Windows `dsh` shim cannot run + // inside bash, so PATH is left to the distro. + if let Some(shim_dir) = shim_dir { + let existing = std::env::var_os("PATH").unwrap_or_default(); + let mut entries = vec![shim_dir.to_path_buf()]; + entries.extend(std::env::split_paths(&existing)); + match std::env::join_paths(entries) { + Ok(joined) => env.push(("PATH".to_string(), joined.to_string_lossy().to_string())), + Err(e) => crate::log_warn!("拼接 PATH 失败,沿用系统 PATH: {e}"), + } } Ok(env) } @@ -230,8 +234,32 @@ fn spawn_session( rows: u16, ) -> Result<(TerminalSession, PathBuf), String> { let (home_path, version_dir) = crate::plugins::resolve_instance(state, instance_id)?; - let shim_dir = prepare_shim(&state.data_dir, &version_dir)?; - let env = terminal_env(state, instance_id, &shim_dir)?; + // WSL instances (issue #19 follow-up): the terminal shell is the distro's + // bash through wsl.exe. The Windows `dsh` shim cannot run inside bash, so + // PATH is left to the distro; DSH_HOME is already the Linux path and the + // session starts in the Linux HOME. + let wsl_distro = { + let cfg = state.config.lock().unwrap(); + let inst = cfg + .instances + .iter() + .find(|i| i.id == instance_id) + .ok_or_else(|| "实例不存在".to_string())?; + cfg.homes + .iter() + .find(|h| h.id == inst.home_id) + .and_then(|h| h.wsl.clone()) + }; + let shell: String = match &wsl_distro { + Some(_) => "wsl.exe".to_string(), + None => shell_program(), + }; + let shim_dir = if wsl_distro.is_some() { + None + } else { + Some(prepare_shim(&state.data_dir, &version_dir)?) + }; + let env = terminal_env(state, instance_id, shim_dir.as_deref())?; let pty_system = native_pty_system(); let pair = pty_system @@ -243,8 +271,16 @@ fn spawn_session( }) .map_err(|e| format!("创建 PTY 失败: {e}"))?; - let mut cmd = CommandBuilder::new(shell_program()); - cmd.cwd(home_path.as_os_str()); + let mut cmd = CommandBuilder::new(&shell); + if let Some(d) = &wsl_distro { + // UNC paths are not valid Windows process working directories; start + // wsl.exe from a neutral dir and let bash land in the Linux HOME + // (wsl.exe -- bash -i already starts in $HOME by default). + cmd.args(["-d", d, "--", "bash", "-i"]); + cmd.cwd(std::env::temp_dir().as_os_str()); + } else { + cmd.cwd(home_path.as_os_str()); + } for (k, v) in &env { cmd.env(k, v); } diff --git a/src-tauri/src/tui.rs b/src-tauri/src/tui.rs index 49ad567..1553d65 100644 --- a/src-tauri/src/tui.rs +++ b/src-tauri/src/tui.rs @@ -118,6 +118,16 @@ pub async fn start_tui_session( .find(|i| i.id == instance_id) .cloned() .ok_or_else(|| "实例不存在".to_string())?; + let wsl_distro = cfg + .homes + .iter() + .find(|h| h.id == inst.home_id) + .and_then(|h| h.wsl.clone()); + // WSL (issue #19 follow-up): profile-kind probing reads through \\wsl$\, + // so ensure the distro is running before classifying. + if let Some(d) = &wsl_distro { + crate::wsl::ensure_distro_running(&state, d).await?; + } let Some(profile) = crate::process::tui_active_profile(&cfg, instance_id) else { return Err("实例没有可用的 profile".to_string()); }; @@ -127,7 +137,37 @@ pub async fn start_tui_session( return Err("该实例的 profile 不是 TUI 类型".to_string()); } - if !crate::process::version_bin_ready(&version_dir) { + // WSL (issue #19 follow-up): the TUI runs inside the distro. The bin.js + // exists check and the spawn go through wsl.exe; the version dir is the + // Linux path. + let (linux_version, linux_home) = { + let cfg2 = state.config.lock().unwrap(); + let home = cfg2 + .homes + .iter() + .find(|h| h.id == inst.home_id) + .ok_or_else(|| "DSH_HOME 不存在".to_string())?; + let ver = cfg2 + .versions + .iter() + .find(|v| v.id == inst.version_id) + .ok_or_else(|| "版本不存在".to_string())?; + (ver.dir.clone(), home.path.clone()) + }; + if let Some(distro) = &wsl_distro { + let bin = crate::process::version_bin(&linux_version); + if !crate::wsl::wsl_test(distro, "-s", &bin.to_string_lossy()).await { + return Err(format!( + "版本 {} 安装不完整(缺少 {}),请重新安装", + cfg.versions + .iter() + .find(|v| v.dir == linux_version) + .map(|v| v.version.clone()) + .unwrap_or_default(), + bin.display() + )); + } + } else if !crate::process::version_bin_ready(&version_dir) { let bin = crate::process::version_bin(&version_dir); return Err(format!( "版本 {} 安装不完整(缺少 {}),请重新安装", @@ -153,11 +193,33 @@ pub async fn start_tui_session( }) .map_err(|e| format!("创建 PTY 失败: {e}"))?; - let mut cmd = CommandBuilder::new(crate::process::node()); - cmd.arg(crate::process::version_bin(&version_dir)); - cmd.arg("--profile"); - cmd.arg(&profile); - cmd.cwd(home_path.as_os_str()); + let mut cmd = CommandBuilder::new(if wsl_distro.is_some() { + "wsl.exe" + } else { + crate::process::node() + }); + if let Some(distro) = &wsl_distro { + // WSL (issue #19 follow-up): run the distro's node + version bin.js + // through bash; DSH_HOME is the Linux path (build_env already used + // the Linux home path from config). + let bin = crate::process::version_bin(&linux_version); + let root = crate::wsl::WslRoot::resolve(distro).await?; + let script = format!( + "export PATH={0}:\"$PATH\"; cd {1} && exec {2} {3} --profile {4}", + crate::wsl::sh_quote(&root.node_bin_dir()), + crate::wsl::sh_quote(&linux_home.to_string_lossy()), + crate::wsl::sh_quote(&root.node_exe()), + crate::wsl::sh_quote(&bin.to_string_lossy()), + crate::wsl::sh_quote(&profile), + ); + cmd.args(["-d", distro, "--", "bash", "-lc", &script]); + cmd.cwd(std::env::temp_dir().as_os_str()); + } else { + cmd.arg(crate::process::version_bin(&version_dir)); + cmd.arg("--profile"); + cmd.arg(&profile); + cmd.cwd(home_path.as_os_str()); + } for (k, v) in &env { cmd.env(k, v); } diff --git a/src-tauri/src/wsl.rs b/src-tauri/src/wsl.rs index 054d0a9..81f6c50 100644 --- a/src-tauri/src/wsl.rs +++ b/src-tauri/src/wsl.rs @@ -75,6 +75,74 @@ pub fn unc_path(distro: &str, linux_path: &str) -> std::path::PathBuf { std::path::PathBuf::from(format!(r"\\wsl$\{distro}\{rest}")) } +/// Windows-fs-accessible path for a possibly-WSL location: local paths pass +/// through unchanged; WSL paths map to the distro's `\\wsl$\` UNC share +/// (usable while the distro is running — call `ensure_distro_running` before +/// relying on it). This is the single place WSL path mapping happens, so +/// business code never scatters `unc_path` calls. +pub fn fs_path(distro: Option<&str>, linux: &std::path::Path) -> std::path::PathBuf { + match distro { + Some(d) => unc_path(d, &linux.to_string_lossy()), + None => linux.to_path_buf(), + } +} + +/// Resolves a `DshHome` to the path Windows file APIs can use: `home.path` +/// as-is for local homes, `\\wsl$\\…` for WSL homes. +pub fn home_fs_path(home: &crate::config::DshHome) -> std::path::PathBuf { + fs_path(home.wsl.as_deref(), &home.path) +} + +/// Resolves a `DshVersion` install dir for Windows file APIs, mirroring +/// `home_fs_path` (WSL versions map through `\\wsl$\` too, e.g. bundle +/// probes in `process::profile_kind`). +pub fn version_fs_path(ver: &crate::config::DshVersion) -> std::path::PathBuf { + fs_path(ver.wsl.as_deref(), &ver.dir) +} + +/// How long a positive `ensure_distro_running` result is trusted. Probing +/// wsl.exe on every invoke is wasteful for list-heavy flows (profiles, +/// plugins, skills), so a verified distro is reused for a short window. +const DISTRO_READY_TTL: std::time::Duration = std::time::Duration::from_secs(5); + +/// Ensures a distro is reachable so `\\wsl$\` UNC access works: boots a +/// stopped distro with `wsl.exe -d -- true` (a no-op that starts +/// it) and caches positive results in `AppState` for `DISTRO_READY_TTL`. +/// Returns a clear error when wsl.exe is missing or the distro is gone. +pub async fn ensure_distro_running( + state: &tauri::State<'_, crate::AppState>, + distro: &str, +) -> Result<(), String> { + { + let cache = state.distro_ready.lock().await; + if cache + .get(distro) + .map(|t| t.elapsed() < DISTRO_READY_TTL) + .unwrap_or(false) + { + return Ok(()); + } + } + let out = wsl_cmd(distro, &["true".to_string()]) + .output() + .await + .map_err(|e| format!("wsl.exe 执行失败: {e}"))?; + if !out.status.success() { + let err = String::from_utf8_lossy(&out.stderr).trim().to_string(); + return Err(if err.is_empty() { + format!("WSL 发行版 {distro} 不可用") + } else { + format!("WSL 发行版 {distro} 不可用: {err}") + }); + } + state + .distro_ready + .lock() + .await + .insert(distro.to_string(), std::time::Instant::now()); + Ok(()) +} + /// Lists installed WSL distros (`wsl.exe -l -q`), excluding the internal /// docker-desktop distros. Empty when WSL is unavailable. #[cfg(windows)] @@ -360,4 +428,66 @@ mod tests { std::path::PathBuf::from(r"\\wsl$\Ubuntu\home\u\.dsh-launcher") ); } + + #[test] + fn fs_path_passes_local_through_and_maps_wsl() { + let local = std::path::Path::new(r"C:\Users\u\.dsh-launcher\homes\x"); + assert_eq!(fs_path(None, local), local.to_path_buf()); + + let linux = std::path::Path::new("/home/u/.dsh-launcher/homes/x"); + assert_eq!( + fs_path(Some("Ubuntu"), linux), + std::path::PathBuf::from(r"\\wsl$\Ubuntu\home\u\.dsh-launcher\homes\x") + ); + } + + #[test] + fn home_fs_path_uses_wsl_field() { + let local = crate::config::DshHome { + id: "h1".into(), + name: "local".into(), + path: std::path::PathBuf::from(r"C:\homes\l"), + wsl: None, + }; + assert_eq!( + home_fs_path(&local), + std::path::PathBuf::from(r"C:\homes\l") + ); + + let wsl_home = crate::config::DshHome { + id: "h2".into(), + name: "wsl".into(), + path: std::path::PathBuf::from("/home/u/.dsh-launcher/homes/w"), + wsl: Some("Ubuntu".into()), + }; + assert_eq!( + home_fs_path(&wsl_home), + std::path::PathBuf::from(r"\\wsl$\Ubuntu\home\u\.dsh-launcher\homes\w") + ); + } + + #[test] + fn version_fs_path_maps_wsl_install_dir() { + let local = crate::config::DshVersion { + id: "v1".into(), + version: "0.1.0".into(), + dir: std::path::PathBuf::from(r"C:\ver\0.1.0"), + wsl: None, + }; + assert_eq!( + version_fs_path(&local), + std::path::PathBuf::from(r"C:\ver\0.1.0") + ); + + let wsl_ver = crate::config::DshVersion { + id: "v2".into(), + version: "0.1.0".into(), + dir: std::path::PathBuf::from("/home/u/.dsh-launcher/versions/0.1.0"), + wsl: Some("Debian".into()), + }; + assert_eq!( + version_fs_path(&wsl_ver), + std::path::PathBuf::from(r"\\wsl$\Debian\home\u\.dsh-launcher\versions\0.1.0") + ); + } } diff --git a/src/components/MigratePluginsDialog.vue b/src/components/MigratePluginsDialog.vue index d519adf..8c3c656 100644 --- a/src/components/MigratePluginsDialog.vue +++ b/src/components/MigratePluginsDialog.vue @@ -35,9 +35,9 @@ const conflictStrategy = ref<'skip' | 'reinstall'>('skip') /** Also copy the source profile's enabled/disabled state. */ const syncDisabled = ref(true) -/** Local instances other than the target (WSL excluded, as in the install wizard). */ +/** Local instances other than the target (WSL sources copy through \\wsl$\). */ const sourceInstances = computed(() => - store.instances.filter((i) => i.id !== props.targetInstanceId && !store.homeById(i.home_id)?.wsl), + store.instances.filter((i) => i.id !== props.targetInstanceId), ) /** Non-registry sources (git tarballs, local files, links) cannot be reinstalled diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 6cdaba3..994e555 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -359,7 +359,6 @@ "viewLog": "View log", "filesHint": "Open this instance's DSH_HOME directory, or reveal its runtime log in the file manager", "wslHomeFixed": "A WSL instance's HOME lives inside the distro (reachable via Open directory); it cannot be changed yet", - "wslTabUnsupported": "\"{tab}\" operates on files inside the HOME and is not supported for WSL instances yet", "dirOpened": "Directory opened in file manager: {path}", "logOpened": "Log revealed in file manager: {path}", "createShortcut": "Create shortcut", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 0ec9e82..9bbc0e4 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -359,7 +359,6 @@ "viewLog": "查看日志", "filesHint": "打开该实例的 DSH_HOME 目录,或在文件管理器中定位该实例的运行日志", "wslHomeFixed": "WSL 实例的 HOME 位于发行版内(打开目录可访问),暂不支持更换", - "wslTabUnsupported": "「{tab}」涉及 HOME 内文件操作,WSL 实例暂不支持", "dirOpened": "已在文件管理器中打开目录:{path}", "logOpened": "已在文件管理器中定位日志:{path}", "createShortcut": "创建快捷方式", diff --git a/src/views/InstanceEdit.vue b/src/views/InstanceEdit.vue index 4af3cd8..c557d6f 100644 --- a/src/views/InstanceEdit.vue +++ b/src/views/InstanceEdit.vue @@ -1314,16 +1314,6 @@ const terminalRunning = ref(false) - -
- - {{ t('instanceEdit.wslTabUnsupported', { tab: t(`instanceEdit.tabs.${activeTab}`) }) }} - -
-

diff --git a/src/views/plugins/InstallWizard.vue b/src/views/plugins/InstallWizard.vue index 797a1de..4540f25 100644 --- a/src/views/plugins/InstallWizard.vue +++ b/src/views/plugins/InstallWizard.vue @@ -21,10 +21,7 @@ const submitting = ref(false) const profiles = ref([]) const profilesLoading = ref(false) -const instances = computed(() => - // Plugin installs operate on local files; WSL instances are unsupported. - store.instances.filter((i) => !store.homeById(i.home_id)?.wsl), -) +const instances = computed(() => store.instances) const selectedInstance = computed(() => store.instanceById(instanceId.value)) /**