mod shell_updater; use std::io::Cursor; use std::path::PathBuf; use std::process::Command; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; use dsh_core::clipboard::{ detect_image_mime, 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, pub cwd: Option, 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>>, url: Mutex>, initial_boot_started: AtomicBool, } 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)); } fn start_initial_boot(app: AppHandle) { let Some(state) = app.try_state::() else { return; }; if state.initial_boot_started.swap(true, Ordering::AcqRel) { return; } thread::spawn(move || boot(app)); } #[tauri::command] async fn check_shell_update(app: AppHandle) -> Option { match shell_updater::check(&app).await { Ok(Some(update)) => Some(update), Ok(None) => { start_initial_boot(app); None } Err(error) => { log::warn!("shell update check failed: {error}"); start_initial_boot(app); None } } } #[tauri::command] async fn install_shell_update(app: AppHandle) -> Result<(), String> { shell_updater::install(&app).await } #[tauri::command] fn skip_shell_update(app: AppHandle) { start_initial_boot(app); } #[tauri::command] fn open_in_browser(state: tauri::State) -> 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(app: AppHandle) -> Result, String> { let (tx, rx) = std::sync::mpsc::sync_channel(1); app.run_on_main_thread(move || { let _ = tx.send(read_images()); }) .map_err(|e| e.to_string())?; rx.recv().map_err(|e| e.to_string())? } fn read_images() -> Result, String> { if let Some(file) = read_native_image() { return Ok(vec![file]); } if let Some(file) = read_arboard_image() { return Ok(vec![file]); } if let Some(file) = read_cli_image() { return Ok(vec![file]); } if let Some(files) = read_arboard_image_paths() { return Ok(files); } Ok(Vec::new()) } #[cfg(target_os = "linux")] fn read_native_image() -> Option { let clipboard = gtk::Clipboard::get(&gdk::SELECTION_CLIPBOARD); let pixbuf = clipboard.wait_for_image()?; let png = pixbuf.save_to_bufferv("png", &[]).ok()?; file_from_bytes(filename_for_mime("image/png", 0), "image/png".into(), png) } #[cfg(not(target_os = "linux"))] fn read_native_image() -> Option { None } fn read_arboard_image() -> Option { let mut clipboard = arboard::Clipboard::new().ok()?; let img = clipboard.get_image().ok()?; let width = img.width as u32; let height = img.height as u32; let bytes = img.bytes.into_owned(); let buffer = image::RgbaImage::from_raw(width, height, bytes)?; let mut png = Vec::new(); buffer .write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png) .ok()?; file_from_bytes(filename_for_mime("image/png", 0), "image/png".into(), png) } fn read_arboard_image_paths() -> Option> { let mut clipboard = arboard::Clipboard::new().ok()?; let text = clipboard.get_text().ok()?; let loaded = files_from_paths(parse_uri_list(&text)); if loaded.is_empty() { None } else { Some(loaded) } } fn read_cli_image() -> Option { const ATTEMPTS: &[(&str, &[&str])] = &[ ("wl-paste", &["--type", "image/png"]), ("wl-paste", &["--type", "image/jpeg"]), ("wl-paste", &["--type", "image/webp"]), ( "xclip", &["-selection", "clipboard", "-t", "image/png", "-o"], ), ( "xclip", &["-selection", "clipboard", "-t", "image/jpeg", "-o"], ), ( "xclip", &["-selection", "clipboard", "-t", "image/webp", "-o"], ), ]; for (bin, args) in ATTEMPTS { let output = match Command::new(bin).args(*args).output() { Ok(output) => output, Err(_) => continue, }; if !output.status.success() || output.stdout.is_empty() { continue; } let mime = detect_image_mime(&output.stdout).unwrap_or("image/png"); if let Some(file) = file_from_bytes(filename_for_mime(mime, 0), mime.into(), output.stdout) { return Some(file); } } None } fn enable_microphone(window: &tauri::WebviewWindow) { #[cfg(target_os = "linux")] { let _ = window.with_webview(|platform| { use gtk::glib::{object::ObjectExt, StaticType}; use webkit2gtk::{PermissionRequestExt, SettingsExt, WebViewExt}; let webview = platform.inner(); if let Some(settings) = webview.settings() { settings.set_enable_media_stream(true); settings.set_enable_mediasource(true); } webview.connect_permission_request(|_, request| { if request .type_() .is_a(webkit2gtk::UserMediaPermissionRequest::static_type()) { request.allow(); true } else { false } }); }); } let _ = window; } 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::() 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::>() .into_iter() .rev() .collect::>() .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; let updater = match shell_updater::public_key() { Some(public_key) => tauri_plugin_updater::Builder::new().pubkey(public_key), None => tauri_plugin_updater::Builder::new(), } .build(); tauri::Builder::default() .plugin(updater) .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), initial_boot_started: AtomicBool::new(false), }); 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(); } enable_microphone(&window); let _ = window; Ok(()) }) .invoke_handler(tauri::generate_handler![ restart, open_in_browser, read_clipboard_images, check_shell_update, install_shell_update, skip_shell_update, ]) .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { .. } = event { if let Some(state) = window.try_state::() { state.stop(); } } }) .run(tauri::generate_context!()) .expect("error while running DeepSeek Harness Desktop"); }