1use std::path::{Component, Path, PathBuf};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum DispatchMode {
19 Auto,
22 Server,
24 Desktop,
26}
27
28impl DispatchMode {
29 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#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum DispatchOutcome {
45 HandedOff {
49 deep_link: String,
51 },
52 ServeBrowser {
56 upsell: bool,
59 },
60 DesktopNotInstalled,
64}
65
66pub trait DeepLinkEnv {
71 fn handler_registered(&self) -> bool;
74 fn open_url(&self, url: &str) -> Result<(), String>;
77}
78
79pub 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
120pub fn build_deep_link(canonical_uri: &str) -> String {
125 format!("redui://?connect={}", percent_encode_connect(canonical_uri))
126}
127
128pub 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
142fn 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
169pub 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
184fn 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
217pub 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
239fn 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#[cfg(target_os = "linux")]
257fn os_handler_registered() -> bool {
258 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 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 false
290}
291
292fn 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 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 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 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 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 #[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 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); 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 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 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 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}