Skip to main content

vtcode_desktop_protocol/
handoff_url.rs

1//! `vtcode://` handoff links: open the desktop app at a thread from the CLI.
2//!
3//! Contract shared by the CLI (producer) and the desktop app (consumer):
4//! `vtcode://session/<archive-id>?workspace=<url-encoded-absolute-path>`.
5//! Parsing is fail-closed: anything else yields `None` and the URL is
6//! ignored. Paths decode lossy on purpose: a mangled path fails workspace
7//! canonicalization later and surfaces invalid-folder instead of linking
8//! somewhere unexpected.
9
10use std::path::PathBuf;
11
12/// URL scheme claimed by the desktop bundle (`CFBundleURLTypes`).
13pub const HANDOFF_SCHEME: &str = "vtcode";
14
15/// A parsed handoff link: load this thread in this workspace.
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct HandoffLink {
18    /// Archive identifier of the thread (== CLI thread id).
19    pub session_id: String,
20    /// Workspace the thread belongs to (canonicalized by the consumer).
21    pub workspace: PathBuf,
22}
23
24/// Build the canonical handoff URL for a link.
25#[must_use]
26pub fn handoff_url(link: &HandoffLink) -> String {
27    let session: String = url::form_urlencoded::byte_serialize(link.session_id.as_bytes()).collect();
28    let workspace: String =
29        url::form_urlencoded::byte_serialize(link.workspace.as_os_str().as_encoded_bytes()).collect();
30    format!("{HANDOFF_SCHEME}://session/{session}?workspace={workspace}")
31}
32
33/// Parse a handoff URL. Returns `None` for any other shape.
34#[must_use]
35pub fn parse_handoff_url(raw: &str) -> Option<HandoffLink> {
36    let url = url::Url::parse(raw).ok()?;
37    if url.scheme() != HANDOFF_SCHEME || url.host_str() != Some("session") {
38        return None;
39    }
40    let mut segments = url.path_segments()?;
41    let session_id = segments.next().filter(|id| !id.trim().is_empty())?;
42    if segments.next().is_some() {
43        return None;
44    }
45    let workspace = url
46        .query_pairs()
47        .find_map(|(key, value)| (key == "workspace").then_some(value))?;
48    if workspace.trim().is_empty() {
49        return None;
50    }
51    Some(HandoffLink {
52        session_id: session_id.trim().to_owned(),
53        workspace: PathBuf::from(workspace.into_owned()),
54    })
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60
61    fn link(session_id: &str, workspace: &str) -> HandoffLink {
62        HandoffLink {
63            session_id: session_id.to_string(),
64            workspace: PathBuf::from(workspace),
65        }
66    }
67
68    #[test]
69    fn canonical_form_is_stable() {
70        assert_eq!(
71            handoff_url(&link("thread-1", "/repo/workspace")),
72            "vtcode://session/thread-1?workspace=%2Frepo%2Fworkspace"
73        );
74    }
75
76    #[test]
77    fn build_parse_round_trips_hostile_paths() {
78        for original in [
79            link("thread-1", "/repo/workspace"),
80            link("session-label-9", "/sp ace/üñî +?&=#%"),
81            link("a", "/"),
82        ] {
83            let parsed = parse_handoff_url(&handoff_url(&original)).expect("round trip");
84            assert_eq!(parsed, original);
85        }
86    }
87
88    #[test]
89    fn rejects_non_handoff_urls() {
90        for raw in [
91            "",
92            "not a url",
93            "https://session/thread-1?workspace=/repo",
94            "vtcode://other/thread-1?workspace=/repo",
95            "vtcode:session/thread-1?workspace=/repo",
96            "vtcode://session/?workspace=/repo",
97            "vtcode://session/a/b?workspace=/repo",
98            "vtcode://session/thread-1",
99            "vtcode://session/thread-1?workspace=",
100            "vtcode://session/thread-1?workspace=%20%20",
101            "vtcode://session/thread-1?other=/repo",
102        ] {
103            assert_eq!(parse_handoff_url(raw), None, "{raw}");
104        }
105    }
106}