Skip to main content

day_piece_webview/
resources.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3//! Scoped, binary resource trees. Providers never run while holding the registry lock.
4use std::collections::HashMap;
5use std::sync::{
6    Arc, Mutex, OnceLock, Weak,
7    atomic::{AtomicU64, Ordering},
8};
9
10/// A normalized path inside the provider, without a leading slash. Query is kept separately;
11/// fragments are browser-local. Providers run on workers on native hosts and on the browser
12/// thread on wasm. Do not access Day signals or toolkit objects from this callback.
13#[derive(Clone, Debug)]
14pub struct ResourceRequest {
15    pub path: String,
16    pub query: Option<String>,
17    pub method: String,
18}
19/// An immutable binary response. Cloning shares the body. GET byte ranges and HEAD are handled
20/// by the adapter. Keep responses bounded; this API buffers a resource, never an entire site.
21#[derive(Clone, Debug)]
22pub struct ResourceResponse {
23    pub status: u16,
24    pub mime: String,
25    pub body: Arc<[u8]>,
26    pub headers: Vec<(String, String)>,
27}
28impl ResourceResponse {
29    pub fn new(mime: impl Into<String>, body: impl Into<Arc<[u8]>>) -> Self {
30        Self {
31            status: 200,
32            mime: mime.into(),
33            body: body.into(),
34            headers: Vec::new(),
35        }
36    }
37    pub fn not_found() -> Self {
38        Self::error(404)
39    }
40    pub fn error(status: u16) -> Self {
41        Self {
42            status,
43            ..Self::new("text/plain", Vec::new())
44        }
45    }
46}
47type Handler = dyn Fn(ResourceRequest) -> ResourceResponse + Send + Sync;
48struct Provider {
49    id: u64,
50    handler: Box<Handler>,
51}
52impl Drop for Provider {
53    fn drop(&mut self) {
54        registry().lock().unwrap().remove(&self.id);
55    }
56}
57/// A resource namespace, kept alive by this handle and by views displaying it. No listener,
58/// HTTP server, temporary extraction directory, or JavaScript/base64 transport is required.
59#[derive(Clone)]
60pub struct ResourceProvider(Arc<Provider>);
61impl std::fmt::Debug for ResourceProvider {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        f.debug_tuple("ResourceProvider").field(&self.0.id).finish()
64    }
65}
66impl PartialEq for ResourceProvider {
67    fn eq(&self, other: &Self) -> bool {
68        self.0.id == other.0.id
69    }
70}
71impl Eq for ResourceProvider {}
72fn registry() -> &'static Mutex<HashMap<u64, Weak<Provider>>> {
73    static REG: OnceLock<Mutex<HashMap<u64, Weak<Provider>>>> = OnceLock::new();
74    REG.get_or_init(Default::default)
75}
76impl ResourceProvider {
77    pub fn new(
78        handler: impl Fn(ResourceRequest) -> ResourceResponse + Send + Sync + 'static,
79    ) -> Self {
80        static NEXT: AtomicU64 = AtomicU64::new(1);
81        let p = Arc::new(Provider {
82            id: NEXT.fetch_add(1, Ordering::Relaxed),
83            handler: Box::new(handler),
84        });
85        registry().lock().unwrap().insert(p.id, Arc::downgrade(&p));
86        Self(p)
87    }
88    pub fn id(&self) -> u64 {
89        self.0.id
90    }
91    /// The engine-appropriate base, with trailing slash. Treat it as opaque.
92    pub fn base_url(&self) -> String {
93        if cfg!(target_arch = "wasm32") {
94            format!(
95                "assets/data/day-piece-webview/{}/{}/",
96                browser_namespace(),
97                self.id()
98            )
99        } else if cfg!(all(feature = "qt", day_qt_webengine)) {
100            format!("day-resource://p{}/", self.id())
101        } else if cfg!(all(windows, not(feature = "xaml"))) {
102            format!("http://day-resource.p{}/", self.id())
103        } else if cfg!(any(target_os = "android", windows, target_env = "ohos")) {
104            format!("https://day-resource.invalid/{}/", self.id())
105        } else {
106            format!("day-resource://p{}/", self.id())
107        }
108    }
109    /// Encode an archive-relative path; query and fragment, if needed, can be appended by the
110    /// caller. Literal %, # and ? in file names are preserved through percent encoding.
111    pub fn url(&self, path: &str) -> String {
112        use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
113        format!(
114            "{}{}",
115            self.base_url(),
116            path.split('/')
117                .map(|p| utf8_percent_encode(p, NON_ALPHANUMERIC).to_string())
118                .collect::<Vec<_>>()
119                .join("/")
120        )
121    }
122}
123
124pub(crate) fn respond(id: u64, url: &str, method: &str, range: &str) -> ResourceResponse {
125    let p = registry().lock().unwrap().get(&id).and_then(Weak::upgrade);
126    let Some(p) = p else {
127        return ResourceResponse::error(410);
128    };
129    let base = ResourceProvider(p.clone()).base_url();
130    // wasm URLs are absolute on arrival; compare their full reserved pathname, not an arbitrary suffix.
131    let Ok(parsed) = url::Url::parse(url) else {
132        return ResourceResponse::error(400);
133    };
134    let relative = if cfg!(target_arch = "wasm32") {
135        parsed
136            .path()
137            .split_once(&format!(
138                "/assets/data/day-piece-webview/{}/{id}/",
139                browser_namespace()
140            ))
141            .map(|(_, p)| p)
142    } else {
143        url.strip_prefix(&base)
144            .map(|s| s.split(['?', '#']).next().unwrap_or(""))
145    };
146    let Some(relative) = relative else {
147        return ResourceResponse::error(403);
148    };
149    let Ok(path) = percent_encoding::percent_decode_str(relative).decode_utf8() else {
150        return ResourceResponse::error(400);
151    };
152    if path.starts_with('/')
153        || path.contains(['\\', '\0'])
154        || path.split('/').any(|s| s == ".." || s == ".")
155    {
156        return ResourceResponse::error(403);
157    }
158    if method != "GET" && method != "HEAD" {
159        return ResourceResponse::error(405);
160    }
161    let request = ResourceRequest {
162        path: path.into_owned(),
163        query: parsed.query().map(str::to_owned),
164        method: method.into(),
165    };
166    let mut r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (p.handler)(request)))
167        .unwrap_or_else(|_| ResourceResponse::error(500));
168    // Android's WebResourceResponse rejects redirects. Providers return content or errors;
169    // navigation belongs to the view, not to an implicit redirect from a resource callback.
170    if !(200..=599).contains(&r.status)
171        || (300..400).contains(&r.status)
172        || r.mime.contains(['\r', '\n', '\0'])
173    {
174        return ResourceResponse::error(500);
175    }
176    r.headers.retain(|(k, v)| {
177        !k.is_empty()
178            && k.bytes()
179                .all(|c| c.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&c))
180            && v.chars()
181                .all(|c| c == '\t' || (' '..='\u{ff}').contains(&c) && c != '\u{7f}')
182            && ![
183                "content-type",
184                "content-length",
185                "content-range",
186                "accept-ranges",
187                "cache-control",
188                "x-content-type-options",
189            ]
190            .iter()
191            .any(|n| k.eq_ignore_ascii_case(n))
192    });
193    r.headers.push(("Cache-Control".into(), "no-store".into()));
194    r.headers
195        .push(("X-Content-Type-Options".into(), "nosniff".into()));
196    if r.status == 204 || r.status == 205 {
197        r.body = Arc::from([]);
198    }
199    let len = r.body.len();
200    if r.status == 200 {
201        r.headers.push(("Accept-Ranges".into(), "bytes".into()));
202        if !range.is_empty() && method == "GET" {
203            if let Some((a, b)) = byte_range(range, len) {
204                r.status = 206;
205                r.body = Arc::from(&r.body[a..=b]);
206                r.headers
207                    .push(("Content-Range".into(), format!("bytes {a}-{b}/{len}")));
208            } else {
209                r.status = 416;
210                r.body = Arc::from([]);
211                r.headers
212                    .push(("Content-Range".into(), format!("bytes */{len}")));
213            }
214        }
215    }
216    r.headers
217        .push(("Content-Length".into(), r.body.len().to_string()));
218    if method == "HEAD" {
219        r.body = Arc::from([]);
220    }
221    r
222}
223fn byte_range(s: &str, len: usize) -> Option<(usize, usize)> {
224    if len == 0 {
225        return None;
226    }
227    let (a, b) = s.strip_prefix("bytes=")?.split_once('-')?;
228    if a.is_empty() {
229        let n = b.parse::<usize>().ok()?;
230        return (n > 0).then_some((len.saturating_sub(n), len - 1));
231    }
232    let a: usize = a.parse().ok()?;
233    let b = if b.is_empty() {
234        len - 1
235    } else {
236        b.parse::<usize>().ok()?.min(len - 1)
237    };
238    (a <= b && a < len).then_some((a, b))
239}
240
241#[cfg(not(target_arch = "wasm32"))]
242pub(crate) fn background(work: impl FnOnce() + Send + 'static) {
243    type Work = Box<dyn FnOnce() + Send>;
244    static QUEUE: OnceLock<std::sync::mpsc::Sender<Work>> = OnceLock::new();
245    let queue = QUEUE.get_or_init(|| {
246        let (tx, rx) = std::sync::mpsc::channel::<Work>();
247        let rx = Arc::new(Mutex::new(rx));
248        for _ in 0..4 {
249            let rx = rx.clone();
250            std::thread::spawn(move || {
251                loop {
252                    let work = rx.lock().unwrap().recv();
253                    let Ok(work) = work else { break };
254                    work();
255                }
256            });
257        }
258        tx
259    });
260    // Queue request metadata, not inflated resources. A fixed pool avoids creating a thread
261    // for every image in a large document, and enqueueing never blocks the UI.
262    let _ = queue.send(Box::new(work));
263}
264
265// Owned C ABI response used by Qt, WebView2, JNI and ArkWeb. Buffers remain valid until free.
266#[repr(C)]
267pub(crate) struct WireResponse {
268    pub status: u16,
269    pub mime: std::ffi::CString,
270    pub headers: std::ffi::CString,
271    pub response: ResourceResponse,
272}
273pub(crate) fn wire(r: ResourceResponse) -> Box<WireResponse> {
274    let headers = r
275        .headers
276        .iter()
277        .map(|(k, v)| format!("{k}: {v}\r\n"))
278        .collect::<String>();
279    Box::new(WireResponse {
280        status: r.status,
281        mime: std::ffi::CString::new(r.mime.as_str()).unwrap_or_default(),
282        headers: std::ffi::CString::new(headers).unwrap_or_default(),
283        response: r,
284    })
285}
286unsafe fn cstr<'a>(s: *const std::ffi::c_char) -> &'a str {
287    if s.is_null() {
288        ""
289    } else {
290        unsafe { std::ffi::CStr::from_ptr(s) }
291            .to_str()
292            .unwrap_or("")
293    }
294}
295#[unsafe(no_mangle)]
296pub unsafe extern "C" fn day_web_resource_read(
297    id: u64,
298    url: *const std::ffi::c_char,
299    method: *const std::ffi::c_char,
300    range: *const std::ffi::c_char,
301) -> *mut WireResponse {
302    Box::into_raw(wire(respond(
303        id,
304        unsafe { cstr(url) },
305        unsafe { cstr(method) },
306        unsafe { cstr(range) },
307    )))
308}
309#[unsafe(no_mangle)]
310pub unsafe extern "C" fn day_web_resource_free(r: *mut WireResponse) {
311    if !r.is_null() {
312        drop(unsafe { Box::from_raw(r) })
313    }
314}
315#[unsafe(no_mangle)]
316pub unsafe extern "C" fn day_web_resource_status(r: &WireResponse) -> u16 {
317    r.status
318}
319#[unsafe(no_mangle)]
320pub unsafe extern "C" fn day_web_resource_mime(r: &WireResponse) -> *const std::ffi::c_char {
321    r.mime.as_ptr()
322}
323#[unsafe(no_mangle)]
324pub unsafe extern "C" fn day_web_resource_headers(r: &WireResponse) -> *const std::ffi::c_char {
325    r.headers.as_ptr()
326}
327#[unsafe(no_mangle)]
328pub unsafe extern "C" fn day_web_resource_data(r: &WireResponse) -> *const u8 {
329    r.response.body.as_ptr()
330}
331#[unsafe(no_mangle)]
332pub unsafe extern "C" fn day_web_resource_len(r: &WireResponse) -> usize {
333    r.response.body.len()
334}
335
336#[cfg(not(target_arch = "wasm32"))]
337#[unsafe(no_mangle)]
338pub unsafe extern "C" fn day_web_resource_start(
339    id: u64,
340    url: *const std::ffi::c_char,
341    method: *const std::ffi::c_char,
342    range: *const std::ffi::c_char,
343    context: *mut std::ffi::c_void,
344    done: extern "C" fn(*mut std::ffi::c_void, *mut WireResponse),
345) {
346    let (url, method, range) = unsafe {
347        (
348            cstr(url).to_owned(),
349            cstr(method).to_owned(),
350            cstr(range).to_owned(),
351        )
352    };
353    let context = context as usize;
354    background(move || {
355        let r = Box::into_raw(wire(respond(id, &url, &method, &range)));
356        done(context as *mut _, r);
357    });
358}
359
360#[cfg(all(feature = "dom", target_arch = "wasm32"))]
361fn browser_namespace() -> String {
362    crate::browser::namespace()
363}
364#[cfg(not(all(feature = "dom", target_arch = "wasm32")))]
365fn browser_namespace() -> String {
366    String::new()
367}
368
369impl ResourceProvider {
370    /// Mount a bundled directory at `__day_assets/` alongside dynamic resources. Pass the
371    /// generated `res::assets::<directory>` constant. Paths inside the directory remain
372    /// document-relative. The directory is trusted app content; the callback owns other paths.
373    pub fn with_site(
374        site: impl crate::IntoInlineSite,
375        handler: impl Fn(ResourceRequest) -> ResourceResponse + Send + Sync + 'static,
376    ) -> Self {
377        let root = site.into_inline_site().root;
378        Self::new(move |request| {
379            let Some(path) = request.path.strip_prefix("__day_assets/") else {
380                return handler(request);
381            };
382            let name = format!("{root}/{path}");
383            #[cfg(target_arch = "wasm32")]
384            {
385                let mut r = ResourceResponse::new("application/octet-stream", Vec::new());
386                r.headers.push(("X-Day-Bundled-Asset".into(), name));
387                r
388            }
389            #[cfg(not(target_arch = "wasm32"))]
390            {
391                match day_spec::resource(day_spec::AssetName::dynamic(name)) {
392                    Some(resource) => {
393                        ResourceResponse::new(mime_for_path(path), resource.as_slice().to_vec())
394                    }
395                    None => ResourceResponse::not_found(),
396                }
397            }
398        })
399    }
400}
401#[cfg(not(target_arch = "wasm32"))]
402fn mime_for_path(path: &str) -> &'static str {
403    match path.rsplit('.').next().unwrap_or("") {
404        "html" | "htm" => "text/html",
405        "xhtml" => "application/xhtml+xml",
406        "css" => "text/css",
407        "js" | "mjs" => "text/javascript",
408        "json" => "application/json",
409        "wasm" => "application/wasm",
410        "svg" => "image/svg+xml",
411        "png" => "image/png",
412        "jpg" | "jpeg" => "image/jpeg",
413        "webp" => "image/webp",
414        "gif" => "image/gif",
415        "woff" => "font/woff",
416        "woff2" => "font/woff2",
417        "ttf" => "font/ttf",
418        "otf" => "font/otf",
419        _ => "application/octet-stream",
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    #[test]
427    fn namespace_validation_precedes_callback_and_preserves_queries() {
428        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
429        let count = calls.clone();
430        let p = ResourceProvider::new(move |r| {
431            count.fetch_add(1, Ordering::Relaxed);
432            assert_eq!(r.path, "text/café #1.xhtml");
433            assert_eq!(r.query.as_deref(), Some("mode=print"));
434            ResourceResponse::new("text/html", Vec::new())
435        });
436        let other = ResourceProvider::new(|_| unreachable!("wrong provider"));
437        for path in [
438            "%2E%2E/private",
439            "a/%2e%2e/b",
440            "%2Fprivate",
441            "a%5Cb",
442            "%00",
443            "%FF",
444        ] {
445            assert!(respond(p.id(), &format!("{}{path}", p.base_url()), "GET", "").status >= 400);
446        }
447        assert_eq!(respond(p.id(), &other.url("x"), "GET", "").status, 403);
448        assert_eq!(respond(p.id(), &p.url("x"), "POST", "").status, 405);
449        assert_eq!(calls.load(Ordering::Relaxed), 0);
450        assert_eq!(
451            respond(
452                p.id(),
453                &(p.url("text/café #1.xhtml") + "?mode=print#anchor"),
454                "GET",
455                ""
456            )
457            .status,
458            200
459        );
460        assert_eq!(calls.load(Ordering::Relaxed), 1);
461    }
462    #[test]
463    fn shared_bytes_head_and_response_headers() {
464        let bytes: Arc<[u8]> = Arc::from([0, 1, 255]);
465        let body = bytes.clone();
466        let p = ResourceProvider::new(move |_| {
467            let mut r = ResourceResponse::new("application/octet-stream", body.clone());
468            r.headers = vec![
469                ("X-Fixture".into(), "yes".into()),
470                ("Content-Length".into(), "999".into()),
471                ("Bad:Name".into(), "x".into()),
472                ("X-Bad".into(), "x\r\ny".into()),
473            ];
474            r
475        });
476        let r = respond(p.id(), &p.url("bytes"), "GET", "");
477        assert!(Arc::ptr_eq(&r.body, &bytes));
478        let h: HashMap<_, _> = r.headers.into_iter().collect();
479        assert_eq!(h["Content-Length"], "3");
480        assert_eq!(h["X-Fixture"], "yes");
481        assert!(!h.contains_key("Bad:Name") && !h.contains_key("X-Bad"));
482        let head = respond(p.id(), &p.url("bytes"), "HEAD", "");
483        assert!(head.body.is_empty());
484        assert!(
485            head.headers
486                .contains(&("Content-Length".into(), "3".into()))
487        );
488    }
489    #[test]
490    fn provider_can_use_registry_reentrantly_from_parallel_requests() {
491        let p = ResourceProvider::new(|_| {
492            let nested = ResourceProvider::new(|_| ResourceResponse::not_found());
493            assert_eq!(
494                respond(nested.id(), &nested.url("missing"), "GET", "").status,
495                404
496            );
497            ResourceResponse::new("text/plain", vec![42])
498        });
499        let tasks: Vec<_> = (0..8)
500            .map(|_| {
501                let p = p.clone();
502                std::thread::spawn(move || respond(p.id(), &p.url("fixture"), "GET", "").body)
503            })
504            .collect();
505        for task in tasks {
506            assert_eq!(&*task.join().unwrap(), &[42]);
507        }
508    }
509    #[test]
510    fn errors_and_empty_statuses_are_portable() {
511        for status in [100, 302, 600] {
512            let p = ResourceProvider::new(move |_| ResourceResponse::error(status));
513            assert_eq!(respond(p.id(), &p.url("fixture"), "GET", "").status, 500);
514        }
515        for status in [204, 205] {
516            let p = ResourceProvider::new(move |_| ResourceResponse {
517                status,
518                ..ResourceResponse::new("text/plain", vec![1])
519            });
520            assert!(
521                respond(p.id(), &p.url("fixture"), "GET", "")
522                    .body
523                    .is_empty()
524            );
525        }
526        let p = ResourceProvider::new(|_| panic!("synthetic provider failure"));
527        assert_eq!(respond(p.id(), &p.url("fixture"), "GET", "").status, 500);
528    }
529    #[test]
530    fn relative_paths_binary_ranges_and_lifetime() {
531        let p = ResourceProvider::new(|r| {
532            assert_eq!(r.path, "images/a b.png");
533            ResourceResponse::new("image/png", vec![0, 1, 2, 255])
534        });
535        let u = p.url("images/a b.png");
536        assert_eq!(&*respond(p.id(), &u, "GET", "").body, &[0, 1, 2, 255]);
537        let r = respond(p.id(), &u, "GET", "bytes=1-2");
538        assert_eq!(r.status, 206);
539        assert_eq!(&*r.body, &[1, 2]);
540        assert_eq!(respond(p.id(), &u, "GET", "bytes=99-").status, 416);
541        assert!(respond(p.id(), &u, "HEAD", "").body.is_empty());
542        assert_eq!(
543            respond(
544                p.id(),
545                &format!("{}%2e%2e/private", p.base_url()),
546                "GET",
547                ""
548            )
549            .status,
550            403
551        );
552        assert_eq!(
553            respond(p.id(), "https://elsewhere.invalid/", "GET", "").status,
554            403
555        );
556        let id = p.id();
557        drop(p);
558        assert_eq!(respond(id, &u, "GET", "").status, 410);
559    }
560    #[test]
561    fn ranges() {
562        assert_eq!(byte_range("bytes=-2", 4), Some((2, 3)));
563        assert_eq!(byte_range("bytes=1-", 4), Some((1, 3)));
564        assert_eq!(byte_range("bytes=1-2,3-4", 8), None);
565    }
566}