use std::path::{Component, Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DispatchMode {
Auto,
Server,
Desktop,
}
impl DispatchMode {
pub fn from_flags(server: bool, desktop: bool) -> Self {
if server {
DispatchMode::Server
} else if desktop {
DispatchMode::Desktop
} else {
DispatchMode::Auto
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DispatchOutcome {
HandedOff {
deep_link: String,
},
ServeBrowser {
upsell: bool,
},
DesktopNotInstalled,
}
pub trait DeepLinkEnv {
fn handler_registered(&self) -> bool;
fn open_url(&self, url: &str) -> Result<(), String>;
}
pub fn dispatch(
mode: DispatchMode,
canonical_uri: &str,
env: &dyn DeepLinkEnv,
) -> Result<DispatchOutcome, String> {
match mode {
DispatchMode::Server => Ok(DispatchOutcome::ServeBrowser { upsell: false }),
DispatchMode::Auto => {
if env.handler_registered() {
let deep_link = build_deep_link(canonical_uri);
env.open_url(&deep_link)?;
Ok(DispatchOutcome::HandedOff { deep_link })
} else {
Ok(DispatchOutcome::ServeBrowser { upsell: true })
}
}
DispatchMode::Desktop => {
if env.handler_registered() {
let deep_link = build_deep_link(canonical_uri);
env.open_url(&deep_link)?;
Ok(DispatchOutcome::HandedOff { deep_link })
} else {
Ok(DispatchOutcome::DesktopNotInstalled)
}
}
}
}
pub fn build_deep_link(canonical_uri: &str) -> String {
format!("redui://?connect={}", percent_encode_connect(canonical_uri))
}
pub fn build_deep_link_with_handoff(canonical_uri: &str, handoff_url: &str) -> String {
format!(
"redui://?connect={}&handoff={}",
percent_encode_connect(canonical_uri),
percent_encode_connect(handoff_url),
)
}
fn percent_encode_connect(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for &byte in value.as_bytes() {
let keep =
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b':' | b'/');
if keep {
out.push(byte as char);
} else {
out.push('%');
out.push(hex_upper(byte >> 4));
out.push(hex_upper(byte & 0x0f));
}
}
out
}
fn hex_upper(nibble: u8) -> char {
match nibble {
0..=9 => (b'0' + nibble) as char,
_ => (b'A' + (nibble - 10)) as char,
}
}
pub fn canonicalize_target_uri(uri: &str, cwd: &Path) -> Result<String, String> {
match super::ui_bridge::classify_ui_target(uri)? {
super::ui_bridge::UiTarget::File => canonicalize_file_uri(uri, cwd),
_ => Ok(uri.to_string()),
}
}
fn canonicalize_file_uri(input: &str, cwd: &Path) -> Result<String, String> {
let path_part = input.strip_prefix("file://").unwrap_or(input);
if path_part.is_empty() {
return Err("file:// URI has no path".to_string());
}
let raw = Path::new(path_part);
let absolute = if raw.is_absolute() {
raw.to_path_buf()
} else {
cwd.join(raw)
};
let mut normalized = PathBuf::new();
for component in absolute.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
other => normalized.push(other.as_os_str()),
}
}
let rendered = normalized
.to_str()
.ok_or_else(|| "resolved path is not valid UTF-8".to_string())?;
Ok(format!("file://{rendered}"))
}
pub struct OsDeepLinkEnv;
impl DeepLinkEnv for OsDeepLinkEnv {
fn handler_registered(&self) -> bool {
if let Some(forced) = env_override("RED_UI_DEEPLINK_REGISTERED") {
return forced;
}
os_handler_registered()
}
fn open_url(&self, url: &str) -> Result<(), String> {
open_url_with_os_handler(url)
}
}
fn env_override(key: &str) -> Option<bool> {
match std::env::var(key) {
Ok(value) => {
let v = value.trim().to_ascii_lowercase();
if v.is_empty() {
None
} else {
Some(!matches!(v.as_str(), "0" | "false" | "no" | "off"))
}
}
Err(_) => None,
}
}
#[cfg(target_os = "linux")]
fn os_handler_registered() -> bool {
std::process::Command::new("xdg-mime")
.args(["query", "default", "x-scheme-handler/redui"])
.output()
.map(|out| out.status.success() && !out.stdout.is_empty())
.unwrap_or(false)
}
#[cfg(target_os = "windows")]
fn os_handler_registered() -> bool {
let user = std::process::Command::new("reg")
.args(["query", "HKCU\\Software\\Classes\\redui", "/ve"])
.output()
.map(|out| out.status.success())
.unwrap_or(false);
user || std::process::Command::new("reg")
.args(["query", "HKCR\\redui", "/ve"])
.output()
.map(|out| out.status.success())
.unwrap_or(false)
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
fn os_handler_registered() -> bool {
false
}
fn open_url_with_os_handler(url: &str) -> Result<(), String> {
let (cmd, args): (&str, Vec<&str>) = if cfg!(target_os = "macos") {
("open", vec![url])
} else if cfg!(target_os = "windows") {
("cmd", vec!["/C", "start", "", url])
} else {
("xdg-open", vec![url])
};
std::process::Command::new(cmd)
.args(args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map(|_| ())
.map_err(|err| err.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
struct FakeEnv {
registered: bool,
opened: RefCell<Vec<String>>,
}
impl FakeEnv {
fn new(registered: bool) -> Self {
Self {
registered,
opened: RefCell::new(Vec::new()),
}
}
}
impl DeepLinkEnv for FakeEnv {
fn handler_registered(&self) -> bool {
self.registered
}
fn open_url(&self, url: &str) -> Result<(), String> {
self.opened.borrow_mut().push(url.to_string());
Ok(())
}
}
#[test]
fn mode_from_flags_resolves_precedence() {
assert_eq!(DispatchMode::from_flags(false, false), DispatchMode::Auto);
assert_eq!(DispatchMode::from_flags(true, false), DispatchMode::Server);
assert_eq!(DispatchMode::from_flags(false, true), DispatchMode::Desktop);
assert_eq!(DispatchMode::from_flags(true, true), DispatchMode::Server);
}
#[test]
fn build_deep_link_keeps_file_uri_shape() {
assert_eq!(
build_deep_link("file:///home/user/data.rdb"),
"redui://?connect=file:///home/user/data.rdb"
);
}
#[test]
fn build_deep_link_encodes_query_breaking_chars() {
assert_eq!(
build_deep_link("file:///tmp/my db?x&y#z.rdb"),
"redui://?connect=file:///tmp/my%20db%3Fx%26y%23z.rdb"
);
}
#[test]
fn build_deep_link_passes_remote_scheme() {
assert_eq!(
build_deep_link("reds://db.internal:5050"),
"redui://?connect=reds://db.internal:5050"
);
}
#[test]
fn canonicalize_resolves_relative_file_uri_against_cwd() {
let cwd = Path::new("/work/project");
assert_eq!(
canonicalize_target_uri("file://./data.rdb", cwd).unwrap(),
"file:///work/project/data.rdb"
);
assert_eq!(
canonicalize_target_uri("file://../sib/data.rdb", cwd).unwrap(),
"file:///work/sib/data.rdb"
);
}
#[test]
fn canonicalize_keeps_absolute_file_uri() {
let cwd = Path::new("/elsewhere");
assert_eq!(
canonicalize_target_uri("file:///abs/data.rdb", cwd).unwrap(),
"file:///abs/data.rdb"
);
}
#[test]
fn canonicalize_passes_remote_targets_through() {
let cwd = Path::new("/work");
assert_eq!(
canonicalize_target_uri("red://db.internal:6000", cwd).unwrap(),
"red://db.internal:6000"
);
assert_eq!(
canonicalize_target_uri("red+wss://edge.example/redwire", cwd).unwrap(),
"red+wss://edge.example/redwire"
);
}
#[test]
fn auto_with_handler_hands_off_with_canonical_deep_link() {
let env = FakeEnv::new(true);
let canonical = canonicalize_target_uri("file://./data.rdb", Path::new("/work")).unwrap();
let outcome = dispatch(DispatchMode::Auto, &canonical, &env).unwrap();
assert_eq!(
outcome,
DispatchOutcome::HandedOff {
deep_link: "redui://?connect=file:///work/data.rdb".to_string(),
}
);
assert_eq!(
*env.opened.borrow(),
vec!["redui://?connect=file:///work/data.rdb".to_string()]
);
}
#[test]
fn auto_without_handler_falls_back_with_upsell_and_opens_nothing() {
let env = FakeEnv::new(false);
let outcome = dispatch(DispatchMode::Auto, "file:///work/data.rdb", &env).unwrap();
assert_eq!(outcome, DispatchOutcome::ServeBrowser { upsell: true });
assert!(env.opened.borrow().is_empty());
}
#[test]
fn server_mode_forces_browser_without_probing_or_opening() {
let env = FakeEnv::new(true); let outcome = dispatch(DispatchMode::Server, "file:///work/data.rdb", &env).unwrap();
assert_eq!(outcome, DispatchOutcome::ServeBrowser { upsell: false });
assert!(env.opened.borrow().is_empty());
}
#[test]
fn desktop_mode_with_handler_hands_off() {
let env = FakeEnv::new(true);
let outcome = dispatch(DispatchMode::Desktop, "file:///work/data.rdb", &env).unwrap();
assert_eq!(
outcome,
DispatchOutcome::HandedOff {
deep_link: "redui://?connect=file:///work/data.rdb".to_string(),
}
);
assert_eq!(env.opened.borrow().len(), 1);
}
#[test]
fn desktop_mode_without_handler_reports_not_installed() {
let env = FakeEnv::new(false);
let outcome = dispatch(DispatchMode::Desktop, "file:///work/data.rdb", &env).unwrap();
assert_eq!(outcome, DispatchOutcome::DesktopNotInstalled);
assert!(env.opened.borrow().is_empty());
}
#[test]
fn handoff_deep_link_carries_the_nonce_url_not_the_secret() {
let handoff = "http://127.0.0.1:54321/handoff/0123456789abcdef0123456789abcdef";
let link = build_deep_link_with_handoff("red://db.internal:5050", handoff);
assert_eq!(
link,
"redui://?connect=red://db.internal:5050\
&handoff=http://127.0.0.1:54321/handoff/0123456789abcdef0123456789abcdef"
);
assert!(!link.contains("token"));
assert!(!link.contains("Bearer"));
assert!(link.contains("/handoff/"));
}
#[test]
fn deep_link_never_carries_a_credential() {
let env = FakeEnv::new(true);
let outcome = dispatch(DispatchMode::Auto, "red://db.internal:5050", &env).unwrap();
if let DispatchOutcome::HandedOff { deep_link } = outcome {
assert!(!deep_link.contains("token"));
assert!(!deep_link.contains("password"));
assert!(!deep_link.contains("secret"));
assert!(!deep_link.contains("auth"));
assert_eq!(deep_link, "redui://?connect=red://db.internal:5050");
} else {
panic!("expected handoff");
}
}
}