diff --git a/Cargo.lock b/Cargo.lock index 1f61e56..471ddff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -949,7 +949,7 @@ dependencies = [ [[package]] name = "dsh-core" -version = "0.1.2" +version = "0.1.3" dependencies = [ "base64 0.22.1", "dirs", @@ -966,7 +966,7 @@ dependencies = [ [[package]] name = "dsh-desktop" -version = "0.1.2" +version = "0.1.3" dependencies = [ "arboard", "dsh-core", diff --git a/Cargo.toml b/Cargo.toml index 6af2deb..45874be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/dsh-core", "src-tauri"] resolver = "2" [workspace.package] -version = "0.1.2" +version = "0.1.3" edition = "2021" license = "MIT" authors = ["TommyFang2077"] diff --git a/README.md b/README.md index 3c8c258..b0f5293 100644 --- a/README.md +++ b/README.md @@ -68,18 +68,19 @@ dsh 原本运行在浏览器标签页中;本项目用 Tauri 2 和系统 WebVie | 平台 | 产物 | 运行时要求 | | --- | --- | --- | -| Windows | NSIS `.exe` / `.msi` | [WebView2](https://developer.microsoft.com/microsoft-edge/webview2/)(安装器可引导下载)+ 本机 `dsh` | -| macOS | Apple Silicon / Intel `.dmg` | 未公证,首次打开需在「隐私与安全性」允许 + 本机 `dsh` | -| Linux | `.deb` / `.rpm` | WebKitGTK 4.1 + 本机 `dsh` | +| Windows | NSIS `.exe` / `.msi` | [WebView2](https://developer.microsoft.com/microsoft-edge/webview2/)(安装器可引导下载)+ Node.js/npm(首次启动自动安装 `dsh` 到内置目录) | +| macOS | Apple Silicon / Intel `.dmg` | 未公证,首次打开需在「隐私与安全性」允许 + Node.js/npm(首次启动自动安装 `dsh` 到内置目录) | +| Linux | `.deb` / `.rpm` | WebKitGTK 4.1 + Node.js/npm(首次启动自动安装 `dsh` 到内置目录) | | Linux Flatpak | `.flatpak` | **零依赖**:自带 Node.js 24 与 `@deepseek-ai/dsh` | -除 Flatpak 外,需要先安装 `dsh`: +除 Flatpak 外,需要本机有 Node.js/npm,用于首次启动自动下载并安装 `dsh`。也可以提前安装或指定路径: ```bash npm install -g @deepseek-ai/dsh +# 或设置 DSH_DESKTOP_DSH_BIN 指向 dsh 可执行文件 ``` -安装后直接启动,等待启动页完成即可进入官方 WebUI。 +未检测到 `dsh` 时,第一次启动会显示「正在下载并安装内置 dsh…」,完成后自动进入官方 WebUI。 ## 功能一览 diff --git a/crates/dsh-core/src/launcher.rs b/crates/dsh-core/src/launcher.rs index 08e0674..f4e6659 100644 --- a/crates/dsh-core/src/launcher.rs +++ b/crates/dsh-core/src/launcher.rs @@ -26,6 +26,15 @@ impl DshNotFound { fn new(msg: impl Into) -> Self { Self::Message(msg.into()) } + + /// True when resolution successfully analyzed every candidate and found no + /// usable dsh at all (as opposed to an invalid user-supplied override). + pub fn is_missing(&self) -> bool { + matches!( + self, + DshNotFound::Message(message) if message.contains("找不到") + ) + } } pub fn strip_ansi(text: &str) -> String { @@ -59,6 +68,18 @@ pub fn find_npx_dsh_bins() -> Vec { if let Some(globbed) = pattern.to_str() { if let Ok(paths) = glob_simple(globbed) { for path in paths { + // npm also drops an extensionless POSIX shim (`dsh`) next to + // `dsh.cmd`; Windows cannot CreateProcess that file, so prefer + // the cmd wrapper when present. + #[cfg(windows)] + let path = { + let cmd = path.with_extension("cmd"); + if cmd.is_file() { + cmd + } else { + path + } + }; if is_executable(&path) { bins.push(path); } @@ -105,7 +126,7 @@ impl DshLauncher { } } - pub fn resolve(&self) -> Result, DshNotFound> { + fn collect_candidates(&self) -> Result>, DshNotFound> { let mut candidates: Vec> = Vec::new(); if let Ok(env_override) = std::env::var(ENV_BIN_OVERRIDE) { @@ -163,6 +184,11 @@ impl DshLauncher { } } + Ok(candidates) + } + + pub fn resolve(&self) -> Result, DshNotFound> { + let candidates = self.collect_candidates()?; let chosen = candidates.into_iter().next().ok_or_else(|| { if self.in_flatpak { DshNotFound::new( @@ -170,13 +196,22 @@ impl DshLauncher { ) } else { DshNotFound::new( - "找不到 DeepSeek Harness (dsh),也没有可用的 npx。\n请先安装:npm install -g @deepseek-ai/dsh\n或用 DSH_DESKTOP_DSH_BIN 指定 dsh 的完整路径。", + "找不到 DeepSeek Harness (dsh)。\n请安装 Node.js/npm(首次启动会自动安装 dsh),或用 DSH_DESKTOP_DSH_BIN 指定 dsh 的完整路径。", ) } })?; Ok(chosen) } + /// True when a real dsh binary is available without relying on the + /// transient `npx --yes @deepseek-ai/dsh` fallback. + pub fn has_installed_dsh(&self) -> Result { + let candidates = self.collect_candidates()?; + Ok(candidates + .iter() + .any(|candidate| candidate.first().map(String::as_str) != Some("npx"))) + } + pub fn web_argv(&self) -> Result, DshNotFound> { let mut argv = self.resolve()?; argv.extend([ @@ -406,6 +441,15 @@ mod tests { assert_eq!(argv, vec!["echo"]); } + #[test] + fn has_installed_dsh_with_override() { + let _guard = ENV_LOCK.lock().unwrap(); + std::env::set_var(ENV_BIN_OVERRIDE, "echo"); + let launcher = DshLauncher::new(None, None); + assert!(launcher.has_installed_dsh().unwrap()); + std::env::remove_var(ENV_BIN_OVERRIDE); + } + #[test] fn invalid_env_override_raises() { let _guard = ENV_LOCK.lock().unwrap(); diff --git a/data/metainfo/io.github.tommyfang.DshDesktop.metainfo.xml b/data/metainfo/io.github.tommyfang.DshDesktop.metainfo.xml index cc0751d..321acac 100644 --- a/data/metainfo/io.github.tommyfang.DshDesktop.metainfo.xml +++ b/data/metainfo/io.github.tommyfang.DshDesktop.metainfo.xml @@ -41,6 +41,15 @@ + + +
    +
  • Non-Flatpak installs auto-install dsh into the app's built-in prefix on first launch when no dsh is found
  • +
  • Fixed Gitea mirror publish failing on a first-version 404 (release lookup no longer retries 404)
  • +
  • Windows prefers the dsh.cmd shim over the extensionless POSIX script in the npx cache
  • +
+
+
    diff --git a/docs/release-notes/v0.1.3.md b/docs/release-notes/v0.1.3.md new file mode 100644 index 0000000..3395ab0 --- /dev/null +++ b/docs/release-notes/v0.1.3.md @@ -0,0 +1,21 @@ +# DeepSeek Harness Desktop v0.1.3 + +**摘要 / Summary:** 首次启动自动安装内置 dsh(非 Flatpak 不再要求手动安装);Gitea 镜像发布修复首个版本的 404 卡死;Windows 上优先使用 `dsh.cmd` shim。 + +## 新功能 / Features +- **未检测到 dsh 时自动安装**:非 Flatpak 安装包首次启动如果找不到可用的 dsh,会自动下载并安装到应用内置目录(Windows 为 `%APPDATA%\dsh-desktop\dsh-prefix`,Linux/macOS 为 `~/.local/share/dsh-desktop/dsh-prefix`),完成后直接进入官方 WebUI,不再要求先手动 `npm install -g @deepseek-ai/dsh` + - 需要本机有 Node.js/npm;没有 npm 时仍会给出明确提示 + - `DSH_DESKTOP_DSH_BIN` / `--dsh`、`--no-update` / `DSH_DESKTOP_NO_UPDATE` 的优先级与原有行为不变 + +## 修复 / Fixes +- **Gitea 镜像发布在首个版本上失败**:Release 查询把 404(tag 已同步但 Release 尚未创建)误当瞬时故障重试 4 次后退出;现在 404 会直接走「创建 Release」分支 +- **Windows 上 npx 缓存里的 dsh 无法启动**:npm 在 `.npm/_npx/*/node_modules/.bin` 同时生成无扩展名的 POSIX 脚本 `dsh` 与 `dsh.cmd`,此前可能选中无扩展名脚本导致启动失败;现在 Windows 优先使用 `dsh.cmd` + +## 变更 / Changes +- 非 Flatpak 安装包的运行时要求从「本机已安装 dsh」改为「本机有 Node.js/npm 用于首次自动安装」(README 同步更新) + +## 安装与更新 / Install & Update +- 安装包:Windows NSIS/MSI、macOS DMG(Apple Silicon 与 Intel)、Linux deb/rpm、Flatpak +- 非 Flatpak:首次启动会自动安装 dsh 到内置目录;也可设置 `DSH_DESKTOP_DSH_BIN` 指定 dsh 路径 +- macOS 包未公证:首次打开需在「系统设置 → 隐私与安全性」中允许 +- 更新提示出现在启动页:点击「下载并安装」即可(走 Gitea 镜像,签名校验后生效) \ No newline at end of file diff --git a/scripts/publish-gitea-release.sh b/scripts/publish-gitea-release.sh index 1fee6f5..4ccdb46 100755 --- a/scripts/publish-gitea-release.sh +++ b/scripts/publish-gitea-release.sh @@ -57,7 +57,10 @@ else release_body="大陆镜像安装包;文件与 GitHub Release 同源。应用内更新包由 Tauri 签名校验。" fi -release_json=$(curl_retry curl --fail --silent --show-error -H "$AUTH" "$API/releases/tags/$RELEASE_TAG") +# 404 is expected when the mirror has not yet created a release for the tag; +# the create branch below handles it. Only retry transient failures, so the +# lookup itself must not go through curl_retry. +release_json=$(curl --fail --silent --show-error -H "$AUTH" "$API/releases/tags/$RELEASE_TAG" || true) release_id=$(printf '%s' "$release_json" | jq -r '.id // empty') if [[ -z "$release_id" ]]; then release_payload=$(jq -n \ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 09736e9..b51b009 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -357,14 +357,38 @@ fn boot(app: AppHandle) { ); let launcher = DshLauncher::new(state.args.dsh.clone(), state.args.cwd.clone()); + let missing = match launcher.resolve() { + Ok(_) => !launcher.has_installed_dsh().unwrap_or(true), + Err(error) => error.is_missing(), + }; + let mut bootstrap_message: Option = None; + if missing && !state.args.no_update { + let _ = app.emit( + "status", + StatusPayload { + message: "未检测到 dsh,正在下载并安装内置 dsh…".into(), + }, + ); + let result = update_dsh(true); + mark_update_checked(); + log::info!( + "dsh not found; bootstrap install: {} ({})", + result.status, + result.message + ); + bootstrap_message = Some(result.message); + } let process = match launcher.start() { Ok(p) => Arc::new(p), Err(err) => { + let detail = bootstrap_message + .map(|message| format!("{message}\n{err}")) + .unwrap_or_default(); let _ = app.emit( "error", ErrorPayload { message: err.to_string(), - detail: String::new(), + detail, }, ); return;