Skip to main content

reddb_server/server/
ui_deeplink.rs

1//! Deep-link dispatch for `red ui <uri>` (ADR 0051, issue #1046, PRD #1041).
2//!
3//! The default `red ui <uri>` (no `--server`) prefers the installed desktop
4//! app via the `redui://` URL scheme and falls back to the served browser
5//! bridge ([`super::ui_bridge`]) when no handler is registered. This module
6//! owns the *decision*, the canonicalised deep-link *string*, and the OS
7//! handoff — all behind a [`DeepLinkEnv`] seam so both branches (handoff vs
8//! fallback) and the deep-link URL are unit-testable without touching the OS.
9//!
10//! The dispatched `redui://?connect=<canonical-uri>` URL carries the target
11//! only — never a credential. Auth handoff is a separate slice (ADR 0051: the
12//! token crosses via a local secret channel, never the deep-link URL).
13
14use std::path::{Component, Path, PathBuf};
15
16/// Which dispatch path `red ui` should take, before consulting the OS.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum DispatchMode {
19    /// Default: prefer the desktop app when its `redui://` handler is
20    /// registered, else fall back to the served browser bridge.
21    Auto,
22    /// `--server`: force the browser-served bridge path.
23    Server,
24    /// `--desktop`: force the desktop download/install path.
25    Desktop,
26}
27
28impl DispatchMode {
29    /// Resolve the mode from the `--server` / `--desktop` flags. `--server`
30    /// wins if both are somehow set — it is the path that always works.
31    pub fn from_flags(server: bool, desktop: bool) -> Self {
32        if server {
33            DispatchMode::Server
34        } else if desktop {
35            DispatchMode::Desktop
36        } else {
37            DispatchMode::Auto
38        }
39    }
40}
41
42/// What the caller (`red ui`) should do once dispatch has run.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum DispatchOutcome {
45    /// Handed off to the desktop app — [`DeepLinkEnv::open_url`] was already
46    /// invoked with `deep_link`. The caller prints the handoff line and exits
47    /// cleanly; it must *not* start the bridge.
48    HandedOff {
49        /// The exact `redui://?connect=…` URL that was opened.
50        deep_link: String,
51    },
52    /// Serve the pinned bundle and open the browser (the [`super::ui_bridge`]
53    /// path). When `upsell` is set, the caller first prints the one-line nudge
54    /// to install the desktop app for a faster native shell.
55    ServeBrowser {
56        /// True only when we fell back from [`DispatchMode::Auto`] because no
57        /// handler was registered — print the install upsell.
58        upsell: bool,
59    },
60    /// `--desktop` was requested but no `redui://` handler is registered, so
61    /// there was nothing to hand off to. The caller prints install guidance.
62    /// (The download/install itself lives in the `red-ui` repo — ADR 0051.)
63    DesktopNotInstalled,
64}
65
66/// The OS-touching seam dispatch runs over: probing whether the `redui://`
67/// handler is registered, and opening a URL with the platform handler. Both
68/// branches of [`dispatch`] are driven through this trait so tests assert the
69/// decision and the emitted deep-link string without any OS state.
70pub trait DeepLinkEnv {
71    /// Whether a handler for the `redui://` URL scheme is registered (i.e.
72    /// the desktop app is installed).
73    fn handler_registered(&self) -> bool;
74    /// Open `url` with the platform default handler (the `xdg-open` /
75    /// `open` / `start` equivalent). Returns the spawn error on failure.
76    fn open_url(&self, url: &str) -> Result<(), String>;
77}
78
79/// Decide-and-dispatch. Pure decision logic over the [`DeepLinkEnv`] seam:
80///
81/// - [`DispatchMode::Server`] → [`DispatchOutcome::ServeBrowser`] (no upsell);
82///   the handler is never probed and no URL is opened.
83/// - [`DispatchMode::Auto`] → if the handler is registered, build the deep
84///   link, open it, and return [`DispatchOutcome::HandedOff`]; otherwise
85///   [`DispatchOutcome::ServeBrowser`] with the upsell.
86/// - [`DispatchMode::Desktop`] → if the handler is registered, hand off the
87///   same way; otherwise [`DispatchOutcome::DesktopNotInstalled`].
88///
89/// `canonical_uri` must already be canonicalised (see
90/// [`canonicalize_target_uri`]); it is embedded verbatim (percent-encoded) in
91/// the deep link and never carries a credential.
92pub fn dispatch(
93    mode: DispatchMode,
94    canonical_uri: &str,
95    env: &dyn DeepLinkEnv,
96) -> Result<DispatchOutcome, String> {
97    match mode {
98        DispatchMode::Server => Ok(DispatchOutcome::ServeBrowser { upsell: false }),
99        DispatchMode::Auto => {
100            if env.handler_registered() {
101                let deep_link = build_deep_link(canonical_uri);
102                env.open_url(&deep_link)?;
103                Ok(DispatchOutcome::HandedOff { deep_link })
104            } else {
105                Ok(DispatchOutcome::ServeBrowser { upsell: true })
106            }
107        }
108        DispatchMode::Desktop => {
109            if env.handler_registered() {
110                let deep_link = build_deep_link(canonical_uri);
111                env.open_url(&deep_link)?;
112                Ok(DispatchOutcome::HandedOff { deep_link })
113            } else {
114                Ok(DispatchOutcome::DesktopNotInstalled)
115            }
116        }
117    }
118}
119
120/// Build the `redui://?connect=<canonical-uri>` deep link (ADR 0051). The
121/// canonical URI is percent-encoded so query-breaking characters (spaces,
122/// `&`, `?`, `#`, …) survive, while the readable URI shape (`file:///…`,
123/// `red://…`) is preserved. The link carries the target only — never a token.
124pub fn build_deep_link(canonical_uri: &str) -> String {
125    format!("redui://?connect={}", percent_encode_connect(canonical_uri))
126}
127
128/// Build a deep link that *also* tells the desktop app where to fetch the
129/// held credential from (issue #1048). The `handoff` query value is the
130/// **loopback handoff URL** — `http://127.0.0.1:<port>/handoff/<nonce>` — which
131/// carries the single-use nonce, never the secret. The desktop app fetches the
132/// token from there over the local secret channel; nothing sensitive rides the
133/// deep link, so the token never lands in `ps`, shell history, or URL logs.
134pub fn build_deep_link_with_handoff(canonical_uri: &str, handoff_url: &str) -> String {
135    format!(
136        "redui://?connect={}&handoff={}",
137        percent_encode_connect(canonical_uri),
138        percent_encode_connect(handoff_url),
139    )
140}
141
142/// Percent-encode a target URI for the `connect=` query value. Keeps the
143/// characters that are already query-safe and shape-significant for our
144/// supported schemes (`A-Za-z0-9` and `-._~:/`), and encodes everything else —
145/// including `%` itself — as upper-case `%XX`.
146fn percent_encode_connect(value: &str) -> String {
147    let mut out = String::with_capacity(value.len());
148    for &byte in value.as_bytes() {
149        let keep =
150            byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b':' | b'/');
151        if keep {
152            out.push(byte as char);
153        } else {
154            out.push('%');
155            out.push(hex_upper(byte >> 4));
156            out.push(hex_upper(byte & 0x0f));
157        }
158    }
159    out
160}
161
162fn hex_upper(nibble: u8) -> char {
163    match nibble {
164        0..=9 => (b'0' + nibble) as char,
165        _ => (b'A' + (nibble - 10)) as char,
166    }
167}
168
169/// Canonicalise a `red ui` target URI for embedding in the deep link.
170///
171/// A `file://` / bare-path target is resolved to an absolute, lexically
172/// normalised `file://` URI — the OS handler runs with a different cwd, so a
173/// relative path would break. Any other supported scheme (`red://`, `reds://`,
174/// `red+ws://`, `red+wss://`) is already location-independent and passes
175/// through unchanged. `cwd` is injected so the file branch is testable without
176/// depending on the process working directory.
177pub fn canonicalize_target_uri(uri: &str, cwd: &Path) -> Result<String, String> {
178    match super::ui_bridge::classify_ui_target(uri)? {
179        super::ui_bridge::UiTarget::File => canonicalize_file_uri(uri, cwd),
180        _ => Ok(uri.to_string()),
181    }
182}
183
184/// Resolve a `file://` / bare-path target to an absolute `file://` URI using
185/// `cwd` as the base for relative paths. Folds `.`/`..` lexically so the
186/// result never depends on the target existing on disk.
187fn canonicalize_file_uri(input: &str, cwd: &Path) -> Result<String, String> {
188    let path_part = input.strip_prefix("file://").unwrap_or(input);
189    if path_part.is_empty() {
190        return Err("file:// URI has no path".to_string());
191    }
192
193    let raw = Path::new(path_part);
194    let absolute = if raw.is_absolute() {
195        raw.to_path_buf()
196    } else {
197        cwd.join(raw)
198    };
199
200    let mut normalized = PathBuf::new();
201    for component in absolute.components() {
202        match component {
203            Component::CurDir => {}
204            Component::ParentDir => {
205                normalized.pop();
206            }
207            other => normalized.push(other.as_os_str()),
208        }
209    }
210
211    let rendered = normalized
212        .to_str()
213        .ok_or_else(|| "resolved path is not valid UTF-8".to_string())?;
214    Ok(format!("file://{rendered}"))
215}
216
217/// The production [`DeepLinkEnv`]: probes the OS for the `redui://` handler and
218/// opens URLs via the platform default handler.
219///
220/// The probe can be overridden with `RED_UI_DEEPLINK_REGISTERED` (truthy =>
221/// registered, `0`/`false`/`no` => not registered) — useful for forcing a
222/// branch in manual testing or on platforms without a cheap probe. When unset,
223/// it falls back to a best-effort per-OS check.
224pub struct OsDeepLinkEnv;
225
226impl DeepLinkEnv for OsDeepLinkEnv {
227    fn handler_registered(&self) -> bool {
228        if let Some(forced) = env_override("RED_UI_DEEPLINK_REGISTERED") {
229            return forced;
230        }
231        os_handler_registered()
232    }
233
234    fn open_url(&self, url: &str) -> Result<(), String> {
235        open_url_with_os_handler(url)
236    }
237}
238
239/// Parse a truthy/falsy override env var. Returns `None` when unset/empty so
240/// the caller falls back to the real probe.
241fn env_override(key: &str) -> Option<bool> {
242    match std::env::var(key) {
243        Ok(value) => {
244            let v = value.trim().to_ascii_lowercase();
245            if v.is_empty() {
246                None
247            } else {
248                Some(!matches!(v.as_str(), "0" | "false" | "no" | "off"))
249            }
250        }
251        Err(_) => None,
252    }
253}
254
255/// Best-effort, per-OS probe for a registered `redui://` scheme handler.
256#[cfg(target_os = "linux")]
257fn os_handler_registered() -> bool {
258    // `xdg-mime query default x-scheme-handler/redui` prints the handler's
259    // .desktop file name (and exits 0) when one is registered, nothing
260    // otherwise.
261    std::process::Command::new("xdg-mime")
262        .args(["query", "default", "x-scheme-handler/redui"])
263        .output()
264        .map(|out| out.status.success() && !out.stdout.is_empty())
265        .unwrap_or(false)
266}
267
268#[cfg(target_os = "windows")]
269fn os_handler_registered() -> bool {
270    // A registered URL-scheme handler lives under HKCU/HKCR\Software\Classes\redui.
271    let user = std::process::Command::new("reg")
272        .args(["query", "HKCU\\Software\\Classes\\redui", "/ve"])
273        .output()
274        .map(|out| out.status.success())
275        .unwrap_or(false);
276    user || std::process::Command::new("reg")
277        .args(["query", "HKCR\\redui", "/ve"])
278        .output()
279        .map(|out| out.status.success())
280        .unwrap_or(false)
281}
282
283#[cfg(not(any(target_os = "linux", target_os = "windows")))]
284fn os_handler_registered() -> bool {
285    // macOS (and others) have no cheap CLI probe for a Launch Services URL
286    // handler; default to "not registered" so first contact still works via
287    // the browser fallback. Force the desktop path with `--desktop` or the
288    // `RED_UI_DEEPLINK_REGISTERED` override.
289    false
290}
291
292/// Open `url` with the platform default handler (`xdg-open` / `open` /
293/// `start`). Best-effort: a spawn failure is returned so the caller can react.
294fn open_url_with_os_handler(url: &str) -> Result<(), String> {
295    let (cmd, args): (&str, Vec<&str>) = if cfg!(target_os = "macos") {
296        ("open", vec![url])
297    } else if cfg!(target_os = "windows") {
298        ("cmd", vec!["/C", "start", "", url])
299    } else {
300        ("xdg-open", vec![url])
301    };
302    std::process::Command::new(cmd)
303        .args(args)
304        .stdout(std::process::Stdio::null())
305        .stderr(std::process::Stdio::null())
306        .spawn()
307        .map(|_| ())
308        .map_err(|err| err.to_string())
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use std::cell::RefCell;
315
316    /// Test seam: a scripted [`DeepLinkEnv`] that records every `open_url`
317    /// call so both branches can be asserted without the OS.
318    struct FakeEnv {
319        registered: bool,
320        opened: RefCell<Vec<String>>,
321    }
322
323    impl FakeEnv {
324        fn new(registered: bool) -> Self {
325            Self {
326                registered,
327                opened: RefCell::new(Vec::new()),
328            }
329        }
330    }
331
332    impl DeepLinkEnv for FakeEnv {
333        fn handler_registered(&self) -> bool {
334            self.registered
335        }
336        fn open_url(&self, url: &str) -> Result<(), String> {
337            self.opened.borrow_mut().push(url.to_string());
338            Ok(())
339        }
340    }
341
342    #[test]
343    fn mode_from_flags_resolves_precedence() {
344        assert_eq!(DispatchMode::from_flags(false, false), DispatchMode::Auto);
345        assert_eq!(DispatchMode::from_flags(true, false), DispatchMode::Server);
346        assert_eq!(DispatchMode::from_flags(false, true), DispatchMode::Desktop);
347        // --server wins over --desktop: the always-works path.
348        assert_eq!(DispatchMode::from_flags(true, true), DispatchMode::Server);
349    }
350
351    #[test]
352    fn build_deep_link_keeps_file_uri_shape() {
353        assert_eq!(
354            build_deep_link("file:///home/user/data.rdb"),
355            "redui://?connect=file:///home/user/data.rdb"
356        );
357    }
358
359    #[test]
360    fn build_deep_link_encodes_query_breaking_chars() {
361        // Spaces, `&`, `?`, `#` would corrupt the query — must be encoded.
362        assert_eq!(
363            build_deep_link("file:///tmp/my db?x&y#z.rdb"),
364            "redui://?connect=file:///tmp/my%20db%3Fx%26y%23z.rdb"
365        );
366    }
367
368    #[test]
369    fn build_deep_link_passes_remote_scheme() {
370        assert_eq!(
371            build_deep_link("reds://db.internal:5050"),
372            "redui://?connect=reds://db.internal:5050"
373        );
374    }
375
376    #[test]
377    fn canonicalize_resolves_relative_file_uri_against_cwd() {
378        let cwd = Path::new("/work/project");
379        assert_eq!(
380            canonicalize_target_uri("file://./data.rdb", cwd).unwrap(),
381            "file:///work/project/data.rdb"
382        );
383        // `..` folds lexically; no filesystem touch.
384        assert_eq!(
385            canonicalize_target_uri("file://../sib/data.rdb", cwd).unwrap(),
386            "file:///work/sib/data.rdb"
387        );
388    }
389
390    #[test]
391    fn canonicalize_keeps_absolute_file_uri() {
392        let cwd = Path::new("/elsewhere");
393        assert_eq!(
394            canonicalize_target_uri("file:///abs/data.rdb", cwd).unwrap(),
395            "file:///abs/data.rdb"
396        );
397    }
398
399    #[test]
400    fn canonicalize_passes_remote_targets_through() {
401        let cwd = Path::new("/work");
402        assert_eq!(
403            canonicalize_target_uri("red://db.internal:6000", cwd).unwrap(),
404            "red://db.internal:6000"
405        );
406        assert_eq!(
407            canonicalize_target_uri("red+wss://edge.example/redwire", cwd).unwrap(),
408            "red+wss://edge.example/redwire"
409        );
410    }
411
412    // ----------------------------------------------------------------
413    // The two dispatch branches over the seam (acceptance criteria).
414    // ----------------------------------------------------------------
415
416    #[test]
417    fn auto_with_handler_hands_off_with_canonical_deep_link() {
418        let env = FakeEnv::new(true);
419        let canonical = canonicalize_target_uri("file://./data.rdb", Path::new("/work")).unwrap();
420        let outcome = dispatch(DispatchMode::Auto, &canonical, &env).unwrap();
421        assert_eq!(
422            outcome,
423            DispatchOutcome::HandedOff {
424                deep_link: "redui://?connect=file:///work/data.rdb".to_string(),
425            }
426        );
427        // The seam's open_url was driven with exactly that deep link.
428        assert_eq!(
429            *env.opened.borrow(),
430            vec!["redui://?connect=file:///work/data.rdb".to_string()]
431        );
432    }
433
434    #[test]
435    fn auto_without_handler_falls_back_with_upsell_and_opens_nothing() {
436        let env = FakeEnv::new(false);
437        let outcome = dispatch(DispatchMode::Auto, "file:///work/data.rdb", &env).unwrap();
438        assert_eq!(outcome, DispatchOutcome::ServeBrowser { upsell: true });
439        assert!(env.opened.borrow().is_empty());
440    }
441
442    #[test]
443    fn server_mode_forces_browser_without_probing_or_opening() {
444        let env = FakeEnv::new(true); // handler present, but --server overrides
445        let outcome = dispatch(DispatchMode::Server, "file:///work/data.rdb", &env).unwrap();
446        assert_eq!(outcome, DispatchOutcome::ServeBrowser { upsell: false });
447        assert!(env.opened.borrow().is_empty());
448    }
449
450    #[test]
451    fn desktop_mode_with_handler_hands_off() {
452        let env = FakeEnv::new(true);
453        let outcome = dispatch(DispatchMode::Desktop, "file:///work/data.rdb", &env).unwrap();
454        assert_eq!(
455            outcome,
456            DispatchOutcome::HandedOff {
457                deep_link: "redui://?connect=file:///work/data.rdb".to_string(),
458            }
459        );
460        assert_eq!(env.opened.borrow().len(), 1);
461    }
462
463    #[test]
464    fn desktop_mode_without_handler_reports_not_installed() {
465        let env = FakeEnv::new(false);
466        let outcome = dispatch(DispatchMode::Desktop, "file:///work/data.rdb", &env).unwrap();
467        assert_eq!(outcome, DispatchOutcome::DesktopNotInstalled);
468        assert!(env.opened.borrow().is_empty());
469    }
470
471    #[test]
472    fn handoff_deep_link_carries_the_nonce_url_not_the_secret() {
473        // The handoff URL holds the single-use nonce; the secret token is
474        // never an argument to the builder, so it cannot appear in the link.
475        let handoff = "http://127.0.0.1:54321/handoff/0123456789abcdef0123456789abcdef";
476        let link = build_deep_link_with_handoff("red://db.internal:5050", handoff);
477        assert_eq!(
478            link,
479            "redui://?connect=red://db.internal:5050\
480             &handoff=http://127.0.0.1:54321/handoff/0123456789abcdef0123456789abcdef"
481        );
482        // No credential material rides the link.
483        assert!(!link.contains("token"));
484        assert!(!link.contains("Bearer"));
485        assert!(link.contains("/handoff/"));
486    }
487
488    #[test]
489    fn deep_link_never_carries_a_credential() {
490        // Even if a token-looking string is appended to the path, the deep
491        // link only ever contains the connect target — dispatch never adds
492        // auth, and the builder has no token parameter at all.
493        let env = FakeEnv::new(true);
494        let outcome = dispatch(DispatchMode::Auto, "red://db.internal:5050", &env).unwrap();
495        if let DispatchOutcome::HandedOff { deep_link } = outcome {
496            assert!(!deep_link.contains("token"));
497            assert!(!deep_link.contains("password"));
498            assert!(!deep_link.contains("secret"));
499            assert!(!deep_link.contains("auth"));
500            assert_eq!(deep_link, "redui://?connect=red://db.internal:5050");
501        } else {
502            panic!("expected handoff");
503        }
504    }
505}