@lenorin/dsh-tauri-launcher 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/cordis.patch.yml +7 -0
- package/launcher/README.md +56 -0
- package/launcher/bin/dsh-launcher.exe +0 -0
- package/launcher/build.ps1 +44 -0
- package/launcher/src-tauri/Cargo.toml +20 -0
- package/launcher/src-tauri/build.rs +3 -0
- package/launcher/src-tauri/capabilities/default.json +9 -0
- package/launcher/src-tauri/icons/128x128.png +0 -0
- package/launcher/src-tauri/icons/128x128@2x.png +0 -0
- package/launcher/src-tauri/icons/32x32.png +0 -0
- package/launcher/src-tauri/icons/Square107x107Logo.png +0 -0
- package/launcher/src-tauri/icons/Square142x142Logo.png +0 -0
- package/launcher/src-tauri/icons/Square150x150Logo.png +0 -0
- package/launcher/src-tauri/icons/Square284x284Logo.png +0 -0
- package/launcher/src-tauri/icons/Square30x30Logo.png +0 -0
- package/launcher/src-tauri/icons/Square310x310Logo.png +0 -0
- package/launcher/src-tauri/icons/Square44x44Logo.png +0 -0
- package/launcher/src-tauri/icons/Square71x71Logo.png +0 -0
- package/launcher/src-tauri/icons/Square89x89Logo.png +0 -0
- package/launcher/src-tauri/icons/StoreLogo.png +0 -0
- package/launcher/src-tauri/icons/icon.icns +0 -0
- package/launcher/src-tauri/icons/icon.ico +0 -0
- package/launcher/src-tauri/icons/icon.png +0 -0
- package/launcher/src-tauri/src/dsh.rs +286 -0
- package/launcher/src-tauri/src/lib.rs +727 -0
- package/launcher/src-tauri/src/main.rs +6 -0
- package/launcher/src-tauri/tauri.conf.json +38 -0
- package/launcher/ui/index.html +64 -0
- package/launcher/ui/main.js +122 -0
- package/launcher/ui/settings.css +157 -0
- package/launcher/ui/settings.html +71 -0
- package/launcher/ui/settings.js +83 -0
- package/launcher/ui/styles.css +212 -0
- package/lib/client.js +241 -0
- package/lib/index.js +445 -0
- package/package.json +28 -0
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
//! Tauri 主逻辑:命令注册、系统托盘、退出时终止 DeepSeek Harness 进程。
|
|
2
|
+
|
|
3
|
+
mod dsh;
|
|
4
|
+
|
|
5
|
+
use std::collections::VecDeque;
|
|
6
|
+
use std::process::Command as StdCommand;
|
|
7
|
+
use std::sync::atomic::{AtomicBool, Ordering};
|
|
8
|
+
use std::sync::{Arc, Mutex};
|
|
9
|
+
use std::time::{Duration, Instant};
|
|
10
|
+
|
|
11
|
+
use serde::{Deserialize, Serialize};
|
|
12
|
+
use tauri::menu::{Menu, MenuItem};
|
|
13
|
+
use tauri::tray::TrayIconBuilder;
|
|
14
|
+
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder};
|
|
15
|
+
use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
|
|
16
|
+
use tokio::io::{AsyncBufReadExt, BufReader};
|
|
17
|
+
use std::path::PathBuf;
|
|
18
|
+
use std::process::Stdio;
|
|
19
|
+
|
|
20
|
+
/// 与 Web 设置插件(deepseek-harness-tauri)协作的标记文件,位于启动器 exe 同目录:
|
|
21
|
+
/// `.dsh-heartbeat` 每秒写入一次时间戳(供插件判断进程存活);
|
|
22
|
+
/// `.dsh-quit` 存在 → 仅退出桌面应用本身。开机启动与全局快捷方式由本应用的设置窗口控制。
|
|
23
|
+
const HEARTBEAT_MARKER: &str = ".dsh-heartbeat";
|
|
24
|
+
const QUIT_MARKER: &str = ".dsh-quit";
|
|
25
|
+
const CONFIG_FILE: &str = ".dsh-config.json";
|
|
26
|
+
const RUN_KEY: &str = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run";
|
|
27
|
+
const RUN_NAME: &str = "DeepSeekHarness";
|
|
28
|
+
/// 全局快捷键(按下即唤起主窗口)。设置窗口勾选=注册,取消=注销。
|
|
29
|
+
const HOTKEY: &str = "ctrl+shift+h";
|
|
30
|
+
/// 应用图标源(512×512 PNG,编译期内嵌):托盘/窗口/任务栏共用,
|
|
31
|
+
/// 避免 Tauri 默认 32×32 窗口图标在高 DPI 下被非整数缩放而发虚。
|
|
32
|
+
const APP_ICON_BYTES: &[u8] = include_bytes!("../icons/icon.png");
|
|
33
|
+
|
|
34
|
+
/// 解码内嵌的应用图标。
|
|
35
|
+
fn app_icon() -> tauri::image::Image<'static> {
|
|
36
|
+
tauri::image::Image::from_bytes(APP_ICON_BYTES).expect("应用图标解码失败")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// 应用全局状态:被托管的 dsh 子进程及其诊断信息。
|
|
40
|
+
#[derive(Default)]
|
|
41
|
+
pub struct AppState {
|
|
42
|
+
/// 由本应用启动的 dsh 子进程。
|
|
43
|
+
pub child: tokio::sync::Mutex<Option<tokio::process::Child>>,
|
|
44
|
+
/// 子进程 PID(用于 taskkill /T 结束整棵进程树)。
|
|
45
|
+
pub pid: Mutex<Option<u32>>,
|
|
46
|
+
/// 该进程是否由本应用启动(true 时退出必须终止;false 表示接管了已有实例)。
|
|
47
|
+
pub owned: AtomicBool,
|
|
48
|
+
/// 已确认可用的 Web GUI 地址。
|
|
49
|
+
pub url: Mutex<Option<String>>,
|
|
50
|
+
/// 子进程最近的输出(诊断用)。
|
|
51
|
+
pub log_tail: Arc<Mutex<VecDeque<String>>>,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
#[derive(Serialize, Clone)]
|
|
55
|
+
pub struct InstallLine {
|
|
56
|
+
pub stream: String,
|
|
57
|
+
pub line: String,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#[derive(Serialize, Clone)]
|
|
61
|
+
pub struct LaunchInfo {
|
|
62
|
+
pub url: String,
|
|
63
|
+
pub port: u16,
|
|
64
|
+
pub owned: bool,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
fn tail_text(state: &AppState) -> String {
|
|
68
|
+
state
|
|
69
|
+
.log_tail
|
|
70
|
+
.lock()
|
|
71
|
+
.map(|q| q.iter().cloned().collect::<Vec<_>>().join("\n"))
|
|
72
|
+
.unwrap_or_default()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// 桌面应用自身的持久化设置(与 exe 同目录的 `.dsh-config.json`)。
|
|
76
|
+
/// 开机启动以注册表为准,无需在此持久化;全局快捷键的勾选状态在此保存。
|
|
77
|
+
#[derive(Serialize, Deserialize, Default)]
|
|
78
|
+
struct LauncherConfig {
|
|
79
|
+
#[serde(default)]
|
|
80
|
+
pub global_shortcut: bool,
|
|
81
|
+
/// 退出时是否一并结束 DeepSeek Harness 进程(默认 false:只退出启动器)。
|
|
82
|
+
#[serde(default)]
|
|
83
|
+
pub terminate_harness_on_exit: bool,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
fn config_path() -> Option<PathBuf> {
|
|
87
|
+
std::env::current_exe()
|
|
88
|
+
.ok()?
|
|
89
|
+
.parent()
|
|
90
|
+
.map(|dir| dir.join(CONFIG_FILE))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
fn load_config() -> LauncherConfig {
|
|
94
|
+
config_path()
|
|
95
|
+
.and_then(|p| std::fs::read_to_string(p).ok())
|
|
96
|
+
.and_then(|s| serde_json::from_str::<LauncherConfig>(&s).ok())
|
|
97
|
+
.unwrap_or_default()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
fn save_config(config: &LauncherConfig) {
|
|
101
|
+
if let Some(path) = config_path() {
|
|
102
|
+
if let Ok(json) = serde_json::to_string_pretty(config) {
|
|
103
|
+
let _ = std::fs::write(path, json);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/// 查询 Windows 开机自启是否已启用(HKCU Run 键是否存在)。
|
|
109
|
+
fn autostart_enabled() -> bool {
|
|
110
|
+
#[cfg(windows)]
|
|
111
|
+
{
|
|
112
|
+
use std::os::windows::process::CommandExt;
|
|
113
|
+
let out = StdCommand::new("reg")
|
|
114
|
+
.args(["query", RUN_KEY, "/v", RUN_NAME])
|
|
115
|
+
.creation_flags(0x0800_0000)
|
|
116
|
+
.output();
|
|
117
|
+
matches!(out, Ok(o) if o.status.success())
|
|
118
|
+
}
|
|
119
|
+
#[cfg(not(windows))]
|
|
120
|
+
{
|
|
121
|
+
false
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/// 写入/删除 Windows 开机自启(HKCU Run 键,幂等;`reg add /f`)。
|
|
126
|
+
/// 由本应用(不受沙箱限制的桌面进程)执行,是开机启动的唯一控制入口。
|
|
127
|
+
/// 返回操作后的注册表状态是否与目标一致。
|
|
128
|
+
fn set_autostart(enabled: bool) -> bool {
|
|
129
|
+
let Ok(exe) = std::env::current_exe() else {
|
|
130
|
+
return false;
|
|
131
|
+
};
|
|
132
|
+
let exe_path = exe.to_string_lossy().into_owned();
|
|
133
|
+
#[cfg(windows)]
|
|
134
|
+
{
|
|
135
|
+
use std::os::windows::process::CommandExt;
|
|
136
|
+
let res = if enabled {
|
|
137
|
+
StdCommand::new("reg")
|
|
138
|
+
.args([
|
|
139
|
+
"add",
|
|
140
|
+
RUN_KEY,
|
|
141
|
+
"/v",
|
|
142
|
+
RUN_NAME,
|
|
143
|
+
"/t",
|
|
144
|
+
"REG_SZ",
|
|
145
|
+
"/d",
|
|
146
|
+
&exe_path,
|
|
147
|
+
"/f",
|
|
148
|
+
])
|
|
149
|
+
.creation_flags(0x0800_0000)
|
|
150
|
+
.status()
|
|
151
|
+
} else {
|
|
152
|
+
StdCommand::new("reg")
|
|
153
|
+
.args(["delete", RUN_KEY, "/v", RUN_NAME, "/f"])
|
|
154
|
+
.creation_flags(0x0800_0000)
|
|
155
|
+
.status()
|
|
156
|
+
};
|
|
157
|
+
let _ = res;
|
|
158
|
+
}
|
|
159
|
+
autostart_enabled() == enabled
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// 注册/注销全局快捷键。注册后按下热键即显示并聚焦主窗口(DeepSeek Harness)。
|
|
163
|
+
fn apply_global_shortcut(app: &AppHandle, enabled: bool) -> Result<(), String> {
|
|
164
|
+
let gs = app.global_shortcut();
|
|
165
|
+
if enabled {
|
|
166
|
+
gs.on_shortcut(HOTKEY, move |app, _shortcut, event| {
|
|
167
|
+
if event.state == ShortcutState::Pressed {
|
|
168
|
+
if let Some(w) = app.get_webview_window("main") {
|
|
169
|
+
let _ = w.show();
|
|
170
|
+
let _ = w.unminimize();
|
|
171
|
+
let _ = w.set_focus();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
.map_err(|e| format!("注册全局快捷键失败:{e}"))
|
|
176
|
+
} else {
|
|
177
|
+
gs.unregister(HOTKEY)
|
|
178
|
+
.map_err(|e| format!("注销全局快捷键失败:{e}"))
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/// 桌面目录(只解析一次并缓存,避免每次查询都拉起 PowerShell)。
|
|
183
|
+
static DESKTOP_DIR: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();
|
|
184
|
+
|
|
185
|
+
/// 解析用户桌面目录(`[Environment]::GetFolderPath('Desktop')` 自动处理 OneDrive 重定向)。
|
|
186
|
+
fn desktop_dir() -> Option<PathBuf> {
|
|
187
|
+
DESKTOP_DIR
|
|
188
|
+
.get_or_init(|| {
|
|
189
|
+
#[cfg(windows)]
|
|
190
|
+
{
|
|
191
|
+
use std::os::windows::process::CommandExt;
|
|
192
|
+
let out = StdCommand::new("powershell")
|
|
193
|
+
.args([
|
|
194
|
+
"-NoProfile",
|
|
195
|
+
"-NonInteractive",
|
|
196
|
+
"-Command",
|
|
197
|
+
"[Environment]::GetFolderPath('Desktop')",
|
|
198
|
+
])
|
|
199
|
+
.creation_flags(0x0800_0000)
|
|
200
|
+
.output()
|
|
201
|
+
.ok()?;
|
|
202
|
+
if !out.status.success() {
|
|
203
|
+
return None;
|
|
204
|
+
}
|
|
205
|
+
let dir = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
|
206
|
+
if dir.is_empty() {
|
|
207
|
+
None
|
|
208
|
+
} else {
|
|
209
|
+
Some(PathBuf::from(dir))
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
#[cfg(not(windows))]
|
|
213
|
+
{
|
|
214
|
+
None
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
.clone()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
fn shortcut_path() -> Option<PathBuf> {
|
|
221
|
+
desktop_dir().map(|dir| dir.join("DeepSeek Harness.lnk"))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/// 桌面快捷方式是否存在(.lnk 文件本身即状态,无需持久化)。
|
|
225
|
+
fn desktop_shortcut_exists() -> bool {
|
|
226
|
+
shortcut_path().map(|p| p.exists()).unwrap_or(false)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/// PowerShell 单引号字符串转义:内部单引号翻倍。
|
|
230
|
+
fn ps_quote(s: &str) -> String {
|
|
231
|
+
format!("'{}'", s.replace('\'', "''"))
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/// 创建/删除桌面快捷方式(.lnk 指向本应用 exe,含图标),返回操作后状态是否与目标一致。
|
|
235
|
+
fn set_desktop_shortcut(enabled: bool) -> bool {
|
|
236
|
+
if !enabled {
|
|
237
|
+
// 删除:文件本就不存在也视为成功(以终态为准)。
|
|
238
|
+
if let Some(p) = shortcut_path() {
|
|
239
|
+
let _ = std::fs::remove_file(&p);
|
|
240
|
+
}
|
|
241
|
+
return desktop_shortcut_exists() == enabled;
|
|
242
|
+
}
|
|
243
|
+
#[cfg(windows)]
|
|
244
|
+
{
|
|
245
|
+
use std::os::windows::process::CommandExt;
|
|
246
|
+
let Ok(exe) = std::env::current_exe() else {
|
|
247
|
+
return false;
|
|
248
|
+
};
|
|
249
|
+
let Some(lnk) = shortcut_path() else {
|
|
250
|
+
return false;
|
|
251
|
+
};
|
|
252
|
+
let exe_str = exe.to_string_lossy().into_owned();
|
|
253
|
+
let dir = exe
|
|
254
|
+
.parent()
|
|
255
|
+
.map(|d| d.to_string_lossy().into_owned())
|
|
256
|
+
.unwrap_or_default();
|
|
257
|
+
// IconLocation 的逗号必须整体位于引号内,避免被 PowerShell 解析成数组。
|
|
258
|
+
let script = format!(
|
|
259
|
+
"$ws=New-Object -ComObject WScript.Shell; $sc=$ws.CreateShortcut({lnk}); \
|
|
260
|
+
$sc.TargetPath={exe}; $sc.WorkingDirectory={dir}; $sc.IconLocation={icon}; \
|
|
261
|
+
$sc.Description='DeepSeek Harness 桌面启动器'; $sc.Save()",
|
|
262
|
+
lnk = ps_quote(&lnk.to_string_lossy()),
|
|
263
|
+
exe = ps_quote(&exe_str),
|
|
264
|
+
dir = ps_quote(&dir),
|
|
265
|
+
icon = ps_quote(&format!("{exe_str},0")),
|
|
266
|
+
);
|
|
267
|
+
let res = StdCommand::new("powershell")
|
|
268
|
+
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
|
|
269
|
+
.creation_flags(0x0800_0000)
|
|
270
|
+
.status();
|
|
271
|
+
let _ = res;
|
|
272
|
+
}
|
|
273
|
+
desktop_shortcut_exists() == enabled
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/// 打开设置窗口(首次点击时创建,之后显示已存在的实例)。
|
|
277
|
+
fn show_settings(app: &AppHandle) -> tauri::Result<()> {
|
|
278
|
+
if let Some(w) = app.get_webview_window("settings") {
|
|
279
|
+
let _ = w.show();
|
|
280
|
+
let _ = w.unminimize();
|
|
281
|
+
let _ = w.set_focus();
|
|
282
|
+
return Ok(());
|
|
283
|
+
}
|
|
284
|
+
WebviewWindowBuilder::new(app, "settings", WebviewUrl::App("settings.html".into()))
|
|
285
|
+
.title("设置")
|
|
286
|
+
.inner_size(420.0, 540.0)
|
|
287
|
+
.resizable(false)
|
|
288
|
+
.maximizable(false)
|
|
289
|
+
.minimizable(false)
|
|
290
|
+
.center()
|
|
291
|
+
.build()?;
|
|
292
|
+
if let Some(w) = app.get_webview_window("settings") {
|
|
293
|
+
let _ = w.set_icon(app_icon());
|
|
294
|
+
}
|
|
295
|
+
Ok(())
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/// 放弃对 dsh 子进程的托管(孤儿继续运行),并清空托管状态。同步版本。
|
|
299
|
+
fn orphan_harness(app: &AppHandle) {
|
|
300
|
+
let state = app.state::<AppState>();
|
|
301
|
+
{
|
|
302
|
+
let mut guard = state.child.blocking_lock();
|
|
303
|
+
if let Some(child) = guard.take() {
|
|
304
|
+
// forget 阻止 drop 触发 kill_on_drop,孤儿继续运行;句柄由系统回收。
|
|
305
|
+
std::mem::forget(child);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if let Ok(mut guard) = state.pid.lock() {
|
|
309
|
+
guard.take();
|
|
310
|
+
}
|
|
311
|
+
state.owned.store(false, Ordering::SeqCst);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/// 兜底结束占用 DSH 端口的进程(用于“退出时结束 Harness”且实例非本启动器启动的情况)。
|
|
315
|
+
fn kill_dsh_port_owner() {
|
|
316
|
+
#[cfg(windows)]
|
|
317
|
+
{
|
|
318
|
+
use std::os::windows::process::CommandExt;
|
|
319
|
+
let script = format!(
|
|
320
|
+
"$p = Get-NetTCPConnection -LocalPort {} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty OwningProcess; if ($p) {{ Stop-Process -Id $p -Force -ErrorAction SilentlyContinue }}",
|
|
321
|
+
dsh::DSH_PORT
|
|
322
|
+
);
|
|
323
|
+
let _ = StdCommand::new("powershell")
|
|
324
|
+
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
|
|
325
|
+
.creation_flags(0x0800_0000)
|
|
326
|
+
.status();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/// 按“退出时是否结束 Harness”设置统一执行的退出动作,由托盘退出、
|
|
331
|
+
/// 标记退出与进程退出共用:
|
|
332
|
+
/// - 结束:终止本启动器托管的 dsh 进程树,并兜底关闭 DSH 端口进程;
|
|
333
|
+
/// - 保留(默认):放弃托管,Harness 孤儿继续运行。
|
|
334
|
+
fn exit_launcher(app: &AppHandle) {
|
|
335
|
+
if load_config().terminate_harness_on_exit {
|
|
336
|
+
cleanup_on_exit(app);
|
|
337
|
+
kill_dsh_port_owner();
|
|
338
|
+
} else {
|
|
339
|
+
orphan_harness(app);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/// 响应“仅退出桌面应用”请求(`.dsh-quit` 标记):按设置结束或保留 Harness。
|
|
344
|
+
async fn quit_via_marker(app: &AppHandle) {
|
|
345
|
+
if load_config().terminate_harness_on_exit {
|
|
346
|
+
cleanup_on_exit(app);
|
|
347
|
+
kill_dsh_port_owner();
|
|
348
|
+
} else {
|
|
349
|
+
let state = app.state::<AppState>();
|
|
350
|
+
let mut guard = state.child.lock().await;
|
|
351
|
+
if let Some(child) = guard.take() {
|
|
352
|
+
std::mem::forget(child);
|
|
353
|
+
}
|
|
354
|
+
if let Ok(mut guard) = state.pid.lock() {
|
|
355
|
+
guard.take();
|
|
356
|
+
}
|
|
357
|
+
state.owned.store(false, Ordering::SeqCst);
|
|
358
|
+
}
|
|
359
|
+
app.exit(0);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
#[tauri::command]
|
|
363
|
+
async fn check_dsh() -> dsh::CheckResult {
|
|
364
|
+
dsh::check()
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
#[tauri::command]
|
|
368
|
+
async fn install_dsh(app: AppHandle) -> Result<String, String> {
|
|
369
|
+
dsh::install(move |stream: &str, line: &str| {
|
|
370
|
+
let _ = app.emit(
|
|
371
|
+
"install-output",
|
|
372
|
+
InstallLine {
|
|
373
|
+
stream: stream.to_string(),
|
|
374
|
+
line: line.to_string(),
|
|
375
|
+
},
|
|
376
|
+
);
|
|
377
|
+
})
|
|
378
|
+
.await
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/// 终止由本应用启动的 dsh 进程树(异步版本)。
|
|
382
|
+
async fn kill_child(state: &AppState) {
|
|
383
|
+
let pid = state.pid.lock().ok().and_then(|mut g| g.take());
|
|
384
|
+
if let Some(pid) = pid {
|
|
385
|
+
#[cfg(windows)]
|
|
386
|
+
let res = tokio::process::Command::new("taskkill")
|
|
387
|
+
.args(["/PID", &pid.to_string(), "/T", "/F"])
|
|
388
|
+
.creation_flags(0x0800_0000)
|
|
389
|
+
.output()
|
|
390
|
+
.await;
|
|
391
|
+
#[cfg(not(windows))]
|
|
392
|
+
let res = tokio::process::Command::new("kill")
|
|
393
|
+
.args(["-9", &pid.to_string()])
|
|
394
|
+
.output()
|
|
395
|
+
.await;
|
|
396
|
+
let _ = res;
|
|
397
|
+
}
|
|
398
|
+
if let Some(mut child) = state.child.lock().await.take() {
|
|
399
|
+
let _ = child.kill().await;
|
|
400
|
+
}
|
|
401
|
+
state.owned.store(false, Ordering::SeqCst);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
#[tauri::command]
|
|
405
|
+
async fn stop_dsh(state: State<'_, AppState>) -> Result<(), String> {
|
|
406
|
+
kill_child(&state).await;
|
|
407
|
+
Ok(())
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/// 设置窗口当前快照:开机启动、全局快捷键、桌面快捷方式、退出行为与热键组合。
|
|
411
|
+
#[derive(Serialize, Clone)]
|
|
412
|
+
pub struct SettingsSnapshot {
|
|
413
|
+
pub autostart: bool,
|
|
414
|
+
pub global_shortcut: bool,
|
|
415
|
+
pub desktop_shortcut: bool,
|
|
416
|
+
pub terminate_harness_on_exit: bool,
|
|
417
|
+
pub hotkey: String,
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
#[tauri::command]
|
|
421
|
+
fn get_settings() -> SettingsSnapshot {
|
|
422
|
+
SettingsSnapshot {
|
|
423
|
+
autostart: autostart_enabled(),
|
|
424
|
+
global_shortcut: load_config().global_shortcut,
|
|
425
|
+
desktop_shortcut: desktop_shortcut_exists(),
|
|
426
|
+
terminate_harness_on_exit: load_config().terminate_harness_on_exit,
|
|
427
|
+
hotkey: HOTKEY.to_string(),
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
#[tauri::command]
|
|
432
|
+
fn set_autostart_setting(enabled: bool) -> Result<(), String> {
|
|
433
|
+
if set_autostart(enabled) {
|
|
434
|
+
Ok(())
|
|
435
|
+
} else {
|
|
436
|
+
Err("设置开机启动失败,请检查注册表写入权限。".to_string())
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
#[tauri::command]
|
|
441
|
+
fn set_global_shortcut_setting(app: AppHandle, enabled: bool) -> Result<(), String> {
|
|
442
|
+
apply_global_shortcut(&app, enabled)?;
|
|
443
|
+
let mut config = load_config();
|
|
444
|
+
config.global_shortcut = enabled;
|
|
445
|
+
save_config(&config);
|
|
446
|
+
Ok(())
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
#[tauri::command]
|
|
450
|
+
fn set_desktop_shortcut_setting(enabled: bool) -> Result<(), String> {
|
|
451
|
+
if set_desktop_shortcut(enabled) {
|
|
452
|
+
Ok(())
|
|
453
|
+
} else {
|
|
454
|
+
Err("创建/删除桌面快捷方式失败,请检查桌面目录权限。".to_string())
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
#[tauri::command]
|
|
459
|
+
fn set_terminate_harness_on_exit_setting(enabled: bool) -> Result<(), String> {
|
|
460
|
+
let mut config = load_config();
|
|
461
|
+
config.terminate_harness_on_exit = enabled;
|
|
462
|
+
save_config(&config);
|
|
463
|
+
Ok(())
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/// 关闭(隐藏)设置窗口。窗口的 X 按钮同样触发 CloseRequested → 隐藏到托盘。
|
|
467
|
+
#[tauri::command]
|
|
468
|
+
fn close_settings(app: AppHandle) {
|
|
469
|
+
if let Some(w) = app.get_webview_window("settings") {
|
|
470
|
+
let _ = w.hide();
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/// 确保 DeepSeek Harness 已启动并返回可访问的 Web GUI 地址。
|
|
475
|
+
/// 若默认端口上已有实例在运行则直接接管;否则拉起 `dsh web` 并等待就绪。
|
|
476
|
+
#[tauri::command]
|
|
477
|
+
async fn launch_dsh(state: State<'_, AppState>) -> Result<LaunchInfo, String> {
|
|
478
|
+
// 1) 端口上已有 DeepSeek Harness 实例 → 直接接管(退出时不终止它)。
|
|
479
|
+
if dsh::is_dsh_serving(dsh::DSH_PORT).await {
|
|
480
|
+
let url = dsh::DSH_URL.to_string();
|
|
481
|
+
*state.url.lock().map_err(|_| "应用状态不可用")? = Some(url.clone());
|
|
482
|
+
state.owned.store(false, Ordering::SeqCst);
|
|
483
|
+
return Ok(LaunchInfo {
|
|
484
|
+
url,
|
|
485
|
+
port: dsh::DSH_PORT,
|
|
486
|
+
owned: false,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// 2) 拉起本地安装的 `dsh web`。
|
|
491
|
+
let check = dsh::check();
|
|
492
|
+
if !check.installed {
|
|
493
|
+
return Err("DeepSeek Harness 尚未安装,请先完成安装。".to_string());
|
|
494
|
+
}
|
|
495
|
+
if check.node_ok == false {
|
|
496
|
+
return Err("未检测到 Node.js,无法启动 DeepSeek Harness。".to_string());
|
|
497
|
+
}
|
|
498
|
+
let bin = check.bin_path.clone().ok_or("找不到 dsh 入口脚本(lib/bin.js)。")?;
|
|
499
|
+
let node = dsh::node_exe().ok_or("未找到 node.exe,请确认 Node.js 已正确安装。")?;
|
|
500
|
+
|
|
501
|
+
let mut cmd = tokio::process::Command::new(&node);
|
|
502
|
+
cmd.arg(&bin)
|
|
503
|
+
.arg("web")
|
|
504
|
+
.stdout(Stdio::piped())
|
|
505
|
+
.stderr(Stdio::piped())
|
|
506
|
+
.kill_on_drop(true);
|
|
507
|
+
#[cfg(windows)]
|
|
508
|
+
cmd.creation_flags(0x0800_0000);
|
|
509
|
+
let mut child = cmd.spawn().map_err(|e| format!("无法启动 dsh 进程:{e}"))?;
|
|
510
|
+
let pid = child.id();
|
|
511
|
+
|
|
512
|
+
let tail = state.log_tail.clone();
|
|
513
|
+
if let Some(out) = child.stdout.take() {
|
|
514
|
+
let tail2 = tail.clone();
|
|
515
|
+
tokio::spawn(async move {
|
|
516
|
+
let mut lines = BufReader::new(out).lines();
|
|
517
|
+
while let Ok(Some(line)) = lines.next_line().await {
|
|
518
|
+
if let Ok(mut q) = tail2.lock() {
|
|
519
|
+
if q.len() >= 256 {
|
|
520
|
+
q.pop_front();
|
|
521
|
+
}
|
|
522
|
+
q.push_back(format!("[stdout] {line}"));
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
if let Some(err) = child.stderr.take() {
|
|
528
|
+
let tail2 = tail.clone();
|
|
529
|
+
tokio::spawn(async move {
|
|
530
|
+
let mut lines = BufReader::new(err).lines();
|
|
531
|
+
while let Ok(Some(line)) = lines.next_line().await {
|
|
532
|
+
if let Ok(mut q) = tail2.lock() {
|
|
533
|
+
if q.len() >= 256 {
|
|
534
|
+
q.pop_front();
|
|
535
|
+
}
|
|
536
|
+
q.push_back(format!("[stderr] {line}"));
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
state.child.lock().await.replace(child);
|
|
543
|
+
*state.pid.lock().map_err(|_| "应用状态不可用")? = pid;
|
|
544
|
+
state.owned.store(true, Ordering::SeqCst);
|
|
545
|
+
let url = dsh::DSH_URL.to_string();
|
|
546
|
+
|
|
547
|
+
// 3) 等待 Web GUI 就绪(首次启动可能需要初始化,留足超时)。
|
|
548
|
+
let deadline = Instant::now() + Duration::from_secs(180);
|
|
549
|
+
loop {
|
|
550
|
+
if dsh::is_dsh_serving(dsh::DSH_PORT).await {
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
let exited = state
|
|
554
|
+
.child
|
|
555
|
+
.lock()
|
|
556
|
+
.await
|
|
557
|
+
.as_mut()
|
|
558
|
+
.and_then(|c| c.try_wait().ok().flatten());
|
|
559
|
+
if let Some(status) = exited {
|
|
560
|
+
let reason = tail_text(&state);
|
|
561
|
+
let hint = if reason.contains("EADDRINUSE") {
|
|
562
|
+
"\n提示:端口 3080 已被其他程序占用,请先释放该端口。"
|
|
563
|
+
} else {
|
|
564
|
+
""
|
|
565
|
+
};
|
|
566
|
+
return Err(format!(
|
|
567
|
+
"DeepSeek Harness 进程提前退出({status}){hint}\n{reason}"
|
|
568
|
+
));
|
|
569
|
+
}
|
|
570
|
+
if Instant::now() >= deadline {
|
|
571
|
+
kill_child(&state).await;
|
|
572
|
+
return Err(format!("启动超时(180 秒):\n{}", tail_text(&state)));
|
|
573
|
+
}
|
|
574
|
+
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
*state.url.lock().map_err(|_| "应用状态不可用")? = Some(url.clone());
|
|
578
|
+
Ok(LaunchInfo {
|
|
579
|
+
url,
|
|
580
|
+
port: dsh::DSH_PORT,
|
|
581
|
+
owned: true,
|
|
582
|
+
})
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/// 同步清理:托盘退出与进程退出时的兜底,结束由本应用启动的 dsh 进程树。
|
|
586
|
+
fn cleanup_on_exit(app: &AppHandle) {
|
|
587
|
+
let state = app.state::<AppState>();
|
|
588
|
+
let pid = state.pid.lock().ok().and_then(|mut g| g.take());
|
|
589
|
+
if let Some(pid) = pid {
|
|
590
|
+
#[cfg(windows)]
|
|
591
|
+
{
|
|
592
|
+
use std::os::windows::process::CommandExt;
|
|
593
|
+
let _ = StdCommand::new("taskkill")
|
|
594
|
+
.args(["/PID", &pid.to_string(), "/T", "/F"])
|
|
595
|
+
.creation_flags(0x0800_0000)
|
|
596
|
+
.status();
|
|
597
|
+
}
|
|
598
|
+
#[cfg(not(windows))]
|
|
599
|
+
{
|
|
600
|
+
let _ = StdCommand::new("kill").args(["-9", &pid.to_string()]).status();
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
if let Ok(mut guard) = state.child.try_lock() {
|
|
604
|
+
if let Some(child) = guard.as_mut() {
|
|
605
|
+
let _ = child.start_kill();
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
state.owned.store(false, Ordering::SeqCst);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
|
612
|
+
pub fn run() {
|
|
613
|
+
tauri::Builder::default()
|
|
614
|
+
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
|
615
|
+
.manage(AppState::default())
|
|
616
|
+
.invoke_handler(tauri::generate_handler![
|
|
617
|
+
check_dsh,
|
|
618
|
+
install_dsh,
|
|
619
|
+
launch_dsh,
|
|
620
|
+
stop_dsh,
|
|
621
|
+
get_settings,
|
|
622
|
+
set_autostart_setting,
|
|
623
|
+
set_global_shortcut_setting,
|
|
624
|
+
set_desktop_shortcut_setting,
|
|
625
|
+
set_terminate_harness_on_exit_setting,
|
|
626
|
+
close_settings
|
|
627
|
+
])
|
|
628
|
+
.setup(|app| {
|
|
629
|
+
// 高分辨率图标:托盘与窗口(任务栏/标题栏)统一从 512×512 源缩放。
|
|
630
|
+
let icon = app_icon();
|
|
631
|
+
if let Some(w) = app.get_webview_window("main") {
|
|
632
|
+
let _ = w.set_icon(icon.clone());
|
|
633
|
+
}
|
|
634
|
+
let show = MenuItem::with_id(app, "show", "打开 DeepSeek Harness", true, None::<&str>)?;
|
|
635
|
+
let settings = MenuItem::with_id(app, "settings", "设置", true, None::<&str>)?;
|
|
636
|
+
let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
|
|
637
|
+
let menu = Menu::with_items(app, &[&show, &settings, &quit])?;
|
|
638
|
+
TrayIconBuilder::with_id("main-tray")
|
|
639
|
+
.icon(icon)
|
|
640
|
+
.tooltip("DeepSeek Harness")
|
|
641
|
+
.menu(&menu)
|
|
642
|
+
.show_menu_on_left_click(true)
|
|
643
|
+
.on_menu_event(|app, event| match event.id.as_ref() {
|
|
644
|
+
"show" => {
|
|
645
|
+
if let Some(w) = app.get_webview_window("main") {
|
|
646
|
+
let _ = w.show();
|
|
647
|
+
let _ = w.unminimize();
|
|
648
|
+
let _ = w.set_focus();
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
"settings" => {
|
|
652
|
+
if let Err(e) = show_settings(app) {
|
|
653
|
+
eprintln!("[launcher] 打开设置窗口失败:{e}");
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
"quit" => {
|
|
657
|
+
exit_launcher(app);
|
|
658
|
+
app.exit(0);
|
|
659
|
+
}
|
|
660
|
+
_ => {}
|
|
661
|
+
})
|
|
662
|
+
.build(app)?;
|
|
663
|
+
|
|
664
|
+
// 启动时按持久化配置恢复全局快捷键注册。
|
|
665
|
+
let handle = app.handle().clone();
|
|
666
|
+
if load_config().global_shortcut {
|
|
667
|
+
if let Err(e) = apply_global_shortcut(&handle, true) {
|
|
668
|
+
eprintln!("[launcher] 注册全局快捷键失败:{e}");
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// 标记文件轮询:心跳 + 响应“仅退出桌面应用”请求(每秒一次,
|
|
673
|
+
// 退出标记消费延迟 ≤1 秒;插件侧的心跳“新鲜窗口”须与之匹配)。
|
|
674
|
+
tauri::async_runtime::spawn(async move {
|
|
675
|
+
let mut tick = tokio::time::interval(Duration::from_secs(1));
|
|
676
|
+
loop {
|
|
677
|
+
tick.tick().await;
|
|
678
|
+
let Ok(exe) = std::env::current_exe() else {
|
|
679
|
+
continue;
|
|
680
|
+
};
|
|
681
|
+
let Some(dir) = exe.parent() else {
|
|
682
|
+
continue;
|
|
683
|
+
};
|
|
684
|
+
// 心跳:写入当前 Unix 时间戳,供沙箱内的设置插件读取以判断进程存活。
|
|
685
|
+
let stamp = std::time::SystemTime::now()
|
|
686
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
687
|
+
.map(|d| d.as_secs())
|
|
688
|
+
.unwrap_or(0);
|
|
689
|
+
let _ = std::fs::write(dir.join(HEARTBEAT_MARKER), stamp.to_string());
|
|
690
|
+
if dir.join(QUIT_MARKER).exists() {
|
|
691
|
+
let quit_path = dir.join(QUIT_MARKER);
|
|
692
|
+
// 仅当内容为 "1" 且 60 秒内新鲜才退出;插件用重写内容("0")
|
|
693
|
+
// 取消退出请求,避免依赖外部命令删除文件。
|
|
694
|
+
let requested = std::fs::read_to_string(&quit_path)
|
|
695
|
+
.map(|s| s.trim() == "1")
|
|
696
|
+
.unwrap_or(false);
|
|
697
|
+
let fresh = std::fs::metadata(&quit_path)
|
|
698
|
+
.and_then(|m| m.modified())
|
|
699
|
+
.ok()
|
|
700
|
+
.and_then(|t| t.elapsed().ok())
|
|
701
|
+
.map(|age| age < Duration::from_secs(60))
|
|
702
|
+
.unwrap_or(false);
|
|
703
|
+
if requested && fresh {
|
|
704
|
+
let _ = std::fs::remove_file(&quit_path);
|
|
705
|
+
quit_via_marker(&handle).await;
|
|
706
|
+
break;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
Ok(())
|
|
712
|
+
})
|
|
713
|
+
.on_window_event(|window, event| {
|
|
714
|
+
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
|
715
|
+
// 关闭窗口时隐藏到系统托盘,退出请使用托盘菜单的“退出”。
|
|
716
|
+
api.prevent_close();
|
|
717
|
+
let _ = window.hide();
|
|
718
|
+
}
|
|
719
|
+
})
|
|
720
|
+
.build(tauri::generate_context!())
|
|
721
|
+
.expect("error while building tauri application")
|
|
722
|
+
.run(|app, event| {
|
|
723
|
+
if let tauri::RunEvent::Exit = event {
|
|
724
|
+
exit_launcher(app);
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
}
|