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:
32
src-tauri/Cargo.toml
Normal file
32
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "dsh-desktop"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Tauri desktop shell for DeepSeek Harness"
|
||||
|
||||
[lib]
|
||||
name = "dsh_desktop_lib"
|
||||
crate-type = ["lib", "cdylib", "staticlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "dsh-desktop"
|
||||
path = "src/main.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
dsh-core = { path = "../crates/dsh-core" }
|
||||
tauri = { version = "2", features = ["devtools"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
arboard = "3"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
open = "5"
|
||||
url = "2"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
3
src-tauri/build.rs
Normal file
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
18
src-tauri/capabilities/default.json
Normal file
18
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Main window, including the embedded dsh WebUI",
|
||||
"windows": ["main"],
|
||||
"remote": {
|
||||
"urls": ["http://127.0.0.1:*", "http://localhost:*"]
|
||||
},
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:webview:allow-internal-toggle-devtools",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
BIN
src-tauri/icons/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
BIN
src-tauri/icons/128x128@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
BIN
src-tauri/icons/32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
BIN
src-tauri/icons/64x64.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.icns
Normal file
Binary file not shown.
BIN
src-tauri/icons/icon.ico
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
src-tauri/icons/icon.png
Normal file
BIN
src-tauri/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
15
src-tauri/linux/dsh-desktop.desktop
Normal file
15
src-tauri/linux/dsh-desktop.desktop
Normal file
@@ -0,0 +1,15 @@
|
||||
[Desktop Entry]
|
||||
Categories={{categories}}
|
||||
Comment={{comment}}
|
||||
Comment[zh_CN]=在原生桌面窗口中运行 DeepSeek Harness
|
||||
Exec={{exec}}
|
||||
GenericName=AI Agent Harness
|
||||
GenericName[zh_CN]=AI 智能体工作台
|
||||
Icon={{icon}}
|
||||
Keywords=AI;DeepSeek;agent;harness;dsh;assistant;
|
||||
Name={{name}}
|
||||
Name[zh_CN]=DeepSeek Harness
|
||||
StartupNotify=true
|
||||
StartupWMClass=io.github.tommyfang.DshDesktop
|
||||
Terminal=false
|
||||
Type=Application
|
||||
383
src-tauri/src/lib.rs
Normal file
383
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,383 @@
|
||||
use std::io::Cursor;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use dsh_core::clipboard::{
|
||||
file_from_bytes, filename_for_mime, files_from_paths, parse_uri_list, ClipboardFile,
|
||||
};
|
||||
use dsh_core::launcher::{DshLauncher, DshProcess, URL_TIMEOUT_SECONDS};
|
||||
use dsh_core::modlens::ensure_modlens;
|
||||
use dsh_core::paths::BundledPaths;
|
||||
use dsh_core::preset::ensure_anchored_standard;
|
||||
use dsh_core::updater::{mark_update_checked, update_check_due, update_dsh};
|
||||
use dsh_core::{APP_NAME, ENV_NO_UPDATE};
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
use url::Url;
|
||||
|
||||
const INJECT: &str = concat!(
|
||||
include_str!("../../ui/inject/ingest.js"),
|
||||
include_str!("../../ui/inject/hide-twins.js"),
|
||||
include_str!("../../ui/inject/chrome.js"),
|
||||
);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Args {
|
||||
pub dsh: Option<String>,
|
||||
pub cwd: Option<PathBuf>,
|
||||
pub no_update: bool,
|
||||
pub force_update: bool,
|
||||
pub dev: bool,
|
||||
pub verbose: bool,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
pub fn parse() -> Self {
|
||||
let mut args = Args {
|
||||
dsh: std::env::var(dsh_core::ENV_BIN_OVERRIDE).ok().filter(|s| !s.is_empty()),
|
||||
cwd: std::env::var(dsh_core::ENV_CWD_OVERRIDE)
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(PathBuf::from),
|
||||
no_update: std::env::var(ENV_NO_UPDATE)
|
||||
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
|
||||
.unwrap_or(false),
|
||||
force_update: false,
|
||||
dev: false,
|
||||
verbose: false,
|
||||
};
|
||||
let mut argv = std::env::args().skip(1);
|
||||
while let Some(arg) = argv.next() {
|
||||
match arg.as_str() {
|
||||
"--dsh" => args.dsh = argv.next(),
|
||||
"--cwd" => args.cwd = argv.next().map(PathBuf::from),
|
||||
"--no-update" => args.no_update = true,
|
||||
"--update" => args.force_update = true,
|
||||
"--dev" => args.dev = true,
|
||||
"--verbose" => args.verbose = true,
|
||||
"--help" | "-h" => {
|
||||
print_help();
|
||||
std::process::exit(0);
|
||||
}
|
||||
"--version" | "-V" => {
|
||||
println!("{APP_NAME} {}", dsh_core::VERSION);
|
||||
std::process::exit(0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
args
|
||||
}
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
println!(
|
||||
"{APP_NAME} — native window around the official dsh WebUI.\n\n\
|
||||
--dsh PATH dsh 可执行文件路径或命令\n\
|
||||
--cwd DIR 传给 dsh 的工作目录\n\
|
||||
--no-update 不检查 dsh 更新\n\
|
||||
--update 启动前强制检查/安装 dsh 更新\n\
|
||||
--dev 打开 WebView 开发者工具\n\
|
||||
--verbose 输出调试日志"
|
||||
);
|
||||
}
|
||||
|
||||
struct AppState {
|
||||
args: Args,
|
||||
paths: BundledPaths,
|
||||
process: Mutex<Option<Arc<DshProcess>>>,
|
||||
url: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn stop(&self) {
|
||||
if let Ok(mut slot) = self.process.lock() {
|
||||
if let Some(proc) = slot.take() {
|
||||
proc.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct StatusPayload {
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct ErrorPayload {
|
||||
message: String,
|
||||
detail: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct ReadyPayload {
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn restart(app: AppHandle) {
|
||||
thread::spawn(move || boot(app));
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_in_browser(state: tauri::State<AppState>) -> Result<(), String> {
|
||||
let url = state
|
||||
.url
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|g| g.clone())
|
||||
.ok_or_else(|| "服务尚未就绪".to_string())?;
|
||||
open::that(&url).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_clipboard_images() -> Result<Vec<ClipboardFile>, String> {
|
||||
read_images().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn read_images() -> Result<Vec<ClipboardFile>, String> {
|
||||
let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
|
||||
if let Ok(img) = clipboard.get_image() {
|
||||
let width = img.width as u32;
|
||||
let height = img.height as u32;
|
||||
let bytes = img.bytes.into_owned();
|
||||
if let Some(buffer) = image::RgbaImage::from_raw(width, height, bytes) {
|
||||
let mut png = Vec::new();
|
||||
if buffer
|
||||
.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.is_ok()
|
||||
{
|
||||
if let Some(file) = file_from_bytes(
|
||||
filename_for_mime("image/png", 0),
|
||||
"image/png".into(),
|
||||
png,
|
||||
) {
|
||||
return Ok(vec![file]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(text) = clipboard.get_text() {
|
||||
let loaded = files_from_paths(parse_uri_list(&text));
|
||||
if !loaded.is_empty() {
|
||||
return Ok(loaded);
|
||||
}
|
||||
}
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
fn is_internal(url: &Url) -> bool {
|
||||
matches!(url.scheme(), "tauri" | "asset" | "about" | "data" | "blob")
|
||||
|| matches!(
|
||||
url.host_str(),
|
||||
Some("127.0.0.1" | "localhost" | "::1" | "tauri.localhost")
|
||||
)
|
||||
}
|
||||
|
||||
fn boot(app: AppHandle) {
|
||||
let Some(state) = app.try_state::<AppState>() else {
|
||||
return;
|
||||
};
|
||||
state.stop();
|
||||
let started = Instant::now();
|
||||
let _ = app.emit(
|
||||
"status",
|
||||
StatusPayload {
|
||||
message: "正在启动…".into(),
|
||||
},
|
||||
);
|
||||
|
||||
if state.args.force_update && !state.args.no_update {
|
||||
let _ = app.emit(
|
||||
"status",
|
||||
StatusPayload {
|
||||
message: "正在检查 dsh 更新…".into(),
|
||||
},
|
||||
);
|
||||
let result = update_dsh(true);
|
||||
mark_update_checked();
|
||||
log::info!("dsh update: {} ({})", result.status, result.message);
|
||||
}
|
||||
|
||||
let plugin = ensure_modlens(&state.paths);
|
||||
log::info!(
|
||||
"modlens: {} ({}) in {:?}",
|
||||
plugin.status,
|
||||
plugin.message,
|
||||
started.elapsed()
|
||||
);
|
||||
let preset = ensure_anchored_standard(&state.paths);
|
||||
log::info!(
|
||||
"anchored-standard: {} ({}) in {:?}",
|
||||
preset.status,
|
||||
preset.message,
|
||||
started.elapsed()
|
||||
);
|
||||
let _ = app.emit(
|
||||
"status",
|
||||
StatusPayload {
|
||||
message: "正在启动 dsh web 服务…".into(),
|
||||
},
|
||||
);
|
||||
|
||||
let launcher = DshLauncher::new(state.args.dsh.clone(), state.args.cwd.clone());
|
||||
let process = match launcher.start() {
|
||||
Ok(p) => Arc::new(p),
|
||||
Err(err) => {
|
||||
let _ = app.emit(
|
||||
"error",
|
||||
ErrorPayload {
|
||||
message: err.to_string(),
|
||||
detail: String::new(),
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Ok(mut slot) = state.process.lock() {
|
||||
*slot = Some(Arc::clone(&process));
|
||||
}
|
||||
log::info!("dsh web spawned in {:?}", started.elapsed());
|
||||
|
||||
if !state.args.no_update && !state.args.force_update && update_check_due() {
|
||||
thread::spawn(|| {
|
||||
log::info!("background dsh update check");
|
||||
let result = update_dsh(true);
|
||||
mark_update_checked();
|
||||
log::info!(
|
||||
"background dsh update: {} ({})",
|
||||
result.status,
|
||||
result.message
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(URL_TIMEOUT_SECONDS);
|
||||
loop {
|
||||
if let Some(url) = process.take_url() {
|
||||
if let Ok(mut slot) = state.url.lock() {
|
||||
*slot = Some(url.clone());
|
||||
}
|
||||
let _ = app.emit("ready", ReadyPayload { url });
|
||||
log::info!("dsh web ready in {:?}", started.elapsed());
|
||||
return;
|
||||
}
|
||||
if let Some(code) = process.poll() {
|
||||
let detail = process.snapshot_lines().join("\n");
|
||||
let _ = app.emit(
|
||||
"error",
|
||||
ErrorPayload {
|
||||
message: format!("服务已退出(exit {code})。"),
|
||||
detail,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let detail = process
|
||||
.snapshot_lines()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.take(80)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
process.stop();
|
||||
let _ = app.emit(
|
||||
"error",
|
||||
ErrorPayload {
|
||||
message: format!("等待 dsh web 输出 URL 超时({URL_TIMEOUT_SECONDS} 秒)。"),
|
||||
detail,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(80));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
let args = Args::parse();
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(if args.verbose {
|
||||
"debug"
|
||||
} else {
|
||||
"info"
|
||||
}))
|
||||
.init();
|
||||
|
||||
let mut paths = BundledPaths::discover();
|
||||
let dev = args.dev;
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}))
|
||||
.setup(move |app| {
|
||||
if let Ok(dir) = app.path().resource_dir() {
|
||||
paths = paths.with_resource_dir(dir);
|
||||
}
|
||||
app.manage(AppState {
|
||||
args: args.clone(),
|
||||
paths,
|
||||
process: Mutex::new(None),
|
||||
url: Mutex::new(None),
|
||||
});
|
||||
|
||||
let mut builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into()))
|
||||
.title(APP_NAME)
|
||||
.inner_size(1320.0, 860.0)
|
||||
.min_inner_size(800.0, 560.0)
|
||||
.decorations(false)
|
||||
.resizable(true)
|
||||
.initialization_script(INJECT)
|
||||
.on_navigation(|url| {
|
||||
if is_internal(&url) {
|
||||
true
|
||||
} else {
|
||||
let _ = open::that(url.as_str());
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(icon) = app.default_window_icon().cloned() {
|
||||
builder = builder.icon(icon)?;
|
||||
}
|
||||
|
||||
if dev {
|
||||
builder = builder.devtools(true);
|
||||
}
|
||||
|
||||
let window = builder.build()?;
|
||||
if dev {
|
||||
window.open_devtools();
|
||||
}
|
||||
|
||||
let app_handle = app.handle().clone();
|
||||
let _ = window;
|
||||
thread::spawn(move || boot(app_handle));
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
restart,
|
||||
open_in_browser,
|
||||
read_clipboard_images
|
||||
])
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||
if let Some(state) = window.try_state::<AppState>() {
|
||||
state.stop();
|
||||
}
|
||||
}
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running DeepSeek Harness Desktop");
|
||||
}
|
||||
5
src-tauri/src/main.rs
Normal file
5
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
dsh_desktop_lib::run();
|
||||
}
|
||||
71
src-tauri/tauri.conf.json
Normal file
71
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "DeepSeek Harness",
|
||||
"version": "0.1.0",
|
||||
"identifier": "io.github.tommyfang.DshDesktop",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"enableGTKAppId": true,
|
||||
"macOSPrivateApi": false,
|
||||
"windows": [],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["deb", "rpm", "nsis", "msi", "app", "dmg"],
|
||||
"publisher": "TommyFang2077",
|
||||
"homepage": "https://github.com/TommyFang2077/dsh-desktop",
|
||||
"copyright": "Copyright © 2026 TommyFang2077",
|
||||
"category": "DeveloperTool",
|
||||
"shortDescription": "Desktop shell for DeepSeek Harness",
|
||||
"longDescription": "Runs the official DeepSeek Harness (dsh) WebUI in a native window. Starts dsh web, embeds the UI with the system WebView, and ships ModLens plus Anchored Standard presets. Credentials stay in ~/.dsh.",
|
||||
"licenseFile": "../LICENSE",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/64x64.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"resources": [
|
||||
"../plugins/dsh-desktop-vision/",
|
||||
"../vendor/modlens/",
|
||||
"../vendor/anchored-standard/",
|
||||
"../vendor/zero-anchored-standard/"
|
||||
],
|
||||
"linux": {
|
||||
"deb": {
|
||||
"section": "devel",
|
||||
"desktopTemplate": "linux/dsh-desktop.desktop"
|
||||
},
|
||||
"rpm": {
|
||||
"release": "1",
|
||||
"desktopTemplate": "linux/dsh-desktop.desktop"
|
||||
}
|
||||
},
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installMode": "currentUser",
|
||||
"displayLanguageSelector": true,
|
||||
"languages": ["SimpChinese", "English"]
|
||||
},
|
||||
"webviewInstallMode": {
|
||||
"type": "downloadBootstrapper"
|
||||
}
|
||||
},
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15",
|
||||
"dmg": {
|
||||
"appPosition": { "x": 180, "y": 170 },
|
||||
"applicationFolderPosition": { "x": 480, "y": 170 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user