use tao::window::Window;
use wry::{WebView, WebViewBuilder};
#[derive(Debug, Clone, Copy, Default)]
pub struct Permissions {
pub allow_fetch: bool,
pub allow_clipboard: bool,
pub allow_storage: bool,
}
pub struct BuildOptions<'a> {
pub html: &'a str,
pub perms: Permissions,
pub raw_mode: bool,
pub transparent: bool,
#[cfg(feature = "e2e")]
pub ipc_tx: Option<std::sync::mpsc::Sender<String>>,
}
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';"
)
}
fn extract_attr(tag: &str, attr: &str) -> Option<String> {
let key = format!("{attr}=");
let mut from = 0;
while let Some(rel) = tag[from..].find(&key) {
let vstart = from + rel + key.len();
let bytes = tag.as_bytes();
if vstart >= bytes.len() {
return None;
}
let quote = bytes[vstart];
if quote == b'"' || quote == b'\'' {
let rest = &tag[vstart + 1..];
if let Some(end) = rest.find(quote as char) {
return Some(rest[..end].to_string());
}
}
from = vstart;
}
None
}
fn meta_allows_fetch(html: &str) -> bool {
let lower = html.to_ascii_lowercase();
let mut search_from = 0;
while let Some(rel) = lower[search_from..].find("<meta") {
let tag_start = search_from + rel;
let tag_end = match lower[tag_start..].find('>') {
Some(e) => tag_start + e,
None => break,
};
let tag = &lower[tag_start..tag_end];
let is_allow_tag = tag.contains("name=\"tinyview-allow\"")
|| tag.contains("name='tinyview-allow'");
if is_allow_tag {
if let Some(content) = extract_attr(tag, "content") {
if content.split_whitespace().any(|t| t == "fetch") {
return true;
}
}
}
search_from = tag_end + 1;
}
false
}
fn effective_perms(html: &str, perms: &Permissions) -> Permissions {
let mut eff = *perms;
if !eff.allow_fetch && meta_allows_fetch(html) {
eff.allow_fetch = true;
}
eff
}
fn find_head_open_end(html: &str) -> Option<usize> {
let lower = html.to_ascii_lowercase();
let start = lower.find("<head")?;
let close_rel = lower[start..].find('>')?;
Some(start + close_rel + 1)
}
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
}
}
}
#[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 (_) {} }";
const ZOOM_SCRIPT: &str = "\
(function () { \
var z = 1, MIN = 0.3, MAX = 5, STEP = 0.1; \
function round(v) { return Math.round(v * 100) / 100; } \
function apply() { var el = document.documentElement; if (el) { el.style.zoom = String(z); } } \
window.addEventListener('keydown', function (e) { \
if (!(e.metaKey || e.ctrlKey)) return; \
var k = e.key; \
if (k === '+' || k === '=') { e.preventDefault(); z = round(Math.min(MAX, z + STEP)); apply(); } \
else if (k === '-' || k === '_') { e.preventDefault(); z = round(Math.max(MIN, z - STEP)); apply(); } \
else if (k === '0') { e.preventDefault(); z = 1; apply(); } \
}, true); \
})();";
pub fn prepare_html(html: &str, perms: &Permissions, raw_mode: bool) -> String {
let perms = effective_perms(html, perms);
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()
}
}
pub fn build(window: &Window, opts: BuildOptions<'_>) -> wry::Result<WebView> {
let perms = effective_perms(opts.html, &opts.perms);
let any_perm = perms.allow_fetch || perms.allow_clipboard || perms.allow_storage;
let inject_csp_now = !opts.raw_mode || any_perm;
let html_owned: Option<String> = if inject_csp_now {
Some(inject_csp(opts.html, &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(!perms.allow_storage)
.with_clipboard(perms.allow_clipboard)
.with_transparent(opts.transparent)
.with_navigation_handler(|url: String| {
url.starts_with("about:") || url.starts_with("data:")
});
#[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());
});
}
#[cfg(debug_assertions)]
{
builder = builder.with_devtools(true);
}
#[cfg(not(debug_assertions))]
{
builder = builder.with_devtools(false);
}
#[cfg(target_os = "macos")]
{
if !perms.allow_clipboard {
builder = builder.with_initialization_script(MACOS_CLIPBOARD_NEUTRALIZE);
}
}
builder = builder.with_initialization_script(ZOOM_SCRIPT);
builder.build(window)
}
#[cfg(test)]
mod tests {
use super::*;
#[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());
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:"));
}
#[test]
fn meta_allows_fetch_detects_double_quoted() {
let html = r#"<head><meta name="tinyview-allow" content="fetch"></head>"#;
assert!(meta_allows_fetch(html));
}
#[test]
fn meta_allows_fetch_detects_single_quoted() {
let html = r#"<head><meta name='tinyview-allow' content='fetch'></head>"#;
assert!(meta_allows_fetch(html));
}
#[test]
fn meta_allows_fetch_is_case_insensitive() {
let html = r#"<HEAD><META NAME="tinyview-allow" CONTENT="fetch"></HEAD>"#;
assert!(meta_allows_fetch(html));
}
#[test]
fn meta_allows_fetch_detects_token_among_others() {
let html = r#"<meta name="tinyview-allow" content="clipboard fetch storage">"#;
assert!(meta_allows_fetch(html));
}
#[test]
fn meta_allows_fetch_ignores_other_names() {
let html = r#"<meta name="description" content="fetch">"#;
assert!(!meta_allows_fetch(html));
}
#[test]
fn meta_allows_fetch_ignores_substring_tokens() {
let html = r#"<meta name="tinyview-allow" content="prefetch fetchall">"#;
assert!(!meta_allows_fetch(html));
}
#[test]
fn meta_allows_fetch_false_when_absent() {
let html = "<head><title>x</title></head><body>no meta here</body>";
assert!(!meta_allows_fetch(html));
}
#[test]
fn effective_perms_ors_meta_with_flag() {
let html = r#"<meta name="tinyview-allow" content="fetch">"#;
let eff = effective_perms(html, &Permissions::default());
assert!(eff.allow_fetch);
let eff = effective_perms(
"<head></head>",
&Permissions {
allow_fetch: true,
..Default::default()
},
);
assert!(eff.allow_fetch);
}
#[test]
fn effective_perms_does_not_touch_clipboard_or_storage() {
let html = r#"<meta name="tinyview-allow" content="fetch clipboard storage">"#;
let eff = effective_perms(html, &Permissions::default());
assert!(eff.allow_fetch);
assert!(!eff.allow_clipboard);
assert!(!eff.allow_storage);
}
#[test]
fn prepare_html_meta_opens_connect_src() {
let html = r#"<head><meta name="tinyview-allow" content="fetch"></head>"#;
let out = prepare_html(html, &Permissions::default(), false);
assert!(out.contains("connect-src https: http: ws: wss:"));
assert!(!out.contains("connect-src 'none'"));
}
#[test]
fn prepare_html_meta_forces_injection_in_raw_mode() {
let html = r#"<head><meta name="tinyview-allow" content="fetch"></head>"#;
let out = prepare_html(html, &Permissions::default(), true);
assert!(out.contains("<meta http-equiv=\"Content-Security-Policy\""));
assert!(out.contains("connect-src https: http: ws: wss:"));
}
#[test]
fn prepare_html_raw_mode_without_grant_skips_injection() {
let html = "<head></head>";
let out = prepare_html(html, &Permissions::default(), true);
assert!(!out.contains("Content-Security-Policy"));
}
}