Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 35 additions & 0 deletions crates/plugins/command/gui/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
42 changes: 42 additions & 0 deletions crates/plugins/command/gui/src/launch.rs
Original file line number Diff line number Diff line change
@@ -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."
))
}
}
171 changes: 171 additions & 0 deletions crates/plugins/command/gui/src/main.rs
Original file line number Diff line number Diff line change
@@ -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 <git@jeanmertz.com>".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<HostToPlugin, String> {
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;
Loading
Loading