diff --git a/Cargo.lock b/Cargo.lock index a367a8eda..98c789ab8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2070,6 +2070,15 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e" +[[package]] +name = "jp-gui" +version = "0.1.0" +dependencies = [ + "jp_plugin", + "pretty_assertions", + "serde_json", +] + [[package]] name = "jp-path" version = "0.1.0" diff --git a/crates/plugins/command/gui/Cargo.toml b/crates/plugins/command/gui/Cargo.toml new file mode 100644 index 000000000..358a384e6 --- /dev/null +++ b/crates/plugins/command/gui/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "jp-gui" + +authors.workspace = true +description = "Open the current workspace in the JP macOS app." +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +license-file.workspace = true +publish.workspace = true +readme.workspace = true +repository.workspace = true +version.workspace = true + +[dependencies] +jp_plugin = { workspace = true } + +serde_json = { workspace = true, features = ["std"] } + +[dev-dependencies] +pretty_assertions = { workspace = true, features = ["std"] } + +[lints] +workspace = true + +[package.metadata.jp-registry] +id = "gui" +command = ["gui"] +description = "Open the current workspace in the JP macOS app" +official = true +repository = "https://github.com/dcdpr/jp" + +[[bin]] +name = "jp-gui" +path = "src/main.rs" diff --git a/crates/plugins/command/gui/src/launch.rs b/crates/plugins/command/gui/src/launch.rs new file mode 100644 index 000000000..1a5ed0b0f --- /dev/null +++ b/crates/plugins/command/gui/src/launch.rs @@ -0,0 +1,42 @@ +//! Launching the app, behind a trait so the rest can be tested without opening +//! a window. + +use std::process::Command; + +/// Starts the app on a workspace. +pub(crate) trait Launcher { + /// Open the app identified by `bundle_id`, showing the workspace at `path`. + fn launch(&self, bundle_id: &str, path: &str) -> Result<(), String>; +} + +/// Launches through macOS Launch Services. +pub(crate) struct SystemLauncher; + +impl Launcher for SystemLauncher { + /// Hands the workspace over as `JP_WORKSPACE`, which is what the app reads + /// when a window opens with no workspace of its own. + /// + /// `open -b` finds the app by bundle identifier, so nothing here depends on + /// where it was installed. + /// `-n` is deliberately absent: a second `jp gui` for a workspace already + /// on screen should bring that window forward rather than start a second + /// copy of the app. + fn launch(&self, bundle_id: &str, path: &str) -> Result<(), String> { + let status = Command::new("open") + .arg("-b") + .arg(bundle_id) + .arg("--env") + .arg(format!("JP_WORKSPACE={path}")) + .status() + .map_err(|e| format!("could not run `open`: {e}"))?; + + if status.success() { + return Ok(()); + } + + Err(format!( + "could not open the JP app (`open` exited with {status}). Build and install it with \ + `just build-app`, or open it once by hand so macOS knows where it is." + )) + } +} diff --git a/crates/plugins/command/gui/src/main.rs b/crates/plugins/command/gui/src/main.rs new file mode 100644 index 000000000..d95dbe906 --- /dev/null +++ b/crates/plugins/command/gui/src/main.rs @@ -0,0 +1,171 @@ +//! `jp-gui`: open the current workspace in the JP macOS app. +//! +//! A command plugin that hands the host's already-resolved workspace root to +//! the app and exits. +//! It does no path resolution of its own: `InitMessage` carries the root, so +//! `jp gui`, `jp -w ../other gui`, and `jp gui .` all arrive here already +//! answered. +//! +//! See: `docs/rfd/072-command-plugin-system.md` + +use std::io::{self, BufRead, BufReader, IsTerminal as _, Write}; + +use jp_plugin::message::{DescribeResponse, ExitMessage, HostToPlugin, InitMessage, PluginToHost}; + +mod launch; + +use launch::{Launcher, SystemLauncher}; + +const HELP_TEXT: &str = "\ +Open the current workspace in the JP macOS app. + +Usage: jp gui [PATH] + +Arguments: + [PATH] A directory inside the workspace to open. Defaults to the workspace + the rest of `jp` is using; pass `-w/--workspace` to target another."; + +/// The app's bundle identifier, which is how macOS finds it without a path. +const BUNDLE_ID: &str = "computer.jp.jean-pierre"; + +fn main() { + if io::stdin().is_terminal() { + let mut err = io::stderr().lock(); + drop(writeln!(err, "{HELP_TEXT}")); + drop(writeln!(err)); + drop(writeln!( + err, + "Note: this binary is a JP plugin. Run it via `jp gui`." + )); + std::process::exit(0); + } + + let stdin = BufReader::new(io::stdin()); + let stdout = io::stdout(); + + let code = match run(stdin, stdout, &SystemLauncher) { + Ok(()) => 0, + Err(e) => { + let mut err = io::stderr().lock(); + drop(writeln!(err, "Fatal: {e}")); + 1 + } + }; + + std::process::exit(code); +} + +fn run( + mut stdin: impl BufRead, + mut stdout: impl Write, + launcher: &impl Launcher, +) -> Result<(), String> { + match read_message(&mut stdin)? { + HostToPlugin::Describe => send_describe(&mut stdout), + HostToPlugin::Init(init) => { + send(&mut stdout, &PluginToHost::Ready)?; + open(&init, &mut stdout, launcher) + } + other => Err(format!("expected init or describe, got: {other:?}")), + } +} + +/// Launch the app on the workspace the host resolved. +fn open( + init: &InitMessage, + stdout: &mut impl Write, + launcher: &impl Launcher, +) -> Result<(), String> { + let root = init.workspace.root.as_str(); + + match validate(init, root) { + Err(reason) => send_exit(stdout, 1, Some(&reason)), + Ok(()) => match launcher.launch(BUNDLE_ID, root) { + Ok(()) => send_exit(stdout, 0, None), + Err(reason) => send_exit(stdout, 1, Some(&reason)), + }, + } +} + +/// Check a trailing path argument against the workspace the host resolved. +/// +/// The host has already picked the workspace, so a path that names a different +/// one is a mistake worth reporting rather than silently ignoring: the user +/// would otherwise get a window onto a workspace they did not ask for. +fn validate(init: &InitMessage, root: &str) -> Result<(), String> { + let Some(argument) = init.args.first() else { + return Ok(()); + }; + + if argument.starts_with('-') { + return Err(format!("unknown option: {argument}\n\n{HELP_TEXT}")); + } + + if init.args.len() > 1 { + return Err(format!("expected at most one path\n\n{HELP_TEXT}")); + } + + // The host resolves `jp gui .` against the same workspace, so a relative + // argument that is inside the resolved root is what "." and "src/" look like + // by the time they reach here. + let absolute = std::path::Path::new(argument) + .canonicalize() + .map_err(|e| format!("cannot resolve path '{argument}': {e}"))?; + + let root_path = std::path::Path::new(root) + .canonicalize() + .map_err(|e| format!("cannot resolve workspace root '{root}': {e}"))?; + + if absolute.starts_with(&root_path) { + Ok(()) + } else { + Err(format!( + "'{argument}' is not inside the workspace at '{root}'. Use `jp -w {argument} gui` to \ + open it instead." + )) + } +} + +fn send_describe(stdout: &mut impl Write) -> Result<(), String> { + send( + stdout, + &PluginToHost::Describe(DescribeResponse { + name: "gui".to_owned(), + version: env!("CARGO_PKG_VERSION").to_owned(), + description: "Open the current workspace in the JP macOS app".to_owned(), + command: vec!["gui".to_owned()], + author: Some("Jean Mertz ".to_owned()), + help: Some(HELP_TEXT.to_owned()), + repository: Some("https://github.com/dcdpr/jp".to_owned()), + }), + ) +} + +fn send_exit(stdout: &mut impl Write, code: u8, reason: Option<&str>) -> Result<(), String> { + send( + stdout, + &PluginToHost::Exit(ExitMessage { + code, + reason: reason.map(String::from), + }), + ) +} + +fn read_message(stdin: &mut impl BufRead) -> Result { + let mut line = String::new(); + stdin + .read_line(&mut line) + .map_err(|e| format!("failed to read from host: {e}"))?; + + serde_json::from_str(line.trim()).map_err(|e| format!("invalid host message: {e}")) +} + +fn send(stdout: &mut impl Write, msg: &PluginToHost) -> Result<(), String> { + let json = serde_json::to_string(msg).map_err(|e| format!("serialize error: {e}"))?; + writeln!(stdout, "{json}").map_err(|e| format!("write error: {e}"))?; + stdout.flush().map_err(|e| format!("flush error: {e}")) +} + +#[cfg(test)] +#[path = "main_tests.rs"] +mod tests; diff --git a/crates/plugins/command/gui/src/main_tests.rs b/crates/plugins/command/gui/src/main_tests.rs new file mode 100644 index 000000000..0c2f3fa17 --- /dev/null +++ b/crates/plugins/command/gui/src/main_tests.rs @@ -0,0 +1,202 @@ +use std::{cell::RefCell, io::Cursor}; + +use jp_plugin::message::{PathsInfo, WorkspaceInfo}; +use pretty_assertions::assert_eq; + +use super::*; + +/// Records what it was asked to launch, instead of launching it. +#[derive(Default)] +struct RecordingLauncher { + launched: RefCell>, + fails: bool, +} + +impl RecordingLauncher { + fn failing() -> Self { + Self { + fails: true, + ..Self::default() + } + } +} + +impl Launcher for RecordingLauncher { + fn launch(&self, bundle_id: &str, path: &str) -> Result<(), String> { + self.launched + .borrow_mut() + .push((bundle_id.to_owned(), path.to_owned())); + + if self.fails { + return Err("the app is not installed".to_owned()); + } + + Ok(()) + } +} + +fn init_message(root: &str, args: &[&str]) -> String { + let init = InitMessage { + version: 1, + workspace: WorkspaceInfo { + root: root.into(), + storage: format!("{root}/.jp").into(), + id: "abcde".to_owned(), + }, + paths: PathsInfo::default(), + config: serde_json::json!({}), + options: serde_json::Map::new(), + args: args.iter().map(|a| (*a).to_owned()).collect(), + log_level: 0, + }; + + format!( + "{}\n", + serde_json::to_string(&HostToPlugin::Init(init)).unwrap() + ) +} + +/// Run the plugin against one host message, returning what it wrote back. +fn exchange(input: &str, launcher: &impl Launcher) -> Vec { + let mut stdout = Vec::new(); + run(Cursor::new(input), &mut stdout, launcher).unwrap(); + + String::from_utf8(stdout) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() +} + +#[test] +fn opens_the_workspace_the_host_resolved() { + let launcher = RecordingLauncher::default(); + + let sent = exchange(&init_message("/tmp/my-workspace", &[]), &launcher); + + assert_eq!(launcher.launched.borrow().as_slice(), [( + "computer.jp.jean-pierre".to_owned(), + "/tmp/my-workspace".to_owned() + )]); + assert_eq!(sent, vec![ + PluginToHost::Ready, + PluginToHost::Exit(ExitMessage { + code: 0, + reason: None + }), + ]); +} + +/// The host answers `-w/--workspace` before the plugin runs, so targeting +/// another workspace needs nothing here. +#[test] +fn opens_whatever_root_the_host_sent() { + let launcher = RecordingLauncher::default(); + + exchange(&init_message("/tmp/other", &[]), &launcher); + + assert_eq!(launcher.launched.borrow()[0].1, "/tmp/other"); +} + +/// A failure to launch is reported as a non-zero exit with a reason, rather +/// than a silent success. +#[test] +fn reports_a_failed_launch() { + let launcher = RecordingLauncher::failing(); + + let sent = exchange(&init_message("/tmp/my-workspace", &[]), &launcher); + + assert_eq!(sent, vec![ + PluginToHost::Ready, + PluginToHost::Exit(ExitMessage { + code: 1, + reason: Some("the app is not installed".to_owned()) + }), + ]); +} + +#[test] +fn rejects_an_unknown_option() { + let launcher = RecordingLauncher::default(); + + let sent = exchange(&init_message("/tmp/my-workspace", &["--nope"]), &launcher); + + assert!( + launcher.launched.borrow().is_empty(), + "nothing should launch" + ); + let PluginToHost::Exit(exit) = &sent[1] else { + panic!("expected an exit message, got {:?}", sent[1]); + }; + assert_eq!(exit.code, 1); + assert!( + exit.reason + .as_deref() + .is_some_and(|r| r.starts_with("unknown option: --nope")), + "got: {:?}", + exit.reason + ); +} + +/// A trailing path that names a different workspace is a mistake: the host has +/// already chosen, so opening its choice would silently ignore the argument. +#[test] +fn rejects_a_path_outside_the_workspace() { + let launcher = RecordingLauncher::default(); + let tmp = std::env::temp_dir(); + let outside = tmp.to_string_lossy().into_owned(); + + let sent = exchange( + &init_message(env!("CARGO_MANIFEST_DIR"), &[&outside]), + &launcher, + ); + + assert!( + launcher.launched.borrow().is_empty(), + "nothing should launch" + ); + let PluginToHost::Exit(exit) = &sent[1] else { + panic!("expected an exit message, got {:?}", sent[1]); + }; + assert_eq!(exit.code, 1); + assert!( + exit.reason + .as_deref() + .is_some_and(|r| r.contains("is not inside the workspace")), + "got: {:?}", + exit.reason + ); +} + +/// `jp gui .` and `jp gui src/` name the workspace the host already resolved, +/// so they open it rather than erroring. +#[test] +fn accepts_a_path_inside_the_workspace() { + let launcher = RecordingLauncher::default(); + let root = env!("CARGO_MANIFEST_DIR"); + + exchange(&init_message(root, &[&format!("{root}/src")]), &launcher); + + assert_eq!(launcher.launched.borrow().len(), 1); +} + +#[test] +fn describes_itself_without_launching_anything() { + let launcher = RecordingLauncher::default(); + let describe = format!( + "{}\n", + serde_json::to_string(&HostToPlugin::Describe).unwrap() + ); + + let sent = exchange(&describe, &launcher); + + assert!( + launcher.launched.borrow().is_empty(), + "nothing should launch" + ); + let PluginToHost::Describe(response) = &sent[0] else { + panic!("expected a describe response, got {:?}", sent[0]); + }; + assert_eq!(response.name, "gui"); + assert_eq!(response.command, ["gui"]); +} diff --git a/justfile b/justfile index 34a114858..57a264267 100644 --- a/justfile +++ b/justfile @@ -21,7 +21,7 @@ quiet_flag := if env_var_or_default("CI", "") == "true" { "" } else { "--quiet" # # `grizzly` is deliberately absent: `jp_attachment_bear_note` depends on it, so # it is compiled either way. -non_jp_excludes := "--exclude bookworm --exclude build-registry --exclude comfort --exclude jp-path --exclude jp-serve-web --exclude json_edit --exclude tools" +non_jp_excludes := "--exclude bookworm --exclude build-registry --exclude comfort --exclude jp-gui --exclude jp-path --exclude jp-serve-web --exclude json_edit --exclude tools" alias r := run alias i := install