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:
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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user