1use crate::{AnalysisOptions, analyze_with_progress, server};
3use anyhow::{Context, Result, ensure};
4use maud::{DOCTYPE, PreEscaped, html};
5use serde::Serialize;
6use std::{
7 fs,
8 io::Write,
9 path::{Path, PathBuf},
10 process::{Command, Stdio},
11 sync::{Arc, Mutex},
12 time::Duration,
13};
14use tiny_http::{Method, Server};
15
16#[derive(Default)]
17pub struct WebOptions {
18 pub out: Option<PathBuf>,
19 pub port: u16,
20 pub no_open: bool,
21 pub analysis: AnalysisOptions,
22}
23#[derive(Default, Serialize)]
24struct Progress {
25 completed: usize,
26 total: usize,
27 done: bool,
28 error: Option<String>,
29}
30
31pub fn web(root: impl AsRef<Path>, options: WebOptions) -> Result<()> {
34 let root = fs::canonicalize(root).context("project directory not found")?;
35 ensure!(root.is_dir(), "project must be a directory");
36 options.analysis.validate()?;
37 let out = match options.out {
38 Some(path) => {
39 let parent = fs::canonicalize(
40 path.parent()
41 .filter(|p| !p.as_os_str().is_empty())
42 .unwrap_or(Path::new(".")),
43 )?;
44 parent.join(path.file_name().context("report directory has no name")?)
45 }
46 None => tempfile::Builder::new()
47 .prefix("resopt-web-")
48 .tempdir()?
49 .keep()
50 .join("analysis"),
51 };
52 ensure!(
53 !out.starts_with(&root),
54 "analysis output must be outside the scanned project"
55 );
56 ensure!(
57 !out.try_exists()?,
58 "analysis output must be a new directory"
59 );
60 let server = Server::http(("127.0.0.1", options.port)).map_err(|e| anyhow::anyhow!("{e}"))?;
61 let address = server.server_addr().to_string();
62 let origin = format!("http://{address}");
63 let page = progress_page(&root, &out);
64 println!(
65 "Local web: {origin}/\nProject: {}\nReport: {}\nStop with Ctrl-C. Files stay on this device; reports and restore backups are retained.",
66 root.display(),
67 out.display()
68 );
69 std::io::stdout().flush()?;
70 let state = Arc::new(Mutex::new(Progress::default()));
71 let worker_state = state.clone();
72 let worker_out = out.clone();
73 let worker = std::thread::spawn(move || {
74 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
75 analyze_with_progress(&root, &worker_out, options.analysis, |completed, total| {
76 let mut state = worker_state.lock().unwrap();
77 state.completed = state.completed.max(completed);
78 state.total = total;
79 })
80 }));
81 let mut state = worker_state.lock().unwrap();
82 match result {
83 Ok(Ok(_)) => state.done = true,
84 Ok(Err(error)) => state.error = Some(format!("{error:#}")),
85 Err(_) => state.error = Some("分析进程异常;请查看终端并重试。".into()),
86 }
87 });
88 if !options.no_open {
89 open_browser(&origin);
90 }
91 loop {
92 if state.lock().unwrap().done {
93 worker
94 .join()
95 .map_err(|_| anyhow::anyhow!("analysis thread failed"))?;
96 return server::serve_on(&out, server);
97 }
98 let Some(request) = server.recv_timeout(Duration::from_millis(200))? else {
99 continue;
100 };
101 if server::header(&request, "Host") != Some(address.as_str()) {
102 server::respond(request, 403, "text/plain", b"Invalid host".to_vec());
103 continue;
104 }
105 if request.method() != &Method::Get {
106 server::respond(
107 request,
108 405,
109 "text/plain",
110 b"No uploads; analysis reads the selected local project".to_vec(),
111 );
112 continue;
113 }
114 match request.url() {
115 "/" => server::respond(
116 request,
117 200,
118 "text/html; charset=utf-8",
119 page.as_bytes().to_vec(),
120 ),
121 "/api/progress" => {
122 let progress = state.lock().unwrap();
123 server::respond(
124 request,
125 200,
126 "application/json",
127 serde_json::to_vec(&*progress)?,
128 );
129 }
130 _ => server::respond(request, 404, "text/plain", b"Not found".to_vec()),
131 }
132 }
133}
134fn open_browser(origin: &str) {
135 #[cfg(target_os = "macos")]
136 let mut cmd = Command::new("open");
137 #[cfg(target_os = "windows")]
138 let mut cmd = {
139 let mut cmd = Command::new("cmd");
140 cmd.args(["/C", "start", ""]);
141 cmd
142 };
143 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
144 let mut cmd = Command::new("xdg-open");
145 match cmd
146 .arg(origin)
147 .stdin(Stdio::null())
148 .stdout(Stdio::null())
149 .stderr(Stdio::null())
150 .spawn()
151 {
152 Ok(mut child) => {
153 std::thread::spawn(move || {
154 let _ = child.wait();
155 });
156 }
157 Err(error) => eprintln!("Could not open browser ({error}); open {origin}/ manually."),
158 }
159}
160fn progress_page(root: &Path, out: &Path) -> String {
161 html! {
162 (DOCTYPE) html lang="zh-CN" {
163 head {
164 meta charset="utf-8";meta name="viewport" content="width=device-width,initial-scale=1";
165 title {"resopt · 本地项目分析"}
166 style {(PreEscaped(include_str!("report.css")))}
167 script {(PreEscaped("try{const t=localStorage.getItem('resopt-theme')||'system';document.documentElement.dataset.theme=t==='system'?(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'):t}catch{}"))}
168 }
169 body {main {
170 h1 {"正在分析本地项目"}
171 p {(root.display())}
172 p {"图片直接从本机磁盘读取,不上传到远程服务器。完成后自动进入审核页面。"}
173 p { @if cfg!(target_os="macos") {"当前能力:PNG 无损 · JPEG / HEIC · 透明度与画质检测"} @else {"当前能力:PNG 无损;JPEG / HEIC 编码仅在 macOS 可用。"} }
174 p id="progress" role="status" aria-live="polite" {"正在扫描目录与 Git 忽略规则…"}
175 p {"报告与恢复备份保留在:" (out.display())}
176 }
177 script {(PreEscaped(r#"async function poll(){try{const r=await fetch('/api/progress',{cache:'no-store'});if(!r.ok)throw Error('connection');const p=await r.json();if(p.done){location.reload();return;}if(p.error){document.getElementById('progress').textContent='分析失败:'+p.error;return;}document.getElementById('progress').textContent=p.total?'已分析 '+p.completed+' / '+p.total+' 个资源':'正在扫描目录与 Git 忽略规则…';setTimeout(poll,700);}catch{document.getElementById('progress').textContent='本地服务连接中断,请确认终端进程仍在运行。';setTimeout(poll,2000);}}poll();"#))}
178 }
179 }
180 }.into_string()
181}