Skip to main content

qualia_client_core/
qapps_protocol.rs

1//! Loopback HTTP server mirroring Tauri's `qualia://localhost/` custom protocol.
2//!
3//! Serves installed qapp assets from `{storage_path}/Qapps/`
4//! so embedded WebViews can load sandboxed HTML without `file://` CORS restrictions.
5
6use crate::qapp_paths::{ensure_qapps_dir, qapps_dir};
7use std::path::{Component, Path, PathBuf};
8use std::sync::atomic::{AtomicU16, Ordering};
9use std::thread;
10
11static QAPPS_SERVER_PORT: AtomicU16 = AtomicU16::new(0);
12
13fn find_open_port(host: &str, start: u16) -> u16 {
14    for port in start..=4600 {
15        if std::net::TcpListener::bind((host, port)).is_ok() {
16            return port;
17        }
18    }
19    start
20}
21
22fn safe_path(base: &Path, request_path: &str) -> Option<PathBuf> {
23    let trimmed = request_path.trim_start_matches('/');
24    let mut out = base.to_path_buf();
25    for part in Path::new(trimmed).components() {
26        match part {
27            Component::Normal(name) => out.push(name),
28            Component::CurDir => {}
29            _ => return None,
30        }
31    }
32    if out.starts_with(base) {
33        Some(out)
34    } else {
35        None
36    }
37}
38
39fn guess_mime(path: &Path) -> &'static str {
40    match path.extension().and_then(|e| e.to_str()).unwrap_or("") {
41        "html" | "htm" => "text/html",
42        "js" => "application/javascript",
43        "css" => "text/css",
44        "json" => "application/json",
45        "png" => "image/png",
46        "jpg" | "jpeg" => "image/jpeg",
47        "svg" => "image/svg+xml",
48        "wasm" => "application/wasm",
49        "woff" | "woff2" => "font/woff2",
50        _ => "application/octet-stream",
51    }
52}
53
54fn qapps_root() -> Result<PathBuf, String> {
55    let state = crate::state::APP_STATE
56        .get()
57        .ok_or("APP_STATE not initialized")?;
58    let data_dir = state.config.lock().unwrap().storage_path.clone();
59    Ok(qapps_dir(&data_dir))
60}
61
62/// Start the qualia asset server on 127.0.0.1 (idempotent).
63pub fn start_qualia_protocol() -> Result<u16, String> {
64    let existing = QAPPS_SERVER_PORT.load(Ordering::SeqCst);
65    if existing != 0 {
66        return Ok(existing);
67    }
68
69    let state = crate::state::APP_STATE
70        .get()
71        .ok_or("APP_STATE not initialized")?;
72    let data_dir = state.config.lock().unwrap().storage_path.clone();
73    let root = ensure_qapps_dir(&data_dir).map_err(|e| e.to_string())?;
74    let port = find_open_port("127.0.0.1", 4567);
75    let serve_root = root.clone();
76
77    let server = tiny_http::Server::http(format!("127.0.0.1:{port}"))
78        .map_err(|e| format!("Bind qualia protocol: {e}"))?;
79    QAPPS_SERVER_PORT.store(port, Ordering::SeqCst);
80    eprintln!("Qualia qapps protocol listening on 127.0.0.1:{port}");
81
82    thread::spawn(move || {
83        for request in server.incoming_requests() {
84            let url_path = request.url().to_string();
85            let path_only = url_path.split('?').next().unwrap_or("/");
86            let mut file_path = match safe_path(&serve_root, path_only) {
87                Some(p) => p,
88                None => {
89                    let _ = request.respond(tiny_http::Response::empty(403));
90                    continue;
91                }
92            };
93            if file_path.is_dir() {
94                file_path.push("index.html");
95            }
96            match std::fs::read(&file_path) {
97                Ok(data) => {
98                    let mime = guess_mime(&file_path);
99                    let mut response = tiny_http::Response::from_data(data).with_status_code(200);
100                    response.add_header(
101                        tiny_http::Header::from_bytes(&b"Content-Type"[..], mime.as_bytes())
102                            .unwrap(),
103                    );
104                    response.add_header(
105                        tiny_http::Header::from_bytes(
106                            &b"Content-Security-Policy"[..],
107                            &b"default-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: ws: wss: http://127.0.0.1:8080 http://localhost:8080;"[..]
108                        ).unwrap(),
109                    );
110                    response.add_header(
111                        tiny_http::Header::from_bytes(
112                            &b"X-Content-Type-Options"[..],
113                            &b"nosniff"[..],
114                        )
115                        .unwrap(),
116                    );
117                    response.add_header(
118                        tiny_http::Header::from_bytes(&b"Referrer-Policy"[..], &b"no-referrer"[..])
119                            .unwrap(),
120                    );
121                    let _ = request.respond(response);
122                }
123                Err(_) => {
124                    let _ = request.respond(tiny_http::Response::empty(404));
125                }
126            }
127        }
128    });
129
130    Ok(port)
131}
132
133pub fn qualia_protocol_port() -> u16 {
134    QAPPS_SERVER_PORT.load(Ordering::SeqCst)
135}
136
137/// `http://127.0.0.1:{port}/{qapp}/index.html` — WebView-safe launch URL.
138pub fn qualia_qapp_asset_url(qapp_name: &str, asset_path: &str) -> Result<String, String> {
139    let port = qualia_protocol_port();
140    if port == 0 {
141        return Err("Qualia protocol server not started".into());
142    }
143    let root = qapps_root()?;
144    let qapp_dir = root.join(qapp_name);
145    if !qapp_dir.exists() {
146        return Err(format!("Qapp directory not found: {qapp_name}"));
147    }
148    let trimmed = asset_path.trim_start_matches('/');
149    let resolved = safe_path(&qapp_dir, trimmed)
150        .ok_or_else(|| format!("Invalid qapp asset path: {asset_path}"))?;
151    if !resolved.exists() {
152        return Err(format!("Qapp asset not found: {qapp_name}/{trimmed}"));
153    }
154    let launch_path = if trimmed.is_empty() {
155        "index.html"
156    } else {
157        trimmed
158    };
159    Ok(format!("http://127.0.0.1:{port}/{qapp_name}/{launch_path}"))
160}
161
162pub fn qualia_qapp_launch_url(qapp_name: &str) -> Result<String, String> {
163    qualia_qapp_asset_url(qapp_name, "index.html")
164}
165
166#[cfg(windows)]
167pub fn register_qualia_uri_handler(exe_path: &str) -> Result<(), String> {
168    use winreg::enums::*;
169    use winreg::RegKey;
170
171    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
172    let (classes, _) = hkcu
173        .create_subkey("Software\\Classes\\qualia")
174        .map_err(|e| e.to_string())?;
175    classes
176        .set_value("", &"URL:QualiaDB Protocol")
177        .map_err(|e| e.to_string())?;
178    classes
179        .set_value("URL Protocol", &"")
180        .map_err(|e| e.to_string())?;
181
182    let (icon, _) = classes
183        .create_subkey("DefaultIcon")
184        .map_err(|e| e.to_string())?;
185    icon.set_value("", &format!("{exe_path},0"))
186        .map_err(|e| e.to_string())?;
187
188    let (shell, _) = classes
189        .create_subkey("shell\\open\\command")
190        .map_err(|e| e.to_string())?;
191    shell
192        .set_value("", &format!("\"{exe_path}\" \"%1\""))
193        .map_err(|e| e.to_string())?;
194    Ok(())
195}
196
197#[cfg(not(windows))]
198pub fn register_qualia_uri_handler(_exe_path: &str) -> Result<(), String> {
199    Ok(())
200}