Skip to main content

resopt/
web.rs

1//! Local project companion: filesystem analysis and review, never an upload API.
2use 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}
25/// Start a local-only project analysis and serve the review UI while it runs.
26/// Generated reports and restore backups are retained after the process exits.
27pub 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}