Skip to main content

rightkit_tauri_shell/
deep_link.rs

1//! Deep links / URL schemes: validation, a queue that waits for the frontend, and registration.
2//!
3//! Base: CodeRight `deep_link` (strict parse, control-character and traversal rejection, queue
4//! until the renderer is ready). Generalised from two fixed `coderight://` shapes to any
5//! registered scheme; the unknown-origin consent decision table stays app-specific. Fixed a donor
6//! bug: percent-decoding now decodes UTF-8 bytes, not bytes-as-chars.
7//!
8//! Delivery paths: macOS `RunEvent::Opened`; Windows/Linux the argv of the launch (first launch
9//! or forwarded by [`crate::instance`]).
10
11use std::collections::{BTreeMap, VecDeque};
12use std::sync::Mutex;
13
14use serde::{Deserialize, Serialize};
15
16const MAX_LEN: usize = 4096;
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub struct DeepLink {
21    pub scheme: String,
22    pub host: String,
23    pub segments: Vec<String>,
24    pub query: BTreeMap<String, String>,
25    pub raw: String,
26}
27
28fn percent_decode(raw: &str) -> Option<String> {
29    let bytes = raw.as_bytes();
30    let mut out = Vec::with_capacity(bytes.len());
31    let mut i = 0;
32    while i < bytes.len() {
33        if bytes[i] == b'%' {
34            if i + 2 >= bytes.len() {
35                return None;
36            }
37            let hi = (bytes[i + 1] as char).to_digit(16)?;
38            let lo = (bytes[i + 2] as char).to_digit(16)?;
39            out.push(((hi << 4) | lo) as u8);
40            i += 3;
41        } else {
42            out.push(bytes[i]);
43            i += 1;
44        }
45    }
46    let s = String::from_utf8(out).ok()?;
47    (!s.chars().any(char::is_control)).then_some(s)
48}
49
50/// Parse `<scheme>://<host>/<seg>/<seg>?k=v#frag`. Only `schemes` are accepted; control
51/// characters, `..`/`.` segments, empty hosts and over-long input are rejected.
52pub fn parse(raw: &str, schemes: &[String]) -> Option<DeepLink> {
53    let raw = raw.trim();
54    if raw.len() > MAX_LEN || raw.chars().any(char::is_control) {
55        return None;
56    }
57    let (scheme, rest) = raw.split_once("://")?;
58    let scheme = scheme.to_ascii_lowercase();
59    if !schemes.iter().any(|s| s.eq_ignore_ascii_case(&scheme)) {
60        return None;
61    }
62    let rest = rest.split('#').next()?;
63    let (path_part, query_part) = match rest.split_once('?') {
64        Some((p, q)) => (p, Some(q)),
65        None => (rest, None),
66    };
67    let mut parts = path_part.split('/');
68    let host = percent_decode(parts.next()?)?;
69    if host.is_empty() {
70        return None;
71    }
72    let mut segments = Vec::new();
73    for seg in parts.filter(|s| !s.is_empty()) {
74        let seg = percent_decode(seg)?;
75        if seg == "." || seg == ".." || seg.contains('/') || seg.contains('\\') {
76            return None;
77        }
78        segments.push(seg);
79    }
80    let mut query = BTreeMap::new();
81    if let Some(q) = query_part {
82        for pair in q.split('&').filter(|p| !p.is_empty()) {
83            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
84            query.insert(
85                percent_decode(&k.replace('+', " "))?,
86                percent_decode(&v.replace('+', " "))?,
87            );
88        }
89    }
90    Some(DeepLink {
91        scheme,
92        host,
93        segments,
94        query,
95        raw: raw.to_owned(),
96    })
97}
98
99/// Links wait here until the frontend reports ready, then drain in arrival order.
100#[derive(Default)]
101pub struct DeepLinkQueue {
102    pending: Mutex<VecDeque<DeepLink>>,
103}
104
105impl DeepLinkQueue {
106    pub fn push_all<'a>(
107        &self,
108        raw: impl IntoIterator<Item = &'a str>,
109        schemes: &[String],
110    ) -> usize {
111        let mut q = self.pending.lock().unwrap_or_else(|e| e.into_inner());
112        for r in raw {
113            if let Some(link) = parse(r, schemes) {
114                q.push_back(link);
115            }
116        }
117        q.len()
118    }
119    pub fn drain(&self) -> Vec<DeepLink> {
120        self.pending
121            .lock()
122            .unwrap_or_else(|e| e.into_inner())
123            .drain(..)
124            .collect()
125    }
126}
127
128/// Register `scheme` for the current user, pointing at `executable` (Windows: HKCU registry;
129/// macOS registers through the bundle's `CFBundleURLTypes`, Linux through a `.desktop` MIME entry
130/// installed by the packager, so both return Ok(false) here).
131pub fn register_scheme(scheme: &str, executable: &std::path::Path) -> Result<bool, String> {
132    #[cfg(windows)]
133    {
134        use std::os::windows::process::CommandExt;
135        let base = format!(r"HKCU\Software\Classes\{scheme}");
136        let run = |args: &[&str]| -> Result<(), String> {
137            let out = std::process::Command::new("reg.exe")
138                .creation_flags(0x0800_0000)
139                .args(args)
140                .output()
141                .map_err(|e| e.to_string())?;
142            if out.status.success() {
143                Ok(())
144            } else {
145                Err(String::from_utf8_lossy(&out.stderr).trim().to_owned())
146            }
147        };
148        run(&["ADD", &base, "/ve", "/d", &format!("URL:{scheme}"), "/f"])?;
149        run(&["ADD", &base, "/v", "URL Protocol", "/d", "", "/f"])?;
150        run(&[
151            "ADD",
152            &format!(r"{base}\shell\open\command"),
153            "/ve",
154            "/d",
155            &format!("\"{}\" \"%1\"", executable.display()),
156            "/f",
157        ])?;
158        Ok(true)
159    }
160    #[cfg(not(windows))]
161    {
162        let _ = (scheme, executable);
163        Ok(false)
164    }
165}