use std::path::Path; use std::process::Command; use serde_json::{json, Value}; use crate::browser::BrowserBackend; use crate::pipe::{Action, CommandOutput}; pub const LOCAL_DASHBOARD_EXPECTED_DOMAIN: &str = "__sgclaw_local_dashboard__"; pub const LOCAL_DASHBOARD_SOURCE: &str = "compat.workflow_executor"; pub const LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN: &str = "zhihu_hotlist_screen"; const DISABLE_POST_EXPORT_OPEN_ENV: &str = "SGCLAW_DISABLE_POST_EXPORT_OPEN"; #[derive(Debug, Clone, PartialEq, Eq)] pub enum PostExportOpen { Opened, Failed(String), } pub fn open_exported_xlsx(output_path: &Path) -> PostExportOpen { open_exported_xlsx_with(output_path, launch_with_default_xlsx_app) } fn open_exported_xlsx_with(output_path: &Path, opener: F) -> PostExportOpen where F: FnOnce(&Path) -> Result<(), String>, { if !output_path.exists() { return PostExportOpen::Failed(format!( "导出的 Excel 文件不存在:{}", output_path.display() )); } match opener(output_path) { Ok(()) => PostExportOpen::Opened, Err(reason) => PostExportOpen::Failed(reason), } } pub fn open_local_dashboard( browser_backend: &dyn BrowserBackend, output_path: &Path, presentation_url: &str, ) -> PostExportOpen { if !output_path.exists() { return PostExportOpen::Failed(format!( "生成的大屏文件不存在:{}", output_path.display() )); } if presentation_url.trim().is_empty() { return PostExportOpen::Failed("screen_html_export did not return presentation.url".to_string()); } let params = json!({ "url": presentation_url, "sgclaw_local_dashboard_open": { "source": LOCAL_DASHBOARD_SOURCE, "kind": LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN, "output_path": output_path.to_string_lossy(), "presentation_url": presentation_url, } }); match browser_backend.invoke(Action::Navigate, params, LOCAL_DASHBOARD_EXPECTED_DOMAIN) { Ok(output) if output.success => PostExportOpen::Opened, Ok(output) => PostExportOpen::Failed(command_output_reason(&output)), Err(err) => PostExportOpen::Failed(err.to_string()), } } #[cfg(windows)] fn launch_with_default_xlsx_app(output_path: &Path) -> Result<(), String> { if std::env::var_os(DISABLE_POST_EXPORT_OPEN_ENV).is_some() { return Ok(()); } let output = Command::new("cmd") .args(["/C", "start", "", &output_path.display().to_string()]) .output() .map_err(|err| format!("启动 Excel 默认程序失败:{err}"))?; if output.status.success() { Ok(()) } else { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); if stderr.is_empty() { Err(format!( "启动 Excel 默认程序失败:exit status {}", output.status )) } else { Err(format!("启动 Excel 默认程序失败:{stderr}")) } } } #[cfg(target_os = "macos")] fn launch_with_default_xlsx_app(output_path: &Path) -> Result<(), String> { if std::env::var_os(DISABLE_POST_EXPORT_OPEN_ENV).is_some() { return Ok(()); } let status = Command::new("open") .arg(output_path) .status() .map_err(|err| format!("启动 Excel 默认程序失败:{err}"))?; if status.success() { Ok(()) } else { Err(format!("启动 Excel 默认程序失败:exit status {status}")) } } #[cfg(all(unix, not(target_os = "macos")))] fn launch_with_default_xlsx_app(output_path: &Path) -> Result<(), String> { if std::env::var_os(DISABLE_POST_EXPORT_OPEN_ENV).is_some() { return Ok(()); } let status = Command::new("xdg-open") .arg(output_path) .status() .map_err(|err| format!("启动 Excel 默认程序失败:{err}"))?; if status.success() { Ok(()) } else { Err(format!("启动 Excel 默认程序失败:exit status {status}")) } } fn command_output_reason(output: &CommandOutput) -> String { output .data .get("error") .and_then(Value::as_str) .or_else(|| output.data.get("message").and_then(Value::as_str)) .map(str::to_string) .unwrap_or_else(|| output.data.to_string()) } #[cfg(test)] mod tests { use super::*; use std::collections::VecDeque; use std::path::PathBuf; use std::sync::Mutex; use serde_json::json; use crate::pipe::{ExecutionSurfaceMetadata, PipeError, Timing}; fn temp_file_path(name: &str) -> PathBuf { let root = std::env::temp_dir().join(format!( "sgclaw-artifact-open-{}-{}", std::process::id(), uuid::Uuid::new_v4() )); std::fs::create_dir_all(&root).expect("temp root should exist"); root.join(name) } #[test] fn open_exported_xlsx_with_passes_generated_path_to_launcher() { let output_path = temp_file_path("zhihu-hotlist.xlsx"); std::fs::write(&output_path, b"xlsx").expect("xlsx fixture should be writable"); let seen = Mutex::new(None::); let result = open_exported_xlsx_with(&output_path, |path| { *seen.lock().unwrap() = Some(path.to_path_buf()); Ok(()) }); assert!(matches!(result, PostExportOpen::Opened)); assert_eq!(seen.lock().unwrap().clone().unwrap(), output_path); } #[test] fn open_exported_xlsx_with_reports_launcher_failure() { let output_path = temp_file_path("zhihu-hotlist.xlsx"); std::fs::write(&output_path, b"xlsx").expect("xlsx fixture should be writable"); let result = open_exported_xlsx_with(&output_path, |_path| Err("launcher failed".to_string())); assert!(matches!(result, PostExportOpen::Failed(reason) if reason.contains("launcher failed"))); } #[derive(Default)] struct FakeBrowserBackend { responses: Mutex>>, invocations: Mutex>, } impl FakeBrowserBackend { fn new(responses: Vec>) -> Self { Self { responses: Mutex::new(VecDeque::from(responses)), invocations: Mutex::new(Vec::new()), } } } impl BrowserBackend for FakeBrowserBackend { fn invoke( &self, action: Action, params: Value, expected_domain: &str, ) -> Result { self.invocations .lock() .unwrap() .push((action, params, expected_domain.to_string())); self.responses .lock() .unwrap() .pop_front() .unwrap_or_else(|| Err(PipeError::Timeout)) } fn surface_metadata(&self) -> ExecutionSurfaceMetadata { ExecutionSurfaceMetadata::privileged_browser_pipe("fake_backend") } } #[test] fn open_local_dashboard_uses_exact_approved_marker_payload() { let output_path = temp_file_path("zhihu-hotlist-screen.html"); std::fs::write(&output_path, "").expect("dashboard fixture should be writable"); let presentation_url = format!("file:///{}", output_path.display().to_string().replace('\\', "/")); let backend = FakeBrowserBackend::new(vec![Ok(CommandOutput { seq: 1, success: true, data: json!({ "navigated": true }), aom_snapshot: vec![], timing: Timing { queue_ms: 1, exec_ms: 1, }, })]); let result = open_local_dashboard(&backend, &output_path, &presentation_url); let invocations = backend.invocations.lock().unwrap().clone(); assert!(matches!(result, PostExportOpen::Opened)); assert_eq!(invocations.len(), 1); assert_eq!(invocations[0].0, Action::Navigate); assert_eq!(invocations[0].2, LOCAL_DASHBOARD_EXPECTED_DOMAIN.to_string()); assert_eq!(invocations[0].1["url"], json!(presentation_url)); assert_eq!( invocations[0].1["sgclaw_local_dashboard_open"]["source"], json!(LOCAL_DASHBOARD_SOURCE) ); assert_eq!( invocations[0].1["sgclaw_local_dashboard_open"]["kind"], json!(LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN) ); assert_eq!( invocations[0].1["sgclaw_local_dashboard_open"]["output_path"], json!(output_path.to_string_lossy().to_string()) ); assert_eq!( invocations[0].1["sgclaw_local_dashboard_open"]["presentation_url"], invocations[0].1["url"] ); } }