Add README, plugin citations, and multi-platform release CI.

Ship screenshots and third-party notices for the desktop shell, and
package Windows, macOS, deb, rpm, and Flatpak from GitHub Releases.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Tommy
2026-08-15 22:02:13 +08:00
commit f8a3c2dc6f
72 changed files with 10372 additions and 0 deletions

View File

@@ -0,0 +1,227 @@
use std::path::Path;
use base64::Engine;
use serde::Serialize;
pub const IMAGE_MIMES: &[&str] = &[
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
"image/gif",
"image/bmp",
"image/tiff",
];
pub const INGEST_JS: &str = include_str!("../../../ui/inject/ingest.js");
#[derive(Debug, Clone, Serialize)]
pub struct ClipboardFile {
pub name: String,
#[serde(rename = "type")]
pub mime: String,
pub b64: String,
}
pub fn is_image_mime(mime: &str) -> bool {
let mime = mime.split(';').next().unwrap_or(mime).trim().to_ascii_lowercase();
let mime = if mime == "image/jpg" {
"image/jpeg"
} else {
mime.as_str()
};
IMAGE_MIMES.contains(&mime) || mime == "image/jpeg"
}
pub fn filename_for_mime(mime: &str, index: usize) -> String {
let mime = mime.split(';').next().unwrap_or(mime).trim().to_ascii_lowercase();
let ext = match mime.as_str() {
"image/png" => "png",
"image/jpeg" | "image/jpg" => "jpg",
"image/webp" => "webp",
"image/gif" => "gif",
"image/bmp" => "bmp",
"image/tiff" => "tiff",
_ => "png",
};
if index == 0 {
format!("clipboard.{ext}")
} else {
format!("clipboard-{}.{ext}", index + 1)
}
}
pub fn build_ingest_call(files: &[ClipboardFile]) -> String {
let payload = serde_json::to_string(files).unwrap_or_else(|_| "[]".into());
format!("window.__dshDesktopPasteFiles && window.__dshDesktopPasteFiles({payload});")
}
pub fn parse_uri_list(payload: &str) -> Vec<String> {
let mut paths = Vec::new();
for raw in payload.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(rest) = line.strip_prefix("file:") {
if let Ok(url) = url_parse_file(rest) {
paths.push(url);
}
} else if line.starts_with('/') {
paths.push(line.to_string());
}
}
paths
}
fn url_parse_file(rest: &str) -> Result<String, ()> {
// file:///home/me/Pictures/shot.png or file://localhost/home/...
let uri = if rest.starts_with("//") {
format!("file:{rest}")
} else {
format!("file:{rest}")
};
let decoded = percent_decode(&uri);
if let Some(idx) = decoded.find("://") {
let after = &decoded[idx + 3..];
let path = after
.strip_prefix("localhost")
.unwrap_or(after);
return Ok(path.to_string());
}
if let Some(path) = decoded.strip_prefix("file:") {
return Ok(path.to_string());
}
Ok(decoded)
}
fn percent_decode(input: &str) -> String {
let bytes = input.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let Ok(v) = u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
{
out.push(v);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
pub fn files_from_paths<I, S>(paths: I) -> Vec<ClipboardFile>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut out = Vec::new();
for (index, raw) in paths.into_iter().enumerate() {
let path = Path::new(raw.as_ref());
if !path.is_file() {
continue;
}
let suffix = path
.extension()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_ascii_lowercase();
let mime = match suffix.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"webp" => "image/webp",
"gif" => "image/gif",
"bmp" => "image/bmp",
"tif" | "tiff" => "image/tiff",
_ => continue,
};
let Ok(data) = std::fs::read(path) else {
continue;
};
if data.is_empty() {
continue;
}
let name = path
.file_name()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| filename_for_mime(mime, index));
out.push(ClipboardFile {
name,
mime: mime.to_string(),
b64: base64::engine::general_purpose::STANDARD.encode(data),
});
}
out
}
pub fn file_from_bytes(name: String, mime: String, data: Vec<u8>) -> Option<ClipboardFile> {
if data.is_empty() || !is_image_mime(&mime) {
return None;
}
Some(ClipboardFile {
name,
mime: mime.split(';').next().unwrap_or(&mime).trim().to_string(),
b64: base64::engine::general_purpose::STANDARD.encode(data),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mime_and_filename() {
assert!(is_image_mime("image/png"));
assert!(is_image_mime("image/jpeg; charset=binary"));
assert!(is_image_mime("image/jpg"));
assert!(!is_image_mime("text/plain"));
assert_eq!(filename_for_mime("image/png", 0), "clipboard.png");
assert_eq!(filename_for_mime("image/jpeg", 1), "clipboard-2.jpg");
}
#[test]
fn ingest_js_defines_helper() {
assert!(INGEST_JS.contains("window.__dshDesktopPasteFiles"));
assert!(INGEST_JS.contains("DragEvent"));
assert!(INGEST_JS.contains("new File"));
assert!(INGEST_JS.contains("text/uri-list"));
}
#[test]
fn build_ingest_call_embeds_payload() {
let files = [ClipboardFile {
name: "shot.png".into(),
mime: "image/png".into(),
b64: base64::engine::general_purpose::STANDARD.encode(b"\x89PNG"),
}];
let script = build_ingest_call(&files);
assert!(script.contains("__dshDesktopPasteFiles"));
assert!(script.contains("shot.png"));
assert!(script.contains("image/png"));
assert!(!script.contains(", \"hi\""));
}
#[test]
fn parse_file_uri() {
let payload = "# comment\nfile:///home/me/Pictures/shot.png\n";
assert_eq!(
parse_uri_list(payload),
vec!["/home/me/Pictures/shot.png".to_string()]
);
}
#[test]
fn parse_plain_path() {
assert_eq!(parse_uri_list("/tmp/a.jpg"), vec!["/tmp/a.jpg".to_string()]);
}
#[test]
fn skips_non_images() {
assert!(files_from_paths(["/no/such/file.txt"]).is_empty());
}
}

View File

@@ -0,0 +1,437 @@
use std::collections::VecDeque;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Instant;
use regex::Regex;
use thiserror::Error;
use crate::paths::{home_dir, is_flatpak};
use crate::updater::update_dsh_bin;
use crate::{ENV_BIN_OVERRIDE, ENV_CWD_OVERRIDE};
pub const DSH_DEFAULT_HOST: &str = "127.0.0.1";
pub const URL_TIMEOUT_SECONDS: u64 = 120;
#[derive(Debug, Error)]
pub enum DshNotFound {
#[error("{0}")]
Message(String),
}
impl DshNotFound {
fn new(msg: impl Into<String>) -> Self {
Self::Message(msg.into())
}
}
pub fn strip_ansi(text: &str) -> String {
let re = Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]").expect("ansi regex");
re.replace_all(text, "").into_owned()
}
pub fn parse_dsh_url(line: &str) -> Option<String> {
let re = Regex::new(r"https?://(?:\[[0-9a-fA-F:]+\]|[^/\s:]+)(?::\d+)?(?:/[^\s]*)?")
.expect("url regex");
re.find(&strip_ansi(line)).map(|m| m.as_str().to_string())
}
pub fn default_workspace() -> PathBuf {
if let Ok(raw) = std::env::var(ENV_CWD_OVERRIDE) {
if !raw.is_empty() {
return PathBuf::from(&raw)
.canonicalize()
.unwrap_or_else(|_| PathBuf::from(raw));
}
}
if is_flatpak() {
return home_dir();
}
std::env::current_dir().unwrap_or_else(|_| home_dir())
}
pub fn find_npx_dsh_bins() -> Vec<PathBuf> {
let pattern = home_dir().join(".npm/_npx/*/node_modules/.bin/dsh");
let mut bins = Vec::new();
if let Some(globbed) = pattern.to_str() {
if let Ok(paths) = glob_simple(globbed) {
for path in paths {
if is_executable(&path) {
bins.push(path);
}
}
}
}
bins.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
bins.reverse();
bins
}
fn glob_simple(pattern: &str) -> std::io::Result<Vec<PathBuf>> {
// Only the `_npx/*/` segment is a wildcard.
let Some((prefix, rest)) = pattern.split_once('*') else {
return Ok(vec![PathBuf::from(pattern)]);
};
let suffix = rest.trim_start_matches('/');
let parent = Path::new(prefix);
let mut out = Vec::new();
if parent.is_dir() {
for entry in std::fs::read_dir(parent)? {
let entry = entry?;
let candidate = entry.path().join(suffix);
if candidate.is_file() {
out.push(candidate);
}
}
}
Ok(out)
}
pub struct DshLauncher {
dsh_bin_override: Option<String>,
in_flatpak: bool,
pub workspace: PathBuf,
}
impl DshLauncher {
pub fn new(dsh_bin: Option<String>, workspace: Option<PathBuf>) -> Self {
Self {
dsh_bin_override: dsh_bin,
in_flatpak: is_flatpak(),
workspace: workspace.unwrap_or_else(default_workspace),
}
}
pub fn resolve(&self) -> Result<Vec<String>, DshNotFound> {
let mut candidates: Vec<Vec<String>> = Vec::new();
if let Ok(env_override) = std::env::var(ENV_BIN_OVERRIDE) {
if !env_override.is_empty() {
let parts = shlex::split(&env_override).ok_or_else(|| {
DshNotFound::new(format!("{ENV_BIN_OVERRIDE} 不是合法的命令"))
})?;
candidates.push(parts);
}
}
if let Some(cli) = &self.dsh_bin_override {
let parts = shlex::split(cli)
.ok_or_else(|| DshNotFound::new("dsh 命令不是合法的命令"))?;
candidates.push(parts);
}
let updated = update_dsh_bin();
if is_executable(&updated) {
candidates.push(vec![updated.to_string_lossy().into_owned()]);
}
if self.in_flatpak {
if let Some(bundled) = which::which("dsh").ok() {
candidates.push(vec![bundled.to_string_lossy().into_owned()]);
}
for path in crate::updater::BUNDLED_DSH {
let path = PathBuf::from(path);
if is_executable(&path)
&& candidates
.first()
.and_then(|c| c.first())
.map(|s| s.as_str())
!= Some(path.to_string_lossy().as_ref())
{
candidates.push(vec![path.to_string_lossy().into_owned()]);
}
}
} else {
let local_bin = dsh_bin_name(&home_dir().join(".local/bin"));
if is_executable(&local_bin) {
candidates.push(vec![local_bin.to_string_lossy().into_owned()]);
}
if let Some(cached) = find_npx_dsh_bins().into_iter().next() {
candidates.push(vec![cached.to_string_lossy().into_owned()]);
}
if let Ok(host) = which::which("dsh") {
candidates.push(vec![host.to_string_lossy().into_owned()]);
}
if which::which("npx").is_ok() {
candidates.push(vec![
"npx".into(),
"--yes".into(),
"@deepseek-ai/dsh".into(),
]);
}
}
let chosen = candidates.into_iter().next().ok_or_else(|| {
if self.in_flatpak {
DshNotFound::new(
"此 Flatpak 没有内置 dsh 可执行文件。\n请重新构建/安装 io.github.tommyfang.DshDesktop。",
)
} else {
DshNotFound::new(
"找不到 DeepSeek Harness (dsh),也没有可用的 npx。\n请先安装npm install -g @deepseek-ai/dsh\n或用 DSH_DESKTOP_DSH_BIN 指定 dsh 的完整路径。",
)
}
})?;
Ok(chosen)
}
pub fn web_argv(&self) -> Result<Vec<String>, DshNotFound> {
let mut argv = self.resolve()?;
argv.extend([
"web".into(),
"--host".into(),
DSH_DEFAULT_HOST.into(),
"--port".into(),
"0".into(),
]);
Ok(argv)
}
pub fn start(&self) -> Result<DshProcess, DshNotFound> {
let argv = self.web_argv()?;
let mut cmd = Command::new(&argv[0]);
cmd.args(&argv[1..])
.current_dir(&self.workspace)
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.stdin(Stdio::null());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
}
let mut child = cmd.spawn().map_err(|exc| {
DshNotFound::new(format!("无法启动 dsh web: {exc}"))
})?;
let stdout = child.stdout.take();
Ok(DshProcess::new(child, stdout, self.in_flatpak))
}
}
fn dsh_bin_name(dir: &Path) -> PathBuf {
#[cfg(windows)]
{
let cmd = dir.join("dsh.cmd");
if cmd.is_file() {
return cmd;
}
}
dir.join("dsh")
}
pub fn is_executable(path: &Path) -> bool {
if !path.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(windows)]
{
true
}
}
pub struct DshProcess {
child: Arc<Mutex<Child>>,
pub in_flatpak: bool,
pub url: Arc<Mutex<Option<String>>>,
pub lines: Arc<Mutex<VecDeque<String>>>,
pub started_at: Instant,
}
impl DshProcess {
fn new(child: Child, stdout: Option<std::process::ChildStdout>, in_flatpak: bool) -> Self {
let proc = Self {
child: Arc::new(Mutex::new(child)),
in_flatpak,
url: Arc::new(Mutex::new(None)),
lines: Arc::new(Mutex::new(VecDeque::with_capacity(400))),
started_at: Instant::now(),
};
if let Some(stdout) = stdout {
let lines = Arc::clone(&proc.lines);
let url = Arc::clone(&proc.url);
thread::spawn(move || {
let reader = BufReader::new(stdout);
for line in reader.lines() {
let Ok(line) = line else { break };
let clean = strip_ansi(&line);
let trimmed = clean.trim_end();
if !trimmed.is_empty() {
if let Ok(mut buf) = lines.lock() {
if buf.len() == 400 {
buf.pop_front();
}
buf.push_back(trimmed.to_string());
}
}
if let Some(found) = parse_dsh_url(&line) {
if let Ok(mut slot) = url.lock() {
if slot.is_none() {
*slot = Some(found);
}
}
}
}
});
}
proc
}
pub fn poll(&self) -> Option<i32> {
self.child
.lock()
.ok()
.and_then(|mut child| child.try_wait().ok().flatten().and_then(|s| s.code()))
}
pub fn take_url(&self) -> Option<String> {
self.url.lock().ok().and_then(|g| g.clone())
}
pub fn snapshot_lines(&self) -> Vec<String> {
self.lines
.lock()
.map(|g| g.iter().cloned().collect())
.unwrap_or_default()
}
pub fn stop(&self) {
let Ok(mut child) = self.child.lock() else {
return;
};
if child.try_wait().ok().flatten().is_some() {
return;
}
let pid = child.id();
#[cfg(unix)]
unsafe {
libc::killpg(pid as i32, libc::SIGTERM);
}
#[cfg(windows)]
{
let _ = child.kill();
}
let start = Instant::now();
while start.elapsed() < std::time::Duration::from_secs(3) {
if child.try_wait().ok().flatten().is_some() {
return;
}
thread::sleep(std::time::Duration::from_millis(50));
}
#[cfg(unix)]
unsafe {
libc::killpg(pid as i32, libc::SIGKILL);
}
let _ = child.kill();
let _ = child.wait();
}
}
impl Drop for DshProcess {
fn drop(&mut self) {
self.stop();
}
}
#[cfg(test)]
mod tests {
use super::*;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn parse_ipv4_url() {
assert_eq!(
parse_dsh_url("dsh web: http://127.0.0.1:39095"),
Some("http://127.0.0.1:39095".into())
);
}
#[test]
fn parse_ipv6_url() {
assert_eq!(
parse_dsh_url("dsh web: http://[::1]:39096"),
Some("http://[::1]:39096".into())
);
}
#[test]
fn parse_url_with_trailing_text() {
assert_eq!(
parse_dsh_url("booted profile web on http://127.0.0.1:39095 (ready)"),
Some("http://127.0.0.1:39095".into())
);
}
#[test]
fn parse_no_url() {
assert_eq!(parse_dsh_url("waiting for network…"), None);
}
#[test]
fn strip_ansi_codes() {
assert_eq!(strip_ansi("\x1b[36mhttp://x\x1b[0m"), "http://x");
}
#[test]
fn workspace_uses_env_override() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::set_var(ENV_CWD_OVERRIDE, "/tmp/some-workspace");
let ws = default_workspace();
std::env::remove_var(ENV_CWD_OVERRIDE);
assert!(ws.ends_with("some-workspace") || ws == PathBuf::from("/tmp/some-workspace"));
}
#[test]
fn env_override_wins() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::set_var(ENV_BIN_OVERRIDE, "echo");
let argv = DshLauncher::new(None, None).resolve().unwrap();
std::env::remove_var(ENV_BIN_OVERRIDE);
assert_eq!(argv, vec!["echo"]);
}
#[test]
fn invalid_env_override_raises() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::set_var(ENV_BIN_OVERRIDE, "'unterminated");
let err = DshLauncher::new(None, None).resolve();
std::env::remove_var(ENV_BIN_OVERRIDE);
assert!(err.is_err());
}
#[test]
fn cli_override() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::remove_var(ENV_BIN_OVERRIDE);
let argv = DshLauncher::new(Some("/opt/dsh/bin/dsh".into()), None)
.resolve()
.unwrap();
assert_eq!(argv, vec!["/opt/dsh/bin/dsh"]);
}
#[test]
fn web_argv_shape() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::set_var(ENV_BIN_OVERRIDE, "dsh-test");
let argv = DshLauncher::new(None, None).web_argv().unwrap();
std::env::remove_var(ENV_BIN_OVERRIDE);
assert_eq!(&argv[0..4], ["dsh-test", "web", "--host", "127.0.0.1"]);
assert!(argv.contains(&"--port".into()));
assert_eq!(argv.last().map(String::as_str), Some("0"));
}
}

View File

@@ -0,0 +1,21 @@
//! Shared desktop-shell logic for DeepSeek Harness Desktop.
//!
//! This crate has no GUI dependency. The Tauri (or any other) shell starts
//! `dsh web`, keeps the bundled plugins/presets current, and embeds the
//! official WebUI.
pub mod clipboard;
pub mod launcher;
pub mod modlens;
pub mod paths;
pub mod preset;
pub mod updater;
pub const APP_ID: &str = "io.github.tommyfang.DshDesktop";
pub const APP_NAME: &str = "DeepSeek Harness";
pub const APP_SUMMARY: &str = "Desktop shell for DeepSeek Harness";
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const ENV_BIN_OVERRIDE: &str = "DSH_DESKTOP_DSH_BIN";
pub const ENV_CWD_OVERRIDE: &str = "DSH_DESKTOP_CWD";
pub const ENV_NO_UPDATE: &str = "DSH_DESKTOP_NO_UPDATE";

View File

@@ -0,0 +1,473 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use regex::Regex;
use serde_json::{json, Value};
use crate::paths::{copy_tree, dsh_home, replace_symlink, BundledPaths};
pub const PACKAGE: &str = "@liustack/modlens";
pub const VISION_PACKAGE: &str = "dsh-desktop-vision";
pub const MODLENS_VERSION: &str = "3.16.6";
pub const HIDE_PLAIN_TWINS_JS: &str = include_str!("../../../ui/inject/hide-twins.js");
pub const MANAGED_OVERLAY: &str = "\
# dsh-desktop manages this modlens overlay (wrap every text-only model).
- id: modlens
config:
autoRead: true
families:
- \"\"
";
const OFFICIAL_TEXT_PROVIDERS: &[(&str, &str)] = &[
("deepseek-official", "deepseek-modlens"),
("deepseek", "deepseek-modlens"),
];
#[derive(Debug, Clone)]
pub struct ModlensEnsureResult {
pub status: &'static str,
pub version: Option<String>,
pub message: String,
}
pub fn web_profile_dir() -> PathBuf {
dsh_home().join("profiles/web")
}
fn package_dir(prefix: &Path) -> PathBuf {
prefix.join("node_modules/@liustack/modlens")
}
pub fn read_modlens_version(prefix: &Path) -> Option<String> {
let text = std::fs::read_to_string(package_dir(prefix).join("package.json")).ok()?;
let data: Value = serde_json::from_str(&text).ok()?;
data.get("version")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
pub fn bundled_vision_plugin(paths: &BundledPaths) -> Option<PathBuf> {
paths
.find_dir("vision", "package.json")
.or_else(|| paths.find_dir("dsh-desktop-vision", "package.json"))
.or_else(|| paths.find_dir("plugins/dsh-desktop-vision", "package.json"))
.filter(|p| p.join("client.js").is_file())
}
pub fn bundled_modlens_prefix(paths: &BundledPaths) -> Option<PathBuf> {
paths
.find_dir("modlens", "node_modules/@liustack/modlens")
.or_else(|| paths.find_dir("vendor/modlens", "node_modules/@liustack/modlens"))
}
fn install_into_profile(src_prefix: &Path, profile: &Path) -> std::io::Result<()> {
let dest_pkg = package_dir(profile);
copy_tree(&package_dir(src_prefix), &dest_pkg, true)?;
let dest_nm = profile.join("node_modules");
let src_nm = src_prefix.join("node_modules");
for dep in ["commander", "undici"] {
let src_dep = src_nm.join(dep);
if src_dep.is_dir() {
copy_tree(&src_dep, &dest_nm.join(dep), true)?;
}
}
let fallback = dsh_home().join("profiles/node_modules/@liustack/modlens");
let _ = replace_symlink(&fallback, &dest_pkg);
Ok(())
}
fn read_pkg_version(dir: &Path) -> Option<String> {
let text = std::fs::read_to_string(dir.join("package.json")).ok()?;
let data: Value = serde_json::from_str(&text).ok()?;
data.get("version")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
fn install_vision_plugin(paths: &BundledPaths, profile: &Path) -> bool {
let Some(src) = bundled_vision_plugin(paths) else {
return false;
};
let dest = profile.join("node_modules").join(VISION_PACKAGE);
let up_to_date = dest.join("client.js").is_file()
&& dest.join("index.js").is_file()
&& read_pkg_version(&dest) == read_pkg_version(&src);
if !up_to_date && copy_tree(&src, &dest, true).is_err() {
return false;
}
let fallback = dsh_home().join("profiles/node_modules").join(VISION_PACKAGE);
let _ = replace_symlink(&fallback, &dest);
true
}
fn ensure_manifest(profile: &Path, packages: &BTreeMap<String, String>) -> std::io::Result<()> {
let path = profile.join("package.json");
let mut data = if path.is_file() {
serde_json::from_str(&std::fs::read_to_string(&path)?).unwrap_or_else(|_| json!({}))
} else {
json!({
"name": "dsh-profile-web",
"private": true,
"dsh": {"profile": {"bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"]}}
})
};
{
let obj = data.as_object_mut().unwrap();
obj.entry("dependencies").or_insert_with(|| json!({}));
let dsh = obj.entry("dsh").or_insert_with(|| json!({}));
let profile_meta = dsh
.as_object_mut()
.unwrap()
.entry("profile")
.or_insert_with(|| json!({}));
let bundles = profile_meta
.as_object_mut()
.unwrap()
.entry("bundles")
.or_insert_with(|| json!(["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"]));
if !bundles.is_array() {
*bundles = json!(["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"]);
}
}
for (name, version) in packages {
data["dependencies"][name] = json!(version);
let arr = data["dsh"]["profile"]["bundles"].as_array_mut().unwrap();
if !arr.iter().any(|v| v.as_str() == Some(name)) {
arr.push(json!(name));
}
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, serde_json::to_string_pretty(&data)? + "\n")
}
pub fn ensure_modlens_overlay(text: &str) -> String {
let body = text.replace("\r\n", "\n");
let stripped = body.trim();
if stripped.is_empty() || stripped == "[]" || stripped == "#" {
return MANAGED_OVERLAY.to_string();
}
let re = Regex::new(r"(?m)^- id:\s*modlens\s*$").unwrap();
if re.is_match(&body) {
return patch_existing_modlens_entry(&body);
}
let mut body = body;
if !body.ends_with('\n') {
body.push('\n');
}
body.push('\n');
body.push_str(MANAGED_OVERLAY);
body
}
fn patch_existing_modlens_entry(body: &str) -> String {
let lines: Vec<&str> = body.split('\n').collect();
let mut start = None;
let start_re = Regex::new(r"^- id:\s*modlens\s*$").unwrap();
for (index, line) in lines.iter().enumerate() {
if start_re.is_match(line) {
start = Some(index);
break;
}
}
let Some(start) = start else {
let mut out = body.trim_end().to_string();
out.push_str("\n\n");
out.push_str(MANAGED_OVERLAY);
return out;
};
let mut end = lines.len();
for (index, line) in lines.iter().enumerate().skip(start + 1) {
if line.starts_with("- ") && !line.starts_with(' ') {
end = index;
break;
}
}
let block: Vec<String> = lines[start..end].iter().map(|s| (*s).to_string()).collect();
let new_block = force_wrap_all_config(block).join("\n");
let new_block = new_block.trim_end();
let mut out = String::new();
out.push_str(&lines[..start].join("\n"));
if start > 0 && !out.ends_with('\n') && !out.is_empty() {
out.push('\n');
}
// reconstruct like Python: lines[:start] + new_block.split + lines[end:]
let mut combined: Vec<String> = lines[..start].iter().map(|s| (*s).to_string()).collect();
combined.extend(new_block.split('\n').map(|s| s.to_string()));
combined.extend(lines[end..].iter().map(|s| (*s).to_string()));
let mut text = combined.join("\n");
text = text.trim_end().to_string();
text.push('\n');
text
}
fn force_wrap_all_config(block: Vec<String>) -> Vec<String> {
let config_re = Regex::new(r"^\s+config:\s*$").unwrap();
let mut block = block;
if !block.iter().any(|line| config_re.is_match(line)) {
block.push(" config:".into());
}
let mut stripped = Vec::new();
let mut skipping_families = false;
let families_re = Regex::new(r"^\s+families:\s*").unwrap();
let item_re = Regex::new(r"^\s+- ").unwrap();
for line in block {
if skipping_families {
if item_re.is_match(&line) || line.trim().is_empty() {
continue;
}
skipping_families = false;
}
if families_re.is_match(&line) {
skipping_families = true;
continue;
}
stripped.push(line);
}
let autoread_re = Regex::new(r"^\s+autoRead:\s*").unwrap();
let has_autoread = stripped.iter().any(|line| autoread_re.is_match(line));
let mut out = Vec::new();
let mut inserted = false;
for line in stripped {
if autoread_re.is_match(&line) {
let replaced = Regex::new(r"^(\s+autoRead:\s*).*$")
.unwrap()
.replace(&line, "${1}true");
out.push(replaced.into_owned());
out.push(" families:".into());
out.push(" - \"\"".into());
inserted = true;
continue;
}
let is_config = config_re.is_match(&line);
out.push(line);
if is_config && !has_autoread && !inserted {
out.push(" autoRead: true".into());
out.push(" families:".into());
out.push(" - \"\"".into());
inserted = true;
}
}
if !inserted {
out.push(" autoRead: true".into());
out.push(" families:".into());
out.push(" - \"\"".into());
}
out
}
pub fn remap_default_text_model(settings_text: &str) -> String {
let mut text = settings_text.to_string();
if !text.ends_with('\n') {
text.push('\n');
}
let re = Regex::new(r"(?m)^(agent-default-model:\n(?: .*\n)*)").unwrap();
let Some(caps) = re.captures(&text) else {
return settings_text.to_string();
};
let block = caps.get(1).unwrap().as_str().to_string();
let provider_re = Regex::new(r"(?m)^ provider:\s*(\S+)\s*$").unwrap();
let Some(provider) = provider_re.captures(&block) else {
return settings_text.to_string();
};
let current = provider[1].trim_matches(|c| c == '"' || c == '\'');
let Some((_, wrapped)) = OFFICIAL_TEXT_PROVIDERS.iter().find(|(k, _)| *k == current) else {
return settings_text.to_string();
};
let new_block = Regex::new(r"(?m)^( provider:\s*)\S+\s*$")
.unwrap()
.replacen(&block, 1, format!("${{1}}{wrapped}"));
let start = caps.get(1).unwrap().start();
let end = caps.get(1).unwrap().end();
let mut out = String::new();
out.push_str(&text[..start]);
out.push_str(&new_block);
out.push_str(&text[end..]);
if !settings_text.ends_with('\n') && out.ends_with('\n') {
// keep a trailing newline; Python operated on the padded copy
}
out
}
pub fn ensure_modlens(paths: &BundledPaths) -> ModlensEnsureResult {
let src = bundled_modlens_prefix(paths);
let profile = web_profile_dir();
let installed = read_modlens_version(&profile);
let version = src
.as_ref()
.and_then(|p| read_modlens_version(p))
.or_else(|| installed.clone())
.unwrap_or_else(|| MODLENS_VERSION.to_string());
match ensure_modlens_inner(paths, src.as_deref(), &profile, installed, &version) {
Ok(result) => result,
Err(exc) => ModlensEnsureResult {
status: "failed",
version: Some(version),
message: format!("内置 ModLens 安装失败:{exc}"),
},
}
}
fn ensure_modlens_inner(
paths: &BundledPaths,
src: Option<&Path>,
profile: &Path,
installed: Option<String>,
version: &str,
) -> std::io::Result<ModlensEnsureResult> {
std::fs::create_dir_all(profile)?;
let vision_ok = install_vision_plugin(paths, profile);
let mut packages = BTreeMap::new();
if vision_ok {
packages.insert(VISION_PACKAGE.to_string(), "0.1.0".into());
}
if src.is_none() && installed.is_none() {
if !packages.is_empty() {
ensure_manifest(profile, &packages)?;
}
return Ok(ModlensEnsureResult {
status: "skipped",
version: None,
message: "未找到内置 ModLens跳过插件安装".into(),
});
}
let installed_after = if let Some(src) = src {
if installed.as_deref() != Some(version) {
install_into_profile(src, profile)?;
read_modlens_version(profile).unwrap_or_else(|| version.to_string())
} else {
installed.clone().unwrap_or_else(|| version.to_string())
}
} else {
installed.clone().unwrap_or_else(|| version.to_string())
};
packages.insert(PACKAGE.to_string(), installed_after.clone());
ensure_manifest(profile, &packages)?;
let patch = profile.join("cordis.patch.yml");
let previous = if patch.is_file() {
std::fs::read_to_string(&patch)?
} else {
String::new()
};
let updated = ensure_modlens_overlay(&previous);
if updated != previous {
std::fs::write(&patch, updated)?;
}
let settings = dsh_home().join("settings.yaml");
if settings.is_file() {
let original = std::fs::read_to_string(&settings)?;
let remapped = remap_default_text_model(&original);
if remapped != original {
std::fs::write(&settings, remapped)?;
}
}
if src.is_none() {
return Ok(ModlensEnsureResult {
status: "current",
version: Some(installed_after.clone()),
message: format!("已配置已安装的 ModLens {installed_after}(纯文本自动套视觉桥)"),
});
}
if installed.as_deref() == Some(version) {
return Ok(ModlensEnsureResult {
status: "current",
version: Some(version.to_string()),
message: format!("内置 ModLens {version} 已就绪"),
});
}
if installed.is_some() {
return Ok(ModlensEnsureResult {
status: "updated",
version: Some(version.to_string()),
message: format!("已将内置 ModLens 更新到 {version}"),
});
}
Ok(ModlensEnsureResult {
status: "installed",
version: Some(version.to_string()),
message: format!("已启用内置 ModLens {version}"),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_file_gets_managed_overlay() {
let text = ensure_modlens_overlay("");
assert!(text.contains("id: modlens"));
assert!(text.contains("autoRead: true"));
assert!(text.contains("families:"));
assert!(text.contains("- \"\""));
}
#[test]
fn existing_modlens_gains_wrap_all_families() {
let text = ensure_modlens_overlay("- id: modlens\n config:\n autoRead: true\n");
assert!(text.contains("autoRead: true"));
assert!(text.contains(" - \"\""));
}
#[test]
fn replaces_narrow_families() {
let original = "\
- id: modlens
config:
autoRead: true
families:
- deepseek
- glm
";
let text = ensure_modlens_overlay(original);
assert!(!text.contains("deepseek"));
assert!(!text.contains("glm"));
assert!(text.contains("- \"\""));
}
#[test]
fn keeps_other_entries() {
let original = "- id: other\n config:\n x: 1\n- id: modlens\n config:\n autoRead: false\n";
let text = ensure_modlens_overlay(original);
assert!(text.contains("id: other"));
assert!(text.contains("autoRead: true"));
assert!(text.contains(" - \"\""));
}
#[test]
fn official_deepseek_becomes_modlens() {
let original = "\
agent-default-model:
provider: deepseek-official
model: deepseek-v4-pro
ui-theme:
preference: dark
";
let updated = remap_default_text_model(original);
assert!(updated.contains("provider: deepseek-modlens"));
assert!(updated.contains("model: deepseek-v4-pro"));
assert!(updated.contains("preference: dark"));
}
#[test]
fn qwen_is_left_alone() {
let original = "agent-default-model:\n provider: qwen\n model: qwen-agent\n";
assert_eq!(remap_default_text_model(original), original);
}
#[test]
fn already_wrapped_is_left_alone() {
let original = "agent-default-model:\n provider: deepseek-modlens\n model: deepseek-v4-pro\n";
assert_eq!(remap_default_text_model(original), original);
}
#[test]
fn hide_twins_script_targets_suffix() {
assert!(HIDE_PLAIN_TWINS_JS.contains("(modlens vision)"));
assert!(HIDE_PLAIN_TWINS_JS.contains("MutationObserver"));
}
}

View File

@@ -0,0 +1,179 @@
use std::env;
use std::path::{Path, PathBuf};
/// XDG-style data directory (`~/.local/share` on Linux).
pub fn data_home() -> PathBuf {
if let Some(raw) = env::var_os("XDG_DATA_HOME") {
if !raw.is_empty() {
return PathBuf::from(raw);
}
}
dirs::data_dir().unwrap_or_else(|| home_dir().join(".local/share"))
}
/// XDG-style cache directory (`~/.cache` on Linux).
pub fn cache_home() -> PathBuf {
if let Some(raw) = env::var_os("XDG_CACHE_HOME") {
if !raw.is_empty() {
return PathBuf::from(raw);
}
}
dirs::cache_dir().unwrap_or_else(|| home_dir().join(".cache"))
}
pub fn home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))
}
pub fn dsh_home() -> PathBuf {
if let Some(raw) = env::var_os("DSH_HOME") {
if !raw.is_empty() {
return PathBuf::from(raw);
}
}
home_dir().join(".dsh")
}
pub fn downloads_dir() -> PathBuf {
dirs::download_dir().unwrap_or_else(|| home_dir().join("Downloads"))
}
pub fn is_flatpak() -> bool {
Path::new("/.flatpak-info").exists()
}
/// Roots that may contain bundled ModLens / presets / vision plugin.
///
/// Search order: extra roots (Tauri resource dir), `$XDG_DATA_HOME/dsh-desktop`,
/// `/app/share/dsh-desktop`, `/usr/share/dsh-desktop`, `~/.local/share/dsh-desktop`,
/// then the source-tree `vendor/` / `plugins/` next to the executable or crate.
#[derive(Debug, Clone, Default)]
pub struct BundledPaths {
extra_roots: Vec<PathBuf>,
}
impl BundledPaths {
pub fn discover() -> Self {
Self::default()
}
pub fn with_resource_dir(mut self, dir: PathBuf) -> Self {
if dir.is_dir() {
self.extra_roots.insert(0, dir);
}
self
}
pub fn roots(&self) -> Vec<PathBuf> {
let mut roots = self.extra_roots.clone();
if let Some(xdg) = env::var_os("XDG_DATA_HOME") {
roots.push(PathBuf::from(xdg).join("dsh-desktop"));
}
roots.push(PathBuf::from("/app/share/dsh-desktop"));
roots.push(PathBuf::from("/usr/share/dsh-desktop"));
roots.push(home_dir().join(".local/share/dsh-desktop"));
for repo in repo_roots() {
roots.push(repo);
}
roots
}
pub fn find_dir(&self, rel: &str, marker: &str) -> Option<PathBuf> {
for root in self.roots() {
let candidate = root.join(rel);
if candidate.join(marker).is_file() || candidate.join(marker).is_dir() {
return Some(candidate);
}
}
None
}
}
fn repo_roots() -> Vec<PathBuf> {
let mut roots = Vec::new();
if let Ok(exe) = env::current_exe() {
if let Some(parent) = exe.parent() {
roots.push(parent.to_path_buf());
if let Some(grand) = parent.parent() {
roots.push(grand.to_path_buf());
}
}
}
// crates/dsh-core -> repo root; src-tauri -> repo root
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
if let Some(parent) = manifest.parent() {
roots.push(parent.to_path_buf());
if let Some(grand) = parent.parent() {
roots.push(grand.to_path_buf());
}
}
roots
}
pub fn copy_tree(src: &Path, dest: &Path, keep_symlinks: bool) -> std::io::Result<()> {
if dest.exists() {
std::fs::remove_dir_all(dest)?;
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
copy_tree_inner(src, dest, keep_symlinks)
}
fn copy_tree_inner(src: &Path, dest: &Path, keep_symlinks: bool) -> std::io::Result<()> {
if src
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n == ".git")
{
return Ok(());
}
if src.is_symlink() && keep_symlinks {
let target = std::fs::read_link(src)?;
#[cfg(unix)]
std::os::unix::fs::symlink(target, dest)?;
#[cfg(windows)]
{
if src.is_dir() {
std::os::windows::fs::symlink_dir(target, dest)?;
} else {
std::os::windows::fs::symlink_file(target, dest)?;
}
}
return Ok(());
}
if src.is_dir() {
std::fs::create_dir_all(dest)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
copy_tree_inner(&entry.path(), &dest.join(entry.file_name()), keep_symlinks)?;
}
return Ok(());
}
std::fs::copy(src, dest)?;
Ok(())
}
pub fn replace_symlink(link: &Path, target: &Path) -> std::io::Result<()> {
if let Some(parent) = link.parent() {
std::fs::create_dir_all(parent)?;
}
if link.exists() || link.is_symlink() {
if link.is_dir() && !link.is_symlink() {
std::fs::remove_dir_all(link)?;
} else {
std::fs::remove_file(link)?;
}
}
#[cfg(unix)]
std::os::unix::fs::symlink(target, link)?;
#[cfg(windows)]
{
if target.is_dir() {
std::os::windows::fs::symlink_dir(target, link)?;
} else {
std::os::windows::fs::symlink_file(target, link)?;
}
}
Ok(())
}

View File

@@ -0,0 +1,379 @@
use std::path::{Path, PathBuf};
use regex::Regex;
use crate::paths::{copy_tree, dsh_home, BundledPaths};
pub const PRESET_ID: &str = "anchored-standard";
pub const ZERO_PRESET_ID: &str = "zero-anchored-standard";
pub const SOURCE_MARKER: &str = ".dsh-desktop-source";
pub const DEFAULT_BLOCK: &str = "agent-presets:\n default: anchored-standard\n";
pub const PRESET_NAME_ZH: &str = "锚定式标准(实验)";
pub const PRESET_DESCRIPTION_ZH: &str =
"首轮使用 Minimal 的真实工具对(持久 bash + str_replace_editor不自动注入工作区或技能上下文首次工具调用或回复后开放完整 Standard 工具。";
pub const ZERO_PRESET_NAME_ZH: &str = "零工具锚定式标准(实验)";
pub const ZERO_PRESET_DESCRIPTION_ZH: &str =
"先插入一轮无工具的锚定对话(固定提示),从下一轮起开放完整 Standard 工具。";
#[derive(Debug, Clone)]
pub struct BundledPreset {
pub preset_id: &'static str,
pub name_zh: &'static str,
pub description_zh: &'static str,
}
pub const BUNDLED_PRESETS: &[BundledPreset] = &[
BundledPreset {
preset_id: PRESET_ID,
name_zh: PRESET_NAME_ZH,
description_zh: PRESET_DESCRIPTION_ZH,
},
BundledPreset {
preset_id: ZERO_PRESET_ID,
name_zh: ZERO_PRESET_NAME_ZH,
description_zh: ZERO_PRESET_DESCRIPTION_ZH,
},
];
#[derive(Debug, Clone)]
pub struct PresetEnsureResult {
pub status: &'static str,
pub version: Option<String>,
pub message: String,
}
pub fn preset_install_dir(preset_id: &str) -> PathBuf {
dsh_home().join(".agent-presets").join(preset_id)
}
fn is_preset_dir(path: &Path) -> bool {
path.is_dir() && path.join("preset.yml").is_file()
}
pub fn read_source_version(path: &Path) -> Option<String> {
let version = std::fs::read_to_string(path.join(SOURCE_MARKER)).ok()?;
let version = version.trim();
if version.is_empty() {
None
} else {
Some(version.to_string())
}
}
pub fn bundled_preset_dir(paths: &BundledPaths, preset_id: &str) -> Option<PathBuf> {
paths
.find_dir(preset_id, "preset.yml")
.or_else(|| paths.find_dir(&format!("vendor/{preset_id}"), "preset.yml"))
.filter(|p| is_preset_dir(p))
}
pub fn ensure_default_preset(text: &str) -> String {
let mut body = text.replace("\r\n", "\n");
if !body.ends_with('\n') {
body.push('\n');
}
if body.trim().is_empty() {
return DEFAULT_BLOCK.to_string();
}
let re = Regex::new(r"(?m)^agent-presets:\n((?:[ \t]+.*\n)*)").unwrap();
let Some(caps) = re.captures(&body) else {
body.push('\n');
body.push_str(DEFAULT_BLOCK);
return body;
};
let inner = caps.get(1).unwrap().as_str();
if Regex::new(r"(?m)^[ \t]+default:\s*\S+")
.unwrap()
.is_match(inner)
{
return body;
}
let insert = format!(" default: {PRESET_ID}\n");
let start = caps.get(1).unwrap().start();
let end = caps.get(1).unwrap().end();
format!("{}{}{}{}", &body[..start], insert, inner, &body[end..])
}
pub fn localize_preset_yml(text: &str, name: &str, description: &str) -> String {
let mut body = text.replace("\r\n", "\n");
if !body.ends_with('\n') {
body.push('\n');
}
let name_line = format!("name: {}\n", serde_json::to_string(name).unwrap());
let desc_line = format!(
"description: {}\n",
serde_json::to_string(description).unwrap()
);
let name_re = Regex::new(r"(?m)^name:.*\n").unwrap();
if let Some(m) = name_re.find(&body) {
body = format!("{}{}{}", &body[..m.start()], name_line, &body[m.end()..]);
} else {
body = format!("{name_line}{body}");
}
let desc_re = Regex::new(r"(?m)^description:(?:[ \t].*)?\n(?:[ \t].+\n)*").unwrap();
if let Some(m) = desc_re.find(&body) {
return format!("{}{}{}", &body[..m.start()], desc_line, &body[m.end()..]);
}
if let Some(m) = name_re.find(&body) {
return format!("{}{}{}", &body[..m.end()], desc_line, &body[m.end()..]);
}
format!("{desc_line}{body}")
}
fn write_localized_preset(dest: &Path, spec: &BundledPreset) -> std::io::Result<()> {
let path = dest.join("preset.yml");
let previous = std::fs::read_to_string(&path)?;
let updated = localize_preset_yml(&previous, spec.name_zh, spec.description_zh);
if updated != previous {
std::fs::write(path, updated)?;
}
Ok(())
}
fn short_version(version: Option<&str>) -> String {
match version {
None => "bundled".into(),
Some(v) if v.len() > 12 => v[..12].into(),
Some(v) => v.into(),
}
}
fn ensure_one(paths: &BundledPaths, spec: &BundledPreset) -> PresetEnsureResult {
let src = bundled_preset_dir(paths, spec.preset_id);
let dest = preset_install_dir(spec.preset_id);
let bundled = src.as_ref().and_then(|p| read_source_version(p));
let installed = if dest.is_dir() {
read_source_version(&dest)
} else {
None
};
let version = bundled.clone().or_else(|| installed.clone());
let label = spec.name_zh;
if src.is_none() {
if dest.is_dir() && dest.join("preset.yml").is_file() {
let _ = write_localized_preset(&dest, spec);
return PresetEnsureResult {
status: "current",
version: version.clone(),
message: format!("已配置已安装的{label}{}", short_version(version.as_deref())),
};
}
return PresetEnsureResult {
status: "skipped",
version: None,
message: format!("未找到内置{label},跳过安装"),
};
}
let src = src.unwrap();
if let Err(exc) = (|| -> std::io::Result<()> {
if installed != bundled || !dest.join("preset.yml").is_file() {
copy_tree(&src, &dest, false)?;
}
write_localized_preset(&dest, spec)?;
Ok(())
})() {
return PresetEnsureResult {
status: "failed",
version,
message: format!("内置{label}安装失败:{exc}"),
};
}
let sha = short_version(bundled.as_deref());
if installed == bundled && dest.join("preset.yml").is_file() {
return PresetEnsureResult {
status: "current",
version: bundled,
message: format!("内置{label} {sha} 已就绪"),
};
}
if installed.is_some() {
return PresetEnsureResult {
status: "updated",
version: bundled,
message: format!("已将内置{label}更新到 {sha}"),
};
}
PresetEnsureResult {
status: "installed",
version: bundled,
message: format!("已启用内置{label} {sha}"),
}
}
fn combine(results: &[PresetEnsureResult]) -> PresetEnsureResult {
if results.iter().any(|item| item.status == "failed") {
let failed: Vec<_> = results
.iter()
.filter(|item| item.status == "failed")
.map(|item| item.message.clone())
.collect();
return PresetEnsureResult {
status: "failed",
version: None,
message: failed.join(""),
};
}
let active: Vec<_> = results
.iter()
.filter(|item| item.status != "skipped")
.collect();
if active.is_empty() {
return PresetEnsureResult {
status: "skipped",
version: None,
message: "未找到内置锚定预设,跳过安装".into(),
};
}
let version = active.iter().find_map(|item| item.version.clone());
let (status, lead) = if active.iter().any(|item| item.status == "updated") {
("updated", "已更新内置锚定预设")
} else if active.iter().any(|item| item.status == "installed") {
("installed", "已启用内置锚定预设")
} else {
("current", "内置锚定预设已就绪")
};
let detail = active
.iter()
.map(|item| item.message.as_str())
.collect::<Vec<_>>()
.join("");
PresetEnsureResult {
status,
version,
message: format!("{lead}。{detail}"),
}
}
pub fn ensure_anchored_standard(paths: &BundledPaths) -> PresetEnsureResult {
let results: Vec<_> = BUNDLED_PRESETS
.iter()
.map(|spec| ensure_one(paths, spec))
.collect();
let combined = combine(&results);
if combined.status == "failed" {
return combined;
}
let settings = dsh_home().join("settings.yaml");
match (|| -> std::io::Result<()> {
let previous = if settings.is_file() {
std::fs::read_to_string(&settings)?
} else {
String::new()
};
let updated = ensure_default_preset(&previous);
if updated != previous {
if let Some(parent) = settings.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&settings, updated)?;
}
Ok(())
})() {
Ok(()) => combined,
Err(exc) => PresetEnsureResult {
status: "failed",
version: combined.version,
message: format!("写入默认 preset 失败:{exc}"),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::paths::BundledPaths;
#[test]
fn empty_file_gets_default_block() {
assert_eq!(
ensure_default_preset(""),
"agent-presets:\n default: anchored-standard\n"
);
}
#[test]
fn appends_when_section_missing() {
let updated = ensure_default_preset("ui-theme:\n preference: dark\n");
assert!(updated.contains("preference: dark"));
assert!(updated.contains("agent-presets:\n default: anchored-standard\n"));
}
#[test]
fn inserts_default_into_empty_section() {
let updated = ensure_default_preset("agent-presets:\nui-theme:\n preference: dark\n");
assert!(updated.contains("agent-presets:\n default: anchored-standard\n"));
assert!(updated.contains("preference: dark"));
}
#[test]
fn keeps_existing_default() {
let original = "agent-presets:\n default: standard\n";
assert_eq!(ensure_default_preset(original), original);
}
#[test]
fn does_not_match_permission_default_preset() {
let original = "permission:\n defaultPreset: danger-full-access\n";
let updated = ensure_default_preset(original);
assert!(updated.contains("defaultPreset: danger-full-access"));
assert!(updated.contains("agent-presets:\n default: anchored-standard\n"));
}
#[test]
fn replaces_english_name_and_description() {
let original = "\
name: Anchored Standard (experimental)
description: Bootstrap with the Minimal preset's real tool pair.
order: 5
";
let updated = localize_preset_yml(original, PRESET_NAME_ZH, PRESET_DESCRIPTION_ZH);
assert!(updated.contains(PRESET_NAME_ZH));
assert!(updated.contains(PRESET_DESCRIPTION_ZH));
assert!(!updated.contains("Anchored Standard (experimental)"));
assert!(!updated.contains("Bootstrap"));
assert!(updated.contains("order: 5"));
}
#[test]
fn inserts_description_after_name() {
let updated = localize_preset_yml("name: Anchored Standard\norder: 5\n", PRESET_NAME_ZH, PRESET_DESCRIPTION_ZH);
assert!(updated.contains(PRESET_NAME_ZH));
assert!(!updated.contains("name: Anchored Standard\n"));
assert!(updated.contains(PRESET_DESCRIPTION_ZH));
assert!(updated.contains("order: 5"));
}
#[test]
fn installs_from_bundle_and_sets_default() {
let tmp = tempfile::TempDir::new().unwrap();
let src = tmp.path().join("anchored-standard");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("preset.yml"), "name: Anchored Standard\n").unwrap();
std::fs::write(src.join(SOURCE_MARKER), "abc123def456\n").unwrap();
let home = tmp.path().join(".dsh");
std::env::set_var("DSH_HOME", &home);
let paths = BundledPaths::discover().with_resource_dir(tmp.path().to_path_buf());
let result = ensure_anchored_standard(&paths);
std::env::remove_var("DSH_HOME");
assert_eq!(result.status, "installed");
let dest = home.join(".agent-presets").join(PRESET_ID);
assert!(dest.join("preset.yml").is_file());
let yml = std::fs::read_to_string(dest.join("preset.yml")).unwrap();
assert!(yml.contains(PRESET_DESCRIPTION_ZH));
let settings = std::fs::read_to_string(home.join("settings.yaml")).unwrap();
assert!(settings.contains("default: anchored-standard"));
}
#[test]
fn localizes_zero_preset() {
let original = "\
name: Zero-Anchored Standard (experimental)
description: Inject one zero-tool anchor turn.
order: 6
";
let updated = localize_preset_yml(original, ZERO_PRESET_NAME_ZH, ZERO_PRESET_DESCRIPTION_ZH);
assert!(updated.contains(ZERO_PRESET_NAME_ZH));
assert!(updated.contains(ZERO_PRESET_DESCRIPTION_ZH));
assert!(!updated.contains("Zero-Anchored"));
}
}

View File

@@ -0,0 +1,348 @@
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use crate::paths::{cache_home, data_home};
use crate::ENV_NO_UPDATE;
pub const DSH_PACKAGE: &str = "@deepseek-ai/dsh";
pub const VIEW_TIMEOUT_SECONDS: u64 = 20;
pub const INSTALL_TIMEOUT_SECONDS: u64 = 180;
pub const BUNDLED_PREFIX: &str = "/app";
pub const BUNDLED_DSH: &[&str] = &["/app/bin/dsh", "/app/node24/bin/dsh"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateResult {
pub status: &'static str,
pub version: Option<String>,
pub previous: Option<String>,
pub message: String,
}
impl UpdateResult {
fn new(status: &'static str, version: Option<String>, message: impl Into<String>) -> Self {
Self {
status,
version,
previous: None,
message: message.into(),
}
}
}
pub fn update_prefix() -> PathBuf {
data_home().join("dsh-desktop/dsh-prefix")
}
pub fn update_dsh_bin() -> PathBuf {
let prefix = update_prefix();
#[cfg(windows)]
{
let cmd = prefix.join("dsh.cmd");
if cmd.is_file() {
return cmd;
}
}
prefix.join("bin/dsh")
}
pub fn package_json(prefix: &Path) -> PathBuf {
prefix.join("lib/node_modules/@deepseek-ai/dsh/package.json")
}
pub fn read_version(prefix: &Path) -> Option<String> {
let text = std::fs::read_to_string(package_json(prefix)).ok()?;
let data: serde_json::Value = serde_json::from_str(&text).ok()?;
data.get("version")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
pub fn find_npm() -> Option<PathBuf> {
for candidate in ["/app/bin/npm", "/app/node24/bin/npm"] {
let path = PathBuf::from(candidate);
if crate::launcher::is_executable(&path) {
return Some(path);
}
}
which::which("npm").ok()
}
fn find_node_dir() -> Option<PathBuf> {
for candidate in ["/app/bin/node", "/app/node24/bin/node"] {
let path = PathBuf::from(candidate);
if crate::launcher::is_executable(&path) {
return path.parent().map(|p| p.to_path_buf());
}
}
which::which("node")
.ok()
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
}
fn npm_command(npm: &Path) -> Command {
let mut cmd = Command::new(npm);
let cache = cache_home().join("dsh-desktop/npm");
let _ = std::fs::create_dir_all(&cache);
cmd.env("npm_config_cache", &cache);
cmd.env("npm_config_update_notifier", "false");
cmd.env("npm_config_fund", "false");
cmd.env("npm_config_audit", "false");
if let Some(node_dir) = find_node_dir() {
let mut path = node_dir.into_os_string();
path.push(if cfg!(windows) { ";" } else { ":" });
if let Some(existing) = std::env::var_os("PATH") {
path.push(existing);
}
cmd.env("PATH", path);
}
cmd
}
fn run_npm(npm: &Path, args: &[&str], timeout: Duration) -> Result<std::process::Output, String> {
let mut cmd = npm_command(npm);
cmd.args(args);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let child = cmd.spawn().map_err(|e| e.to_string())?;
let pid = child.id();
let done = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&done);
thread::spawn(move || {
let start = Instant::now();
while start.elapsed() < timeout {
if flag.load(Ordering::Relaxed) {
return;
}
thread::sleep(Duration::from_millis(50));
}
if flag.load(Ordering::Relaxed) {
return;
}
#[cfg(unix)]
unsafe {
libc::kill(pid as i32, libc::SIGKILL);
}
#[cfg(windows)]
{
let _ = Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.status();
}
});
let out = child.wait_with_output().map_err(|e| e.to_string());
done.store(true, Ordering::Relaxed);
out
}
fn last_check_path() -> PathBuf {
cache_home().join("dsh-desktop/last-update-check")
}
/// True when a network update check has not run in the last 24 hours.
pub fn update_check_due() -> bool {
let path = last_check_path();
let Ok(raw) = std::fs::read_to_string(&path) else {
return true;
};
let ts: u64 = raw.trim().parse().unwrap_or(0);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
now.saturating_sub(ts) >= 24 * 60 * 60
}
pub fn mark_update_checked() {
let path = last_check_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let _ = std::fs::write(path, format!("{now}\n"));
}
pub fn fetch_latest_version(npm: &Path) -> Option<String> {
let out = run_npm(npm, &["view", DSH_PACKAGE, "version"], Duration::from_secs(VIEW_TIMEOUT_SECONDS))
.ok()?;
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout)
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.last()
.map(|s| s.to_string())
}
fn seed_from_bundle(dest: &Path) {
let src_pkg = Path::new(BUNDLED_PREFIX).join("lib/node_modules/@deepseek-ai/dsh");
if !src_pkg.is_dir() {
return;
}
let dest_pkg = dest.join("lib/node_modules/@deepseek-ai/dsh");
if dest_pkg.exists() {
return;
}
if crate::paths::copy_tree(&src_pkg, &dest_pkg, true).is_err() {
return;
}
let dest_bin = dest.join("bin");
let _ = std::fs::create_dir_all(&dest_bin);
let link = dest_bin.join("dsh");
if !link.exists() {
let _ = crate::paths::replace_symlink(
&link,
Path::new("../lib/node_modules/@deepseek-ai/dsh/lib/bin.js"),
);
}
}
fn env_skips_update() -> bool {
std::env::var(ENV_NO_UPDATE)
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
.unwrap_or(false)
}
pub fn update_dsh(enabled: bool) -> UpdateResult {
if !enabled || env_skips_update() {
let version = read_version(&update_prefix()).or_else(|| read_version(Path::new(BUNDLED_PREFIX)));
return UpdateResult::new("skipped", version, "已跳过 dsh 更新");
}
let npm = find_npm();
let current = read_version(&update_prefix()).or_else(|| read_version(Path::new(BUNDLED_PREFIX)));
let Some(npm) = npm else {
let extra = current
.as_deref()
.map(|v| format!("{v}"))
.unwrap_or_default();
return UpdateResult::new(
"skipped",
current,
format!("未找到 npm使用已安装的 dsh{extra}"),
);
};
let latest = match fetch_latest_version(&npm) {
Some(v) => v,
None => {
let extra = current
.as_deref()
.map(|v| format!("{v}"))
.unwrap_or_default();
return UpdateResult::new(
"failed",
current,
format!("无法获取 dsh 最新版本,使用已安装版本{extra}"),
);
}
};
if current.as_deref() == Some(latest.as_str()) {
return UpdateResult::new(
"current",
current,
format!("内置 dsh 已是最新({latest}"),
);
}
let dest = update_prefix();
let _ = std::fs::create_dir_all(&dest);
if read_version(&dest).is_none() {
seed_from_bundle(&dest);
}
let prefix_arg = format!("--prefix={}", dest.display());
let pkg = format!("{DSH_PACKAGE}@latest");
let previous = current.clone();
match run_npm(
&npm,
&[
"install",
&prefix_arg,
"--global",
"--no-audit",
"--no-fund",
&pkg,
],
Duration::from_secs(INSTALL_TIMEOUT_SECONDS),
) {
Ok(out) if out.status.success() => {
let installed = read_version(&dest).or(current);
UpdateResult {
status: "updated",
version: installed.clone(),
previous,
message: format!(
"已将内置 dsh 更新到 {}",
installed.as_deref().unwrap_or("latest")
),
}
}
Ok(_) | Err(_) => {
let installed = read_version(&dest).or(current);
let extra = installed
.as_deref()
.map(|v| format!("{v}"))
.unwrap_or_default();
UpdateResult {
status: "failed",
version: installed,
previous,
message: format!("dsh 更新失败,使用已安装版本{extra}"),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn read_version_ok() {
let tmp = TempDir::new().unwrap();
let pkg = package_json(tmp.path());
std::fs::create_dir_all(pkg.parent().unwrap()).unwrap();
std::fs::write(&pkg, r#"{"version":"0.1.0-rc.6"}"#).unwrap();
assert_eq!(read_version(tmp.path()).as_deref(), Some("0.1.0-rc.6"));
}
#[test]
fn read_version_missing() {
assert_eq!(read_version(Path::new("/no/such/prefix")), None);
}
#[test]
fn skip_when_disabled() {
let result = update_dsh(false);
assert_eq!(result.status, "skipped");
}
#[test]
fn skip_when_env_set() {
std::env::set_var(ENV_NO_UPDATE, "1");
let result = update_dsh(true);
std::env::remove_var(ENV_NO_UPDATE);
assert_eq!(result.status, "skipped");
}
#[test]
fn update_check_due_without_stamp() {
let tmp = TempDir::new().unwrap();
std::env::set_var("XDG_CACHE_HOME", tmp.path());
assert!(update_check_due());
mark_update_checked();
assert!(!update_check_due());
std::env::remove_var("XDG_CACHE_HOME");
}
}