Skip to main content

day_core/
documents.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3//! File activation is data delivery, never route navigation. See docs/documents.md.
4use std::{cell::RefCell, collections::VecDeque, rc::Rc, sync::Mutex};
5static PENDING: Mutex<VecDeque<Vec<String>>> = Mutex::new(VecDeque::new());
6day_reactive::tls_slots! {
7    documents;
8    static HANDLER: RefCell<Option<Rc<dyn Fn(Vec<String>)>>> = const { RefCell::new(None) };
9}
10/// Install the application's file-open handler. Called on the UI thread; pending cold-start
11/// activations are delivered on the next UI turn. Register once in the application root.
12pub fn on_open_files(handler: impl Fn(Vec<String>) + 'static) {
13    let handler: Rc<dyn Fn(Vec<String>)> = Rc::new(handler);
14    let previous = HANDLER.with(|h| h.borrow_mut().replace(handler.clone()));
15    drop(previous);
16    day_reactive::Scope::current().on_cleanup(move || {
17        // The app root can outlive this slot during thread-local teardown.
18        let removed = HANDLER.try_with(|h| {
19            if h.borrow()
20                .as_ref()
21                .is_some_and(|current| Rc::ptr_eq(current, &handler))
22            {
23                h.borrow_mut().take()
24            } else {
25                None
26            }
27        });
28        drop(removed);
29    });
30    if day_reactive::has_main_poster() {
31        day_reactive::on_main(drain_open_files);
32    }
33}
34/// Platform intake, callable before launch and from worker threads. Locators must be local
35/// paths or file URLs (mobile providers stage granted content before calling this).
36pub fn request_open_files(files: Vec<String>) {
37    let files: Vec<_> = files
38        .into_iter()
39        .filter(|p| {
40            if p.split_once(':').is_some_and(|(scheme, _)| {
41                scheme.len() > 1
42                    && scheme
43                        .bytes()
44                        .all(|b| b.is_ascii_alphanumeric() || b"+.-".contains(&b))
45            }) && !p.starts_with("file:")
46                && !std::path::Path::new(p).is_absolute()
47            {
48                crate::request_open_url(p);
49                false
50            } else {
51                !p.is_empty()
52            }
53        })
54        .collect();
55    if files.is_empty() {
56        return;
57    }
58    PENDING
59        .lock()
60        .unwrap_or_else(|e| e.into_inner())
61        .push_back(files);
62    if day_reactive::has_main_poster() {
63        day_reactive::on_main(drain_open_files);
64    }
65}
66pub(crate) fn drain_open_files() {
67    loop {
68        // A callback may replace or dispose its registration. Re-resolve between batches.
69        let Some(handler) = HANDLER.with(|h| h.borrow().clone()) else {
70            return;
71        };
72        let next = PENDING
73            .lock()
74            .unwrap_or_else(|e| e.into_inner())
75            .pop_front();
76        let Some(files) = next else { break };
77        handler(files);
78    }
79}
80/// Desktop file associations pass `--day-open-file <path>` once per file, or `--day-open-files`
81/// followed by filenames. Existing positional files also support shell drops onto executables;
82/// arbitrary flags, nonexistent paths and navigation URLs are ignored.
83pub(crate) fn launch_files(args: impl Iterator<Item = String>) -> Vec<String> {
84    let mut args = args.peekable();
85    let mut files = Vec::new();
86    while let Some(arg) = args.next() {
87        if arg == "--day-open-files" {
88            files.extend(args);
89            break;
90        }
91        if arg == "--day-open-url" {
92            if let Some(url) = args.next() {
93                files.push(url);
94            }
95        } else if !arg.starts_with('-') && arg.contains("://") {
96            files.push(arg);
97        } else if arg == "--day-open-file" {
98            if let Some(path) = args.next() {
99                files.push(path)
100            }
101        } else if !arg.starts_with('-') && std::path::Path::new(&arg).is_file() {
102            files.push(arg);
103        }
104    }
105    files
106}
107
108/// Deliver the launch arguments a second copy of the app forwarded to this one
109/// (docs/deep-links.md "Single-instance forwarding"): the files, URLs and routes it was started
110/// with arrive as if this process had been launched with them, and the app's front window comes
111/// forward. Backend-facing; call on the UI thread.
112pub fn forward_launch_args(args: Vec<String>) {
113    request_open_files(launch_files(args.into_iter()));
114    if let Some(window) = crate::windows::focused_window().or_else(crate::windows::initial_window) {
115        window.focus();
116    }
117}
118
119/// The link a launcher shortcut for `route` passes on a desktop command line
120/// (`--day-open-url <link>`): `<scheme>://<route>` with the app's URL scheme, which `day build`
121/// hands the binary as `DAY_APP_SCHEME`, else the bare route. Either form reaches the route
122/// through the deep-link intake.
123pub fn launch_link(route: &str) -> String {
124    let scheme = std::env::var("DAY_APP_SCHEME")
125        .ok()
126        .filter(|s| !s.is_empty())
127        .or_else(|| option_env!("DAY_APP_SCHEME").map(str::to_owned));
128    match scheme {
129        Some(scheme) => format!("{scheme}://{route}"),
130        None => route.to_owned(),
131    }
132}
133
134/// Backend bootstrap hook: claim launch arguments once. GTK passes them to GApplication
135/// so it can forward an open request to an existing process; other backends let core deliver.
136#[doc(hidden)]
137pub fn take_launch_files() -> Vec<String> {
138    static TAKEN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
139    if TAKEN.swap(true, std::sync::atomic::Ordering::Relaxed) {
140        Vec::new()
141    } else {
142        launch_files(std::env::args().skip(1))
143    }
144}
145#[cfg(test)]
146mod tests {
147    use super::*;
148    #[test]
149    fn cold_warm_reentrant_delivery_and_scope_cleanup() {
150        let received = Rc::new(RefCell::new(Vec::new()));
151        request_open_files(vec!["cold.epub".into()]);
152        drain_open_files(); // No consumer yet: the activation must remain queued.
153        let scope = day_reactive::Scope::child();
154        let seen = received.clone();
155        scope.enter(|| {
156            on_open_files(move |files| {
157                if files == ["cold.epub"] {
158                    request_open_files(vec!["reentrant.epub".into()]);
159                }
160                seen.borrow_mut().extend(files);
161            })
162        });
163        drain_open_files();
164        request_open_files(vec!["warm.epub".into()]);
165        drain_open_files();
166        assert_eq!(
167            *received.borrow(),
168            ["cold.epub", "reentrant.epub", "warm.epub"]
169        );
170        scope.dispose();
171        request_open_files(vec!["after-dispose.epub".into()]);
172        drain_open_files();
173        assert_eq!(received.borrow().len(), 3);
174        let second = day_reactive::Scope::child();
175        let seen = received.clone();
176        second.enter(|| on_open_files(move |files| seen.borrow_mut().extend(files)));
177        drain_open_files();
178        assert_eq!(received.borrow().last().unwrap(), "after-dispose.epub");
179        second.dispose();
180    }
181    #[test]
182    fn association_arguments_are_explicit_and_preserve_spaces() {
183        assert_eq!(
184            launch_files(
185                [
186                    "app",
187                    "--day-open-file",
188                    "/tmp/book name.epub",
189                    "--day-open-files",
190                    "a.epub",
191                    "b.epub"
192                ]
193                .into_iter()
194                .map(str::to_owned)
195            ),
196            ["/tmp/book name.epub", "a.epub", "b.epub"]
197        );
198        assert_eq!(
199            launch_files(
200                ["https://example.org", "--debug"]
201                    .into_iter()
202                    .map(str::to_owned)
203            ),
204            ["https://example.org"]
205        );
206        assert_eq!(
207            launch_files(
208                ["--day-open-url", "feed:https://example.org/rss?q=a%26b"]
209                    .into_iter()
210                    .map(str::to_owned)
211            ),
212            ["feed:https://example.org/rss?q=a%26b"]
213        );
214    }
215}