Skip to main content

worktree_io/
daemon.rs

1use anyhow::{bail, Context, Result};
2
3#[derive(Debug, Clone, PartialEq)]
4pub enum DaemonStatus {
5    Installed { path: String },
6    NotInstalled,
7}
8
9impl std::fmt::Display for DaemonStatus {
10    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
11        match self {
12            Self::Installed { path } => write!(f, "Installed at {path}"),
13            Self::NotInstalled => write!(f, "Not installed"),
14        }
15    }
16}
17
18pub fn install() -> Result<()> {
19    platform_install()
20}
21
22pub fn uninstall() -> Result<()> {
23    platform_uninstall()
24}
25
26pub fn status() -> Result<DaemonStatus> {
27    platform_status()
28}
29
30// ──────────────────────────── macOS ────────────────────────────
31
32#[cfg(target_os = "macos")]
33fn app_dir() -> std::path::PathBuf {
34    dirs::home_dir()
35        .unwrap_or_else(|| std::path::PathBuf::from("~"))
36        .join("Applications")
37        .join("WorktreeRunner.app")
38}
39
40#[cfg(target_os = "macos")]
41fn platform_install() -> Result<()> {
42    use std::process::Command;
43
44    let exe = std::env::current_exe().context("Failed to get current executable path")?;
45    let app = app_dir();
46
47    // Remove any previous install so osacompile starts clean
48    if app.exists() {
49        std::fs::remove_dir_all(&app)
50            .with_context(|| format!("Failed to remove existing app at {}", app.display()))?;
51    }
52
53    // macOS delivers URL scheme events as Apple Events (kAEGetURL / open location),
54    // NOT as argv[1] to the executable.  A plain shell script never sees the URL.
55    // Compiling an AppleScript applet that handles `on open location` is the
56    // correct, documented way to receive the URL from the OS.
57    let script_src = std::env::temp_dir().join("worktree-runner.applescript");
58    let applescript = format!(
59        "on open location this_URL\n\
60         \tdo shell script {exe_q} & \" open \" & quoted form of this_URL\n\
61         end open location\n",
62        exe_q = applescript_quoted(&exe.display().to_string()),
63    );
64    std::fs::write(&script_src, &applescript)
65        .context("Failed to write AppleScript source")?;
66
67    // Compile the script into a .app bundle
68    let status = Command::new("osacompile")
69        .args(["-o"])
70        .arg(&app)
71        .arg(&script_src)
72        .status()
73        .context("Failed to run osacompile")?;
74    let _ = std::fs::remove_file(&script_src);
75    if !status.success() {
76        bail!("osacompile failed");
77    }
78
79    // Patch the generated Info.plist: bundle identity + LSUIElement + URL scheme
80    let plist = app.join("Contents").join("Info.plist");
81    let pb = "/usr/libexec/PlistBuddy";
82
83    // CFBundleIdentifier is absent from the osacompile-generated plist — Add it
84    plist_buddy(pb, "Add :CFBundleIdentifier string io.worktree.runner", &plist)?;
85    // CFBundleName is present but defaults to the script filename — override it
86    plist_buddy(pb, "Set :CFBundleName WorktreeRunner", &plist)?;
87
88    // LSUIElement keeps the applet out of the Dock; add if absent then set it
89    let _ = Command::new(pb).args(["-c", "Add :LSUIElement bool true"]).arg(&plist).status();
90    plist_buddy(pb, "Set :LSUIElement true", &plist)?;
91
92    // URL scheme registration
93    let _ = Command::new(pb).args(["-c", "Add :CFBundleURLTypes array"]).arg(&plist).status();
94    plist_buddy(pb, "Add :CFBundleURLTypes:0 dict", &plist)?;
95    plist_buddy(pb, "Add :CFBundleURLTypes:0:CFBundleURLName string Worktree URL", &plist)?;
96    plist_buddy(pb, "Add :CFBundleURLTypes:0:CFBundleURLSchemes array", &plist)?;
97    plist_buddy(pb, "Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string worktree", &plist)?;
98
99    // Register with Launch Services
100    let lsregister = "/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/\
101        LaunchServices.framework/Versions/A/Support/lsregister";
102    let status = Command::new(lsregister)
103        .arg("-f")
104        .arg(&app)
105        .status()
106        .context("Failed to run lsregister")?;
107
108    if !status.success() {
109        bail!("lsregister failed");
110    }
111
112    println!("Installed WorktreeRunner.app at {}", app.display());
113    println!("The worktree:// URL scheme is now registered.");
114    Ok(())
115}
116
117/// Wrap a string in AppleScript's `quoted form` equivalent for embedding in source.
118/// Escapes backslashes and double-quotes so the path is safe inside a double-quoted
119/// AppleScript string literal.
120#[cfg(target_os = "macos")]
121fn applescript_quoted(s: &str) -> String {
122    let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
123    format!("\"{escaped}\"")
124}
125
126/// Run a single PlistBuddy command, returning an error if it fails.
127#[cfg(target_os = "macos")]
128fn plist_buddy(pb: &str, cmd: &str, plist: &std::path::Path) -> Result<()> {
129    let status = std::process::Command::new(pb)
130        .args(["-c", cmd])
131        .arg(plist)
132        .status()
133        .with_context(|| format!("Failed to run PlistBuddy: {cmd}"))?;
134    if !status.success() {
135        bail!("PlistBuddy failed: {cmd}");
136    }
137    Ok(())
138}
139
140#[cfg(target_os = "macos")]
141fn platform_uninstall() -> Result<()> {
142    use std::process::Command;
143
144    let app = app_dir();
145    if app.exists() {
146        // Unregister before removing
147        let lsregister = "/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/\
148            LaunchServices.framework/Versions/A/Support/lsregister";
149        let _ = Command::new(lsregister)
150            .args(["-u"])
151            .arg(&app)
152            .status();
153
154        std::fs::remove_dir_all(&app)
155            .with_context(|| format!("Failed to remove {}", app.display()))?;
156        println!("Removed {}", app.display());
157    } else {
158        println!("Not installed — nothing to remove.");
159    }
160    Ok(())
161}
162
163#[cfg(target_os = "macos")]
164fn platform_status() -> Result<DaemonStatus> {
165    let app = app_dir();
166    if app.join("Contents").join("Info.plist").exists() {
167        Ok(DaemonStatus::Installed {
168            path: app.display().to_string(),
169        })
170    } else {
171        Ok(DaemonStatus::NotInstalled)
172    }
173}
174
175// ──────────────────────────── Linux ────────────────────────────
176
177#[cfg(target_os = "linux")]
178fn desktop_file() -> std::path::PathBuf {
179    dirs::data_local_dir()
180        .unwrap_or_else(|| std::path::PathBuf::from("~/.local/share"))
181        .join("applications")
182        .join("worktree-runner.desktop")
183}
184
185#[cfg(target_os = "linux")]
186fn platform_install() -> Result<()> {
187    use std::process::Command;
188
189    let exe = std::env::current_exe().context("Failed to get current executable path")?;
190    let path = desktop_file();
191
192    if let Some(parent) = path.parent() {
193        std::fs::create_dir_all(parent)
194            .with_context(|| format!("Failed to create {}", parent.display()))?;
195    }
196
197    let content = format!(
198        "[Desktop Entry]\n\
199         Name=Worktree Runner\n\
200         Exec={exe} open %u\n\
201         Type=Application\n\
202         NoDisplay=true\n\
203         MimeType=x-scheme-handler/worktree;\n",
204        exe = exe.display()
205    );
206    std::fs::write(&path, content)
207        .with_context(|| format!("Failed to write desktop file to {}", path.display()))?;
208
209    Command::new("xdg-mime")
210        .args(["default", "worktree-runner.desktop", "x-scheme-handler/worktree"])
211        .status()
212        .context("Failed to run xdg-mime")?;
213
214    println!("Installed desktop entry at {}", path.display());
215    Ok(())
216}
217
218#[cfg(target_os = "linux")]
219fn platform_uninstall() -> Result<()> {
220    let path = desktop_file();
221    if path.exists() {
222        std::fs::remove_file(&path)
223            .with_context(|| format!("Failed to remove {}", path.display()))?;
224        println!("Removed {}", path.display());
225    } else {
226        println!("Not installed — nothing to remove.");
227    }
228    Ok(())
229}
230
231#[cfg(target_os = "linux")]
232fn platform_status() -> Result<DaemonStatus> {
233    let path = desktop_file();
234    if path.exists() {
235        Ok(DaemonStatus::Installed {
236            path: path.display().to_string(),
237        })
238    } else {
239        Ok(DaemonStatus::NotInstalled)
240    }
241}
242
243// ──────────────────────────── Windows ────────────────────────────
244
245#[cfg(target_os = "windows")]
246fn platform_install() -> Result<()> {
247    use std::process::Command;
248
249    let exe = std::env::current_exe()
250        .context("Failed to get current executable path")?
251        .display()
252        .to_string();
253
254    let run = |args: &[&str]| -> Result<()> {
255        let status = Command::new("reg")
256            .args(args)
257            .status()
258            .context("Failed to run `reg`")?;
259        if !status.success() {
260            bail!("reg command failed");
261        }
262        Ok(())
263    };
264
265    run(&[
266        "add",
267        r"HKCU\Software\Classes\worktree",
268        "/d",
269        "URL:Worktree Protocol",
270        "/f",
271    ])?;
272    run(&[
273        "add",
274        r"HKCU\Software\Classes\worktree",
275        "/v",
276        "URL Protocol",
277        "/d",
278        "",
279        "/f",
280    ])?;
281    run(&[
282        "add",
283        r"HKCU\Software\Classes\worktree\shell\open\command",
284        "/d",
285        &format!(r#""{exe}" open "%1""#),
286        "/f",
287    ])?;
288
289    println!("Registered worktree:// URL scheme in Windows registry.");
290    Ok(())
291}
292
293#[cfg(target_os = "windows")]
294fn platform_uninstall() -> Result<()> {
295    use std::process::Command;
296
297    let status = Command::new("reg")
298        .args(["delete", r"HKCU\Software\Classes\worktree", "/f"])
299        .status()
300        .context("Failed to run `reg delete`")?;
301
302    if !status.success() {
303        bail!("reg delete failed");
304    }
305    println!("Unregistered worktree:// URL scheme.");
306    Ok(())
307}
308
309#[cfg(target_os = "windows")]
310fn platform_status() -> Result<DaemonStatus> {
311    use std::process::Command;
312
313    let output = Command::new("reg")
314        .args(["query", r"HKCU\Software\Classes\worktree"])
315        .output()
316        .context("Failed to query registry")?;
317
318    if output.status.success() {
319        Ok(DaemonStatus::Installed {
320            path: r"HKCU\Software\Classes\worktree".to_string(),
321        })
322    } else {
323        Ok(DaemonStatus::NotInstalled)
324    }
325}
326
327// ──────────────────────────── Fallback ────────────────────────────
328
329#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
330fn platform_install() -> Result<()> {
331    bail!("URL scheme registration is not supported on this platform")
332}
333
334#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
335fn platform_uninstall() -> Result<()> {
336    bail!("URL scheme registration is not supported on this platform")
337}
338
339#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
340fn platform_status() -> Result<DaemonStatus> {
341    Ok(DaemonStatus::NotInstalled)
342}