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,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"));
}
}