mirror of
https://github.com/TommyFang2077/dsh-desktop.git
synced 2026-08-17 09:06:36 +08:00
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:
23
crates/dsh-core/Cargo.toml
Normal file
23
crates/dsh-core/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "dsh-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Launcher, updater, and profile helpers for DeepSeek Harness Desktop"
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
dirs = "6"
|
||||
regex = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
shlex = "1"
|
||||
thiserror = "2"
|
||||
which = "8"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
227
crates/dsh-core/src/clipboard.rs
Normal file
227
crates/dsh-core/src/clipboard.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
437
crates/dsh-core/src/launcher.rs
Normal file
437
crates/dsh-core/src/launcher.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
21
crates/dsh-core/src/lib.rs
Normal file
21
crates/dsh-core/src/lib.rs
Normal 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";
|
||||
473
crates/dsh-core/src/modlens.rs
Normal file
473
crates/dsh-core/src/modlens.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
179
crates/dsh-core/src/paths.rs
Normal file
179
crates/dsh-core/src/paths.rs
Normal 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(())
|
||||
}
|
||||
379
crates/dsh-core/src/preset.rs
Normal file
379
crates/dsh-core/src/preset.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
348
crates/dsh-core/src/updater.rs
Normal file
348
crates/dsh-core/src/updater.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user