Skip to main content

ralph/commands/
app.rs

1//! macOS app integration command implementations.
2//!
3//! Responsibilities:
4//! - Implement `ralph app open` by launching the installed SwiftUI app via the macOS `open`
5//!   command.
6//! - Pass workspace context via custom URL scheme `ralph://open?workspace=<path>`
7//! - Keep the invocation logic testable by separating "plan" from execution.
8//!
9//! Not handled here:
10//! - Building or installing the SwiftUI app (see `apps/RalphMac/`).
11//! - Any in-app IPC; the app drives Ralph by executing the CLI as a subprocess.
12//!
13//! Invariants/assumptions:
14//! - The default bundle identifier is `com.mitchfultz.ralph`.
15//! - Non-macOS platforms reject `ralph app open` with a clear error and non-zero exit.
16//! - URL scheme `ralph://` must be registered in the app's Info.plist.
17
18use anyhow::{Context, Result, bail};
19use std::ffi::OsString;
20use std::path::Path;
21use std::process::Command;
22
23#[cfg(unix)]
24use std::os::unix::ffi::OsStrExt;
25
26use crate::cli::app::AppOpenArgs;
27
28const DEFAULT_BUNDLE_ID: &str = "com.mitchfultz.ralph";
29const GUI_CLI_BIN_ENV: &str = "RALPH_BIN_PATH";
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32struct OpenCommandSpec {
33    program: OsString,
34    args: Vec<OsString>,
35}
36
37impl OpenCommandSpec {
38    fn to_command(&self) -> Command {
39        let mut cmd = Command::new(&self.program);
40        cmd.args(&self.args);
41        cmd
42    }
43}
44
45fn plan_open_command(
46    is_macos: bool,
47    args: &AppOpenArgs,
48    cli_executable: Option<&Path>,
49) -> Result<OpenCommandSpec> {
50    if !is_macos {
51        bail!("`ralph app open` is macOS-only.");
52    }
53
54    if args.path.is_some() && args.bundle_id.is_some() {
55        bail!("--path and --bundle-id cannot be used together.");
56    }
57
58    let mut args_out: Vec<OsString> = Vec::new();
59    if let Some(cli_executable) = cli_executable {
60        args_out.push(OsString::from("--env"));
61        args_out.push(env_assignment_for_path(cli_executable));
62    }
63
64    if let Some(path) = args.path.as_deref() {
65        ensure_exists(path)?;
66        args_out.push(OsString::from(path));
67        return Ok(OpenCommandSpec {
68            program: OsString::from("open"),
69            args: args_out,
70        });
71    }
72
73    let bundle_id = args
74        .bundle_id
75        .as_deref()
76        .unwrap_or(DEFAULT_BUNDLE_ID)
77        .trim();
78    if bundle_id.is_empty() {
79        bail!("Bundle id is empty.");
80    }
81
82    args_out.push(OsString::from("-b"));
83    args_out.push(OsString::from(bundle_id));
84
85    Ok(OpenCommandSpec {
86        program: OsString::from("open"),
87        args: args_out,
88    })
89}
90
91fn ensure_exists(path: &Path) -> Result<()> {
92    if path.exists() {
93        return Ok(());
94    }
95
96    bail!("Path does not exist: {}", path.display());
97}
98
99/// Plan the URL command to send workspace context.
100fn plan_url_command(workspace: &Path) -> Result<OpenCommandSpec> {
101    let encoded_path = percent_encode_path(workspace);
102    let url = format!("ralph://open?workspace={}", encoded_path);
103
104    Ok(OpenCommandSpec {
105        program: OsString::from("open"),
106        args: vec![OsString::from(url)],
107    })
108}
109
110fn current_executable_for_gui() -> Option<std::path::PathBuf> {
111    let exe = std::env::current_exe().ok()?;
112    if exe.exists() { Some(exe) } else { None }
113}
114
115#[cfg(unix)]
116fn env_assignment_for_path(path: &Path) -> OsString {
117    use std::os::unix::ffi::{OsStrExt, OsStringExt};
118
119    let mut bytes = Vec::from(format!("{GUI_CLI_BIN_ENV}=").as_bytes());
120    bytes.extend_from_slice(path.as_os_str().as_bytes());
121    OsString::from_vec(bytes)
122}
123
124#[cfg(not(unix))]
125fn env_assignment_for_path(path: &Path) -> OsString {
126    OsString::from(format!("{GUI_CLI_BIN_ENV}={}", path.to_string_lossy()))
127}
128
129/// Percent-encode a path for use in URL query parameters.
130#[cfg(unix)]
131fn percent_encode_path(path: &Path) -> String {
132    percent_encode(path.as_os_str().as_bytes())
133}
134
135/// Percent-encode a path for use in URL query parameters (non-Unix fallback).
136#[cfg(not(unix))]
137fn percent_encode_path(path: &Path) -> String {
138    // On non-Unix platforms, convert to UTF-8 string and encode
139    percent_encode(path.to_string_lossy().as_bytes())
140}
141
142/// Percent-encode a byte sequence for use in URL query parameters.
143fn percent_encode(input: &[u8]) -> String {
144    let mut result = String::with_capacity(input.len() * 3);
145    for &byte in input {
146        // Unreserved characters per RFC 3986
147        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~' | b'/') {
148            result.push(byte as char);
149        } else {
150            result.push('%');
151            result.push_str(&format!("{:02X}", byte));
152        }
153    }
154    result
155}
156
157fn resolve_workspace_path(args: &AppOpenArgs) -> Result<Option<std::path::PathBuf>> {
158    if let Some(ref workspace) = args.workspace {
159        if !workspace.exists() {
160            bail!("Workspace path does not exist: {}", workspace.display());
161        }
162        return Ok(Some(workspace.clone()));
163    }
164
165    Ok(std::env::current_dir().ok().filter(|path| path.exists()))
166}
167
168/// Open the Ralph macOS app.
169///
170/// On macOS, this prefers a single URL launch (`ralph://open?...`) when workspace
171/// context is available. That lets LaunchServices both launch the app and deliver
172/// the workspace in one step, which avoids SwiftUI opening a second scene for a
173/// follow-up external-event dispatch.
174pub fn open(args: AppOpenArgs) -> Result<()> {
175    let cli_executable = current_executable_for_gui();
176
177    let spec = if let Some(workspace_path) = resolve_workspace_path(&args)? {
178        plan_url_command(&workspace_path)?
179    } else {
180        plan_open_command(cfg!(target_os = "macos"), &args, cli_executable.as_deref())?
181    };
182
183    let output = spec
184        .to_command()
185        .output()
186        .context("spawn macOS `open` command for app launch")?;
187
188    if !output.status.success() {
189        let stderr = String::from_utf8_lossy(&output.stderr);
190        bail!(
191            "Failed to launch app (exit status: {}). {}",
192            output.status,
193            stderr.trim()
194        );
195    }
196
197    Ok(())
198}
199
200#[cfg(test)]
201mod tests {
202    use super::{
203        DEFAULT_BUNDLE_ID, GUI_CLI_BIN_ENV, env_assignment_for_path, percent_encode,
204        percent_encode_path, plan_open_command, plan_url_command, resolve_workspace_path,
205    };
206    use crate::cli::app::AppOpenArgs;
207    use std::ffi::{OsStr, OsString};
208    use std::path::PathBuf;
209
210    #[test]
211    fn plan_open_command_non_macos_errors() {
212        let args = AppOpenArgs {
213            bundle_id: None,
214            path: None,
215            workspace: None,
216        };
217
218        let err = plan_open_command(false, &args, None).expect_err("expected error");
219        assert!(
220            err.to_string().to_lowercase().contains("macos-only"),
221            "unexpected error: {err:#}"
222        );
223    }
224
225    #[test]
226    fn plan_open_command_default_bundle_id_uses_open_b() -> anyhow::Result<()> {
227        let args = AppOpenArgs {
228            bundle_id: None,
229            path: None,
230            workspace: None,
231        };
232
233        let spec = plan_open_command(true, &args, None)?;
234        assert_eq!(spec.program, OsString::from("open"));
235        assert_eq!(
236            spec.args,
237            vec![
238                OsStr::new("-b").to_os_string(),
239                OsStr::new(DEFAULT_BUNDLE_ID).to_os_string()
240            ]
241        );
242        Ok(())
243    }
244
245    #[test]
246    fn plan_open_command_bundle_id_override_uses_open_b() -> anyhow::Result<()> {
247        let args = AppOpenArgs {
248            bundle_id: Some("com.example.override".to_string()),
249            path: None,
250            workspace: None,
251        };
252
253        let spec = plan_open_command(true, &args, None)?;
254        assert_eq!(spec.program, OsString::from("open"));
255        assert_eq!(
256            spec.args,
257            vec![
258                OsStr::new("-b").to_os_string(),
259                OsStr::new("com.example.override").to_os_string()
260            ]
261        );
262        Ok(())
263    }
264
265    #[test]
266    fn plan_open_command_path_uses_open_path() -> anyhow::Result<()> {
267        let temp = tempfile::tempdir()?;
268        let app_dir = temp.path().join("Ralph.app");
269        std::fs::create_dir_all(&app_dir)?;
270
271        let args = AppOpenArgs {
272            bundle_id: None,
273            path: Some(app_dir.clone()),
274            workspace: None,
275        };
276
277        let spec = plan_open_command(true, &args, None)?;
278        assert_eq!(spec.program, OsString::from("open"));
279        assert_eq!(spec.args, vec![app_dir.as_os_str().to_os_string()]);
280        Ok(())
281    }
282
283    #[test]
284    fn plan_open_command_path_missing_errors() {
285        let args = AppOpenArgs {
286            bundle_id: None,
287            path: Some(PathBuf::from("/definitely/not/a/real/path/Ralph.app")),
288            workspace: None,
289        };
290
291        let err = plan_open_command(true, &args, None).expect_err("expected error");
292        assert!(
293            err.to_string().to_lowercase().contains("does not exist"),
294            "unexpected error: {err:#}"
295        );
296    }
297
298    #[test]
299    fn plan_url_command_encodes_workspace() -> anyhow::Result<()> {
300        let workspace = PathBuf::from("/Users/test/my project");
301        let spec = plan_url_command(&workspace)?;
302
303        assert_eq!(spec.program, OsString::from("open"));
304        assert_eq!(spec.args.len(), 1);
305
306        let url = spec.args[0].to_str().unwrap();
307        assert!(url.starts_with("ralph://open?workspace="));
308        assert!(
309            url.contains("my%20project"),
310            "space should be percent-encoded"
311        );
312        Ok(())
313    }
314
315    #[test]
316    fn plan_url_command_handles_special_chars() -> anyhow::Result<()> {
317        let workspace = PathBuf::from("/path/with&special=chars");
318        let spec = plan_url_command(&workspace)?;
319
320        let url = spec.args[0].to_str().unwrap();
321        assert!(url.contains("%26"), "& should be encoded as %26");
322        assert!(url.contains("%3D"), "= should be encoded as %3D");
323        Ok(())
324    }
325
326    #[test]
327    fn percent_encode_preserves_unreserved_chars() {
328        let input = b"abc-_.~/123";
329        let encoded = percent_encode(input);
330        assert_eq!(encoded, "abc-_.~/123");
331    }
332
333    #[test]
334    fn percent_encode_encodes_reserved_chars() {
335        let input = b"hello world";
336        let encoded = percent_encode(input);
337        assert_eq!(encoded, "hello%20world");
338    }
339
340    #[test]
341    fn percent_encode_encodes_unicode() {
342        let input = "test/文件".as_bytes();
343        let encoded = percent_encode(input);
344        assert!(encoded.starts_with("test/"));
345        assert!(encoded.len() > "test/文件".len()); // Should be encoded
346    }
347
348    #[test]
349    fn percent_encode_path_handles_spaces() {
350        let path = PathBuf::from("/Users/test/my project");
351        let encoded = percent_encode_path(&path);
352        assert!(encoded.contains("%20"), "spaces should be encoded as %20");
353        assert!(
354            !encoded.contains(' '),
355            "result should not contain literal spaces"
356        );
357    }
358
359    #[test]
360    fn percent_encode_path_preserves_path_structure() {
361        let path = PathBuf::from("/path/to/directory");
362        let encoded = percent_encode_path(&path);
363        assert!(encoded.starts_with("/path/to/"));
364        assert!(encoded.contains('/'));
365    }
366
367    #[test]
368    fn plan_open_command_includes_cli_env_when_provided() -> anyhow::Result<()> {
369        let args = AppOpenArgs {
370            bundle_id: None,
371            path: None,
372            workspace: None,
373        };
374        let cli = PathBuf::from("/tmp/ralph-bin");
375
376        let spec = plan_open_command(true, &args, Some(&cli))?;
377        assert_eq!(spec.program, OsString::from("open"));
378        assert!(spec.args.len() >= 4);
379        assert_eq!(spec.args[0], OsString::from("--env"));
380        assert_eq!(spec.args[1], env_assignment_for_path(&cli));
381        assert_eq!(spec.args[2], OsString::from("-b"));
382        assert_eq!(spec.args[3], OsString::from(DEFAULT_BUNDLE_ID));
383        Ok(())
384    }
385
386    #[test]
387    fn plan_url_command_never_includes_cli_param() -> anyhow::Result<()> {
388        let workspace = PathBuf::from("/Users/test/workspace");
389        let spec = plan_url_command(&workspace)?;
390
391        let url = spec.args[0].to_string_lossy();
392        assert!(url.starts_with("ralph://open?workspace="));
393        assert!(!url.contains("&cli="));
394        Ok(())
395    }
396
397    #[test]
398    fn env_assignment_prefixes_variable_name() {
399        let cli = PathBuf::from("/tmp/ralph");
400        let assignment = env_assignment_for_path(&cli);
401        let text = assignment.to_string_lossy();
402        assert!(text.starts_with(&format!("{GUI_CLI_BIN_ENV}=")));
403        assert!(text.ends_with("/tmp/ralph"));
404    }
405
406    #[test]
407    fn resolve_workspace_path_prefers_explicit_workspace() -> anyhow::Result<()> {
408        let temp = tempfile::tempdir()?;
409        let args = AppOpenArgs {
410            bundle_id: None,
411            path: None,
412            workspace: Some(temp.path().to_path_buf()),
413        };
414
415        let resolved = resolve_workspace_path(&args)?;
416        assert_eq!(resolved.as_deref(), Some(temp.path()));
417        Ok(())
418    }
419
420    #[test]
421    fn resolve_workspace_path_errors_for_missing_workspace() {
422        let args = AppOpenArgs {
423            bundle_id: None,
424            path: None,
425            workspace: Some(PathBuf::from("/definitely/not/a/real/workspace")),
426        };
427
428        let err = resolve_workspace_path(&args).expect_err("expected error");
429        assert!(
430            err.to_string().contains("Workspace path does not exist"),
431            "unexpected error: {err:#}"
432        );
433    }
434}