tinyview 1.0.0

Ephemeral CLI WebView runtime
//! Ephemeral-by-default WebView builder wrapper.
//!
//! See PRD §19 (Security Model) for the design rationale. This module
//! applies ephemeral defaults (`with_incognito(true)`, `with_devtools`
//! off in release, `with_clipboard(false)`, navigation handler that
//! rejects top-level external navigation) and injects a restrictive
//! Content-Security-Policy `<meta>` tag into the HTML.
//!
//! `raw_mode` skips CSP injection only — WebView builder defaults
//! still apply (PRD §19.5).

use tao::window::Window;
use wry::{WebView, WebViewBuilder};

/// User-controlled permission relaxations.
///
/// Each flag relaxes exactly one slice of the default-deny policy.
#[derive(Debug, Clone, Copy, Default)]
pub struct Permissions {
    /// Relax CSP `connect-src` from `'none'` to `https: http: ws: wss:`.
    pub allow_fetch: bool,
    /// Pass `with_clipboard(true)` to wry. macOS is always-on at the OS
    /// level and cannot be fully disabled; see PRD §19.3.
    pub allow_clipboard: bool,
    /// Pass `with_incognito(false)` to wry. Without this, storage is
    /// purged when the WebView is dropped.
    pub allow_storage: bool,
}

/// Options for [`build`].
pub struct BuildOptions<'a> {
    /// Single HTML string to render. The wrapper may inject a CSP
    /// `<meta>` tag into the `<head>` (unless `raw_mode` is set).
    pub html: &'a str,
    pub perms: Permissions,
    /// Raw mode skips CSP `<meta>` injection (PRD §19.5). WebView
    /// builder defaults are still applied. `--allow-*` flags force CSP
    /// injection regardless of this flag.
    pub raw_mode: bool,
    /// PRD §9.9: enable per-pixel transparency on the WebView. Must be
    /// paired with `WindowBuilder::with_transparent(true)` on the host
    /// window — otherwise the OS compositor draws an opaque background
    /// behind the WebView and the alpha channel is invisible.
    ///
    /// On macOS this requires the `transparent` wry feature (enabled in
    /// Cargo.toml) because the implementation calls a WKWebView private
    /// API (`_drawsBackground`).
    pub transparent: bool,

    /// E2E-only IPC channel for the self-test harness (issue #5). When `Some`,
    /// [`build`] installs a wry IPC handler that forwards
    /// `window.ipc.postMessage(<string>)` bodies from page JS to this channel,
    /// letting the harness observe in-page behavior (fetch / clipboard /
    /// storage). The field — and the bridge it enables — only exist under the
    /// `e2e` feature, so the production binary never carries a JS→native bridge
    /// (CLAUDE.md security default: no native bridge). Production callers do not
    /// (and cannot) set this.
    #[cfg(feature = "e2e")]
    pub ipc_tx: Option<std::sync::mpsc::Sender<String>>,
}

/// Build the default CSP value, honoring `allow_fetch`.
///
/// PRD §19.3 baseline:
///   `default-src 'self' 'unsafe-inline' data: blob:;
///    connect-src 'none';
///    object-src 'none';
///    base-uri 'none';
///    form-action 'none';`
pub fn build_csp(perms: &Permissions) -> String {
    let connect_src = if perms.allow_fetch {
        "connect-src https: http: ws: wss:"
    } else {
        "connect-src 'none'"
    };
    format!(
        "default-src 'self' 'unsafe-inline' data: blob:; \
         {connect_src}; \
         object-src 'none'; \
         base-uri 'none'; \
         form-action 'none';"
    )
}

/// Find the start of the `<head ...>` opening tag (case-insensitive)
/// and return the byte offset *just after* the closing `>` of that tag.
fn find_head_open_end(html: &str) -> Option<usize> {
    // Case-insensitive search for "<head". We avoid pulling in `regex`
    // for startup-time reasons (CLAUDE.md KPI #1).
    let lower = html.to_ascii_lowercase();
    let start = lower.find("<head")?;
    // After `<head`, find the next `>` that closes the opening tag.
    // Note: this is a deliberately simple parser; a robust HTML parser
    // would handle attribute strings containing '>'. For our use case
    // (template authors writing well-formed HTML) this is sufficient.
    let close_rel = lower[start..].find('>')?;
    Some(start + close_rel + 1)
}

/// Inject a CSP `<meta http-equiv>` tag into the HTML. Behavior:
/// - If `<head>` exists, insert the meta tag right after the opening tag.
/// - Otherwise, prepend a fresh `<head>...</head>` block to the document.
pub fn inject_csp(html: &str, perms: &Permissions) -> String {
    let csp = build_csp(perms);
    let meta = format!(r#"<meta http-equiv="Content-Security-Policy" content="{csp}">"#);

    match find_head_open_end(html) {
        Some(idx) => {
            let mut out = String::with_capacity(html.len() + meta.len());
            out.push_str(&html[..idx]);
            out.push_str(&meta);
            out.push_str(&html[idx..]);
            out
        }
        None => {
            let mut out = String::with_capacity(html.len() + meta.len() + 13);
            out.push_str("<head>");
            out.push_str(&meta);
            out.push_str("</head>");
            out.push_str(html);
            out
        }
    }
}

/// macOS-only initialization script that defangs `navigator.clipboard`.
///
/// PRD §19.3: WKWebView always enables clipboard at the OS level and
/// wry's `with_clipboard(false)` does nothing on macOS. We strip the
/// JS-visible `navigator.clipboard` so scripts cannot read/write the
/// clipboard programmatically. Cmd+C / Cmd+V via the native menu are
/// out of scope and remain enabled (documented constraint).
#[cfg(target_os = "macos")]
const MACOS_CLIPBOARD_NEUTRALIZE: &str = "\
try { Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: false }); } \
catch (e) { try { navigator.clipboard = undefined; } catch (_) {} }";

/// Apply the same HTML transformation that [`build`] performs before passing
/// the document to wry. Used by `--watch` reloads (PRD §9.10) so the new HTML
/// has the same CSP `<meta>` injected as the initial render.
///
/// Mirrors the `inject_csp_now` rule in [`build`]: CSP is injected unless
/// `raw_mode` is set AND no permission flag has been granted.
pub fn prepare_html(html: &str, perms: &Permissions, raw_mode: bool) -> String {
    let any_perm = perms.allow_fetch || perms.allow_clipboard || perms.allow_storage;
    let inject = !raw_mode || any_perm;
    if inject {
        inject_csp(html, perms)
    } else {
        html.to_string()
    }
}

/// Build a `WebView` with TinyView's ephemeral defaults applied.
///
/// Defaults applied unconditionally:
///   - `with_incognito(true)` unless `perms.allow_storage`
///   - `with_devtools(false)` in release builds, `true` in debug
///   - `with_clipboard(perms.allow_clipboard)`
///   - `with_navigation_handler` rejecting non-`about:` / non-`data:` top-level navigation
///   - On macOS, an initialization script disabling `navigator.clipboard`
///     (unless `perms.allow_clipboard`)
///
/// CSP `<meta>` is injected into HTML unless `opts.raw_mode` is set AND
/// no permission flag has been granted (PRD §19.5).
pub fn build(window: &Window, opts: BuildOptions<'_>) -> wry::Result<WebView> {
    let any_perm = opts.perms.allow_fetch || opts.perms.allow_clipboard || opts.perms.allow_storage;
    let inject_csp_now = !opts.raw_mode || any_perm;

    // Avoid an allocation when we won't inject CSP.
    let html_owned: Option<String> = if inject_csp_now {
        Some(inject_csp(opts.html, &opts.perms))
    } else {
        None
    };
    let html_to_load: &str = html_owned.as_deref().unwrap_or(opts.html);

    let mut builder = WebViewBuilder::new()
        .with_html(html_to_load)
        .with_incognito(!opts.perms.allow_storage)
        .with_clipboard(opts.perms.allow_clipboard)
        .with_transparent(opts.transparent)
        .with_navigation_handler(|url: String| {
            // Top-level navigation policy: only `about:` and `data:` are allowed.
            // Everything else (http/https/file/custom schemes) is rejected.
            url.starts_with("about:") || url.starts_with("data:")
        });

    // E2E self-test bridge (issue #5). Only compiled under the `e2e` feature;
    // production builds never reach this and never expose `window.ipc`.
    #[cfg(feature = "e2e")]
    if let Some(tx) = opts.ipc_tx {
        builder = builder.with_ipc_handler(move |req: wry::http::Request<String>| {
            let _ = tx.send(req.into_body());
        });
    }

    // Devtools: ON in debug builds, OFF in release. PRD §19.3.
    #[cfg(debug_assertions)]
    {
        builder = builder.with_devtools(true);
    }
    #[cfg(not(debug_assertions))]
    {
        builder = builder.with_devtools(false);
    }

    // macOS clipboard reinforcement (PRD §19.3).
    #[cfg(target_os = "macos")]
    {
        if !opts.perms.allow_clipboard {
            builder = builder.with_initialization_script(MACOS_CLIPBOARD_NEUTRALIZE);
        }
    }

    builder.build(window)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Dev-only: emit the fully-composed built-in templates (library inlined,
    /// data injected, CSP `<meta>` applied exactly as the WebView would see)
    /// to the temp dir so they can be loaded in a CSP-enforcing browser to
    /// verify the optional templates actually render under `connect-src 'none'`
    /// and no `'unsafe-eval'`. Ignored by default; run explicitly:
    ///   cargo test --release write_builtin_render_fixtures -- --ignored --nocapture
    #[test]
    #[ignore]
    fn write_builtin_render_fixtures() {
        use crate::template::{self, InjectData, TemplateRef};
        use std::collections::HashMap;

        struct Case {
            name: &'static str,
            tpl: TemplateRef,
            input: &'static str,
            params: &'static [(&'static str, &'static str)],
        }

        let dir = std::env::temp_dir();
        let cases = [
            Case {
                name: "markdown",
                tpl: TemplateRef::Markdown,
                input: "# Title\n\nSome **bold** text.\n\n```rust\nfn main() { println!(\"hi\"); }\n```\n",
                params: &[],
            },
            Case {
                name: "code",
                tpl: TemplateRef::Code,
                input: "fn main() {\n    let x = 41 + 1;\n    println!(\"{x}\");\n}\n",
                params: &[("lang", "rust")],
            },
            Case {
                name: "mermaid",
                tpl: TemplateRef::Mermaid,
                input: "graph TD; A[Start] --> B{Choice}; B -->|yes| C[OK]; B -->|no| D[Stop];",
                params: &[],
            },
        ];

        for case in &cases {
            let mut p: HashMap<String, String> = HashMap::new();
            for (k, v) in case.params {
                p.insert((*k).to_string(), (*v).to_string());
            }
            let data = InjectData {
                input: case.input,
                params: &p,
                title: "tinyview",
                path: None,
            };
            let html = template::render(&case.tpl, &data).expect("render ok");
            let prepared = prepare_html(&html, &Permissions::default(), false);
            let out = dir.join(format!("tinyview_fixture_{}.html", case.name));
            std::fs::write(&out, prepared).expect("write fixture");
            println!("FIXTURE {}: {}", case.name, out.display());
        }
    }

    #[test]
    fn csp_default_blocks_connect() {
        let csp = build_csp(&Permissions::default());
        assert!(csp.contains("connect-src 'none'"));
        assert!(csp.contains("default-src 'self' 'unsafe-inline' data: blob:"));
        assert!(csp.contains("object-src 'none'"));
        assert!(csp.contains("base-uri 'none'"));
        assert!(csp.contains("form-action 'none'"));
    }

    #[test]
    fn csp_allow_fetch_opens_connect() {
        let csp = build_csp(&Permissions {
            allow_fetch: true,
            ..Default::default()
        });
        assert!(csp.contains("connect-src https: http: ws: wss:"));
        assert!(!csp.contains("connect-src 'none'"));
    }

    #[test]
    fn inject_csp_inserts_after_head() {
        let html = "<html><head><title>x</title></head><body>y</body></html>";
        let out = inject_csp(html, &Permissions::default());
        // <meta> should appear immediately after `<head>` and before `<title>`.
        let meta_idx = out
            .find("<meta http-equiv=\"Content-Security-Policy\"")
            .unwrap();
        let title_idx = out.find("<title>").unwrap();
        let head_open_end = out.find("<head>").unwrap() + "<head>".len();
        assert_eq!(meta_idx, head_open_end);
        assert!(meta_idx < title_idx);
    }

    #[test]
    fn inject_csp_handles_head_with_attributes() {
        let html = r#"<html><head lang="en"><title>x</title></head><body></body></html>"#;
        let out = inject_csp(html, &Permissions::default());
        let head_open = out.find("<head ").unwrap();
        let head_close = out[head_open..].find('>').unwrap() + head_open + 1;
        let meta_idx = out
            .find("<meta http-equiv=\"Content-Security-Policy\"")
            .unwrap();
        assert_eq!(
            meta_idx, head_close,
            "meta must be inserted immediately after the opening <head ...> tag"
        );
    }

    #[test]
    fn inject_csp_creates_head_when_missing() {
        let html = "<html><body>only body</body></html>";
        let out = inject_csp(html, &Permissions::default());
        assert!(out.starts_with("<head>"));
        assert!(out.contains("<meta http-equiv=\"Content-Security-Policy\""));
        assert!(out.contains("</head><html>"));
    }

    #[test]
    fn inject_csp_respects_allow_fetch() {
        let html = "<head></head>";
        let out = inject_csp(
            html,
            &Permissions {
                allow_fetch: true,
                ..Default::default()
            },
        );
        assert!(out.contains("connect-src https: http: ws: wss:"));
    }
}