1use crate::{
3 AnalysisOptions,
4 analysis::analyze_with_observer,
5 review::Review,
6 server::{self, App},
7};
8use anyhow::{Context, Result, ensure};
9use std::{
10 fs,
11 io::Write,
12 path::{Path, PathBuf},
13 process::{Command, Stdio},
14 sync::Arc,
15};
16use tiny_http::Server;
17
18#[derive(Default)]
19pub struct WebOptions {
20 pub out: Option<PathBuf>,
21 pub port: u16,
22 pub no_open: bool,
23 pub analysis: AnalysisOptions,
24}
25pub fn web(root: impl AsRef<Path>, options: WebOptions) -> Result<()> {
28 let root = fs::canonicalize(root).context("project directory not found")?;
29 ensure!(root.is_dir(), "project must be a directory");
30 options.analysis.validate()?;
31 let out = match options.out {
32 Some(path) => {
33 let parent = fs::canonicalize(
34 path.parent()
35 .filter(|p| !p.as_os_str().is_empty())
36 .unwrap_or(Path::new(".")),
37 )?;
38 parent.join(path.file_name().context("report directory has no name")?)
39 }
40 None => tempfile::Builder::new()
41 .prefix("resopt-web-")
42 .tempdir()?
43 .keep()
44 .join("analysis"),
45 };
46 ensure!(
47 !out.starts_with(&root),
48 "analysis output must be outside the scanned project"
49 );
50 ensure!(
51 !out.try_exists()?,
52 "analysis output must be a new directory"
53 );
54 let server = Server::http(("127.0.0.1", options.port)).map_err(|e| anyhow::anyhow!("{e}"))?;
55 let token = server::session_token()?;
56 let url = server::launch_url(&server, &token);
57 println!(
58 "Local web: {url}\nProject: {}\nReport: {}\nStop with Ctrl-C. Files stay on this device; reports and restore backups are retained.\nThe URL contains this session's key; the page is not served without it.",
59 root.display(),
60 out.display()
61 );
62 std::io::stdout().flush()?;
63 let app = Arc::new(App::new(out.clone(), root.clone()));
64 let worker_app = app.clone();
65 let analysis = options.analysis;
66 std::thread::spawn(move || {
67 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
68 analyze_with_observer(
69 &root,
70 &out,
71 analysis,
72 &worker_app.control,
73 |index, resource, _, total| {
74 let mut live = worker_app.live.lock().unwrap_or_else(|e| e.into_inner());
75 live.total = total;
76 live.rows.push((
77 index,
78 crate::ResourceAnalysis {
79 fingerprint: None,
80 ..resource.clone()
81 },
82 ));
83 },
84 )
85 .and_then(|_| Review::open(&out))
86 }));
87 match result {
88 Ok(Ok(review)) => worker_app.finish(review),
89 Ok(Err(error)) => {
90 worker_app
91 .live
92 .lock()
93 .unwrap_or_else(|e| e.into_inner())
94 .error = Some(format!("{error:#}"));
95 }
96 Err(_) => {
97 worker_app
98 .live
99 .lock()
100 .unwrap_or_else(|e| e.into_inner())
101 .error = Some(
102 "The analysis thread stopped unexpectedly. Check the terminal output and run resopt web again."
103 .into(),
104 );
105 }
106 }
107 });
108 if !options.no_open {
109 open_browser(&url);
110 }
111 server::run(Arc::new(server), app, token)
112}
113fn open_browser(origin: &str) {
114 #[cfg(target_os = "macos")]
115 let mut cmd = Command::new("open");
116 #[cfg(target_os = "windows")]
117 let mut cmd = {
118 let mut cmd = Command::new("cmd");
119 cmd.args(["/C", "start", ""]);
120 cmd
121 };
122 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
123 let mut cmd = Command::new("xdg-open");
124 match cmd
125 .arg(origin)
126 .stdin(Stdio::null())
127 .stdout(Stdio::null())
128 .stderr(Stdio::null())
129 .spawn()
130 {
131 Ok(mut child) => {
132 std::thread::spawn(move || {
133 let _ = child.wait();
134 });
135 }
136 Err(error) => eprintln!("Could not open browser ({error}); open {origin} manually."),
137 }
138}