qualia_client_core/
qapps_protocol.rs1use 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
62pub 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
137pub 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}