Files
claw/src/compat/artifact_open.rs
木炎 bdf8e12246 feat: align browser callback runtime and export flows
Consolidate the browser task runtime around the callback path, add safer artifact opening for Zhihu exports, and cover the new service/browser flows with focused tests and supporting docs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 21:44:53 +08:00

268 lines
8.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<F>(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::<PathBuf>);
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<VecDeque<Result<CommandOutput, PipeError>>>,
invocations: Mutex<Vec<(Action, Value, String)>>,
}
impl FakeBrowserBackend {
fn new(responses: Vec<Result<CommandOutput, PipeError>>) -> 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<CommandOutput, PipeError> {
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, "<html></html>").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"]
);
}
}