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#[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 let contents = app.join("Contents");
47 let macos_dir = contents.join("MacOS");
48
49 std::fs::create_dir_all(&macos_dir)
50 .with_context(|| format!("Failed to create {}", macos_dir.display()))?;
51
52 let handler = macos_dir.join("runner-handler");
54 let script = format!(
55 "#!/bin/sh\nexec {exe} open \"$1\"\n",
56 exe = exe.display()
57 );
58 std::fs::write(&handler, &script)
59 .with_context(|| format!("Failed to write handler script to {}", handler.display()))?;
60
61 Command::new("chmod")
63 .args(["+x"])
64 .arg(&handler)
65 .status()
66 .context("Failed to chmod handler script")?;
67
68 let plist_path = contents.join("Info.plist");
70 let plist = format!(
71 r#"<?xml version="1.0" encoding="UTF-8"?>
72<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
73 "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
74<plist version="1.0">
75<dict>
76 <key>CFBundleIdentifier</key>
77 <string>io.worktree.runner</string>
78 <key>CFBundleName</key>
79 <string>WorktreeRunner</string>
80 <key>CFBundleExecutable</key>
81 <string>runner-handler</string>
82 <key>CFBundlePackageType</key>
83 <string>APPL</string>
84 <key>LSUIElement</key>
85 <true/>
86 <key>CFBundleURLTypes</key>
87 <array>
88 <dict>
89 <key>CFBundleURLName</key>
90 <string>Worktree URL</string>
91 <key>CFBundleURLSchemes</key>
92 <array>
93 <string>worktree</string>
94 </array>
95 </dict>
96 </array>
97</dict>
98</plist>
99"#
100 );
101 std::fs::write(&plist_path, plist)
102 .with_context(|| format!("Failed to write Info.plist to {}", plist_path.display()))?;
103
104 let lsregister = "/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/\
106 LaunchServices.framework/Versions/A/Support/lsregister";
107 let status = Command::new(lsregister)
108 .arg("-f")
109 .arg(&app)
110 .status()
111 .context("Failed to run lsregister")?;
112
113 if !status.success() {
114 bail!("lsregister failed");
115 }
116
117 println!("Installed WorktreeRunner.app at {}", app.display());
118 println!("The worktree:// URL scheme is now registered.");
119 Ok(())
120}
121
122#[cfg(target_os = "macos")]
123fn platform_uninstall() -> Result<()> {
124 use std::process::Command;
125
126 let app = app_dir();
127 if app.exists() {
128 let lsregister = "/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/\
130 LaunchServices.framework/Versions/A/Support/lsregister";
131 let _ = Command::new(lsregister)
132 .args(["-u"])
133 .arg(&app)
134 .status();
135
136 std::fs::remove_dir_all(&app)
137 .with_context(|| format!("Failed to remove {}", app.display()))?;
138 println!("Removed {}", app.display());
139 } else {
140 println!("Not installed — nothing to remove.");
141 }
142 Ok(())
143}
144
145#[cfg(target_os = "macos")]
146fn platform_status() -> Result<DaemonStatus> {
147 let app = app_dir();
148 if app.join("Contents").join("Info.plist").exists() {
149 Ok(DaemonStatus::Installed {
150 path: app.display().to_string(),
151 })
152 } else {
153 Ok(DaemonStatus::NotInstalled)
154 }
155}
156
157#[cfg(target_os = "linux")]
160fn desktop_file() -> std::path::PathBuf {
161 dirs::data_local_dir()
162 .unwrap_or_else(|| std::path::PathBuf::from("~/.local/share"))
163 .join("applications")
164 .join("worktree-runner.desktop")
165}
166
167#[cfg(target_os = "linux")]
168fn platform_install() -> Result<()> {
169 use std::process::Command;
170
171 let exe = std::env::current_exe().context("Failed to get current executable path")?;
172 let path = desktop_file();
173
174 if let Some(parent) = path.parent() {
175 std::fs::create_dir_all(parent)
176 .with_context(|| format!("Failed to create {}", parent.display()))?;
177 }
178
179 let content = format!(
180 "[Desktop Entry]\n\
181 Name=Worktree Runner\n\
182 Exec={exe} open %u\n\
183 Type=Application\n\
184 NoDisplay=true\n\
185 MimeType=x-scheme-handler/worktree;\n",
186 exe = exe.display()
187 );
188 std::fs::write(&path, content)
189 .with_context(|| format!("Failed to write desktop file to {}", path.display()))?;
190
191 Command::new("xdg-mime")
192 .args(["default", "worktree-runner.desktop", "x-scheme-handler/worktree"])
193 .status()
194 .context("Failed to run xdg-mime")?;
195
196 println!("Installed desktop entry at {}", path.display());
197 Ok(())
198}
199
200#[cfg(target_os = "linux")]
201fn platform_uninstall() -> Result<()> {
202 let path = desktop_file();
203 if path.exists() {
204 std::fs::remove_file(&path)
205 .with_context(|| format!("Failed to remove {}", path.display()))?;
206 println!("Removed {}", path.display());
207 } else {
208 println!("Not installed — nothing to remove.");
209 }
210 Ok(())
211}
212
213#[cfg(target_os = "linux")]
214fn platform_status() -> Result<DaemonStatus> {
215 let path = desktop_file();
216 if path.exists() {
217 Ok(DaemonStatus::Installed {
218 path: path.display().to_string(),
219 })
220 } else {
221 Ok(DaemonStatus::NotInstalled)
222 }
223}
224
225#[cfg(target_os = "windows")]
228fn platform_install() -> Result<()> {
229 use std::process::Command;
230
231 let exe = std::env::current_exe()
232 .context("Failed to get current executable path")?
233 .display()
234 .to_string();
235
236 let run = |args: &[&str]| -> Result<()> {
237 let status = Command::new("reg")
238 .args(args)
239 .status()
240 .context("Failed to run `reg`")?;
241 if !status.success() {
242 bail!("reg command failed");
243 }
244 Ok(())
245 };
246
247 run(&[
248 "add",
249 r"HKCU\Software\Classes\worktree",
250 "/d",
251 "URL:Worktree Protocol",
252 "/f",
253 ])?;
254 run(&[
255 "add",
256 r"HKCU\Software\Classes\worktree",
257 "/v",
258 "URL Protocol",
259 "/d",
260 "",
261 "/f",
262 ])?;
263 run(&[
264 "add",
265 r"HKCU\Software\Classes\worktree\shell\open\command",
266 "/d",
267 &format!(r#""{exe}" open "%1""#),
268 "/f",
269 ])?;
270
271 println!("Registered worktree:// URL scheme in Windows registry.");
272 Ok(())
273}
274
275#[cfg(target_os = "windows")]
276fn platform_uninstall() -> Result<()> {
277 use std::process::Command;
278
279 let status = Command::new("reg")
280 .args(["delete", r"HKCU\Software\Classes\worktree", "/f"])
281 .status()
282 .context("Failed to run `reg delete`")?;
283
284 if !status.success() {
285 bail!("reg delete failed");
286 }
287 println!("Unregistered worktree:// URL scheme.");
288 Ok(())
289}
290
291#[cfg(target_os = "windows")]
292fn platform_status() -> Result<DaemonStatus> {
293 use std::process::Command;
294
295 let output = Command::new("reg")
296 .args(["query", r"HKCU\Software\Classes\worktree"])
297 .output()
298 .context("Failed to query registry")?;
299
300 if output.status.success() {
301 Ok(DaemonStatus::Installed {
302 path: r"HKCU\Software\Classes\worktree".to_string(),
303 })
304 } else {
305 Ok(DaemonStatus::NotInstalled)
306 }
307}
308
309#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
312fn platform_install() -> Result<()> {
313 bail!("URL scheme registration is not supported on this platform")
314}
315
316#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
317fn platform_uninstall() -> Result<()> {
318 bail!("URL scheme registration is not supported on this platform")
319}
320
321#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
322fn platform_status() -> Result<DaemonStatus> {
323 Ok(DaemonStatus::NotInstalled)
324}