1use anyhow::{bail, Context, Result};
2
3#[derive(Debug, Clone, PartialEq)]
4pub enum SchemeStatus {
5 Installed { path: String },
6 NotInstalled,
7}
8
9impl std::fmt::Display for SchemeStatus {
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<SchemeStatus> {
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
47 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 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 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 let plist = app.join("Contents").join("Info.plist");
81 let pb = "/usr/libexec/PlistBuddy";
82
83 plist_buddy(pb, "Add :CFBundleIdentifier string io.worktree.runner", &plist)?;
85 plist_buddy(pb, "Set :CFBundleName WorktreeRunner", &plist)?;
87
88 let _ = Command::new(pb).args(["-c", "Add :LSUIElement bool true"]).arg(&plist).status();
90 plist_buddy(pb, "Set :LSUIElement true", &plist)?;
91
92 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 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#[cfg(target_os = "macos")]
121fn applescript_quoted(s: &str) -> String {
122 let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
123 format!("\"{escaped}\"")
124}
125
126#[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 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<SchemeStatus> {
165 let app = app_dir();
166 if app.join("Contents").join("Info.plist").exists() {
167 Ok(SchemeStatus::Installed {
168 path: app.display().to_string(),
169 })
170 } else {
171 Ok(SchemeStatus::NotInstalled)
172 }
173}
174
175#[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<SchemeStatus> {
233 let path = desktop_file();
234 if path.exists() {
235 Ok(SchemeStatus::Installed {
236 path: path.display().to_string(),
237 })
238 } else {
239 Ok(SchemeStatus::NotInstalled)
240 }
241}
242
243#[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<SchemeStatus> {
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(SchemeStatus::Installed {
320 path: r"HKCU\Software\Classes\worktree".to_string(),
321 })
322 } else {
323 Ok(SchemeStatus::NotInstalled)
324 }
325}
326
327#[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<SchemeStatus> {
341 Ok(SchemeStatus::NotInstalled)
342}