blob: d99d727f123779080a2589879b1ed3dd42d534bf [file]
//! Where an application comes from when the command line does not give a path.
//!
//! Two places, both single-application:
//!
//! - **Installed**: `gpui-shell run clock` runs `~/.gpui-shell/apps/clock`. A
//! dot directory in the home directory rather than a platform configuration
//! directory, so `curl`, Homebrew and mise installs, and every platform,
//! agree on one path a user can type.
//! - **Bundled**: with no argument at all, the application packaged beside the
//! executable. A macOS `.app` or a Linux package is then this binary plus a
//! directory of scripts, and needs no launcher script to pass a path —
//! which an `Info.plist` or a desktop file could not do portably anyway.
use std::path::{Path, PathBuf};
use super::{ENTRY, HostBrand};
use crate::plugin::{MANIFEST_FILE, PluginManifest};
/// The directory installed applications live in, one directory each.
///
/// Named after the command, so an embedding application's own branded command
/// keeps applications written against its catalog apart from ones `gpui-shell`
/// cannot run. Its siblings, such as the dependency cache, are not
/// applications.
///
/// `GPUI_SHELL_HOME`, when set, replaces the whole `~/.<command>` directory —
/// the dependency cache honours it too — so a portable or system-wide install
/// can keep everything somewhere else.
pub(super) fn applications_directory(brand: HostBrand) -> PathBuf {
shell_home(brand, |variable| std::env::var_os(variable)).join("apps")
}
fn shell_home(
brand: HostBrand,
environment: impl Fn(&str) -> Option<std::ffi::OsString>,
) -> PathBuf {
match environment(crate::dependencies::SHELL_HOME_VARIABLE).filter(|value| !value.is_empty()) {
Some(directory) => std::path::absolute(&directory).unwrap_or_else(|_| directory.into()),
None => home().join(format!(".{}", brand.name())),
}
}
fn home() -> PathBuf {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir)
}
/// Whether an argument names an installed application rather than a path.
///
/// A name is one plain path segment: no separator, and not `.` or `..`, which
/// both mean a directory relative to where the command runs.
pub(super) fn is_application_name(argument: &str) -> bool {
!argument.is_empty()
&& argument != "."
&& argument != ".."
&& !argument.contains(['/', '\\'])
&& !argument.starts_with('~')
}
/// The directory of an installed application.
pub(super) fn resolve(name: &str, brand: HostBrand) -> Result<PathBuf, String> {
let directory = applications_directory(brand).join(name);
if is_application(&directory) {
return Ok(directory);
}
Err(format!(
"no application `{name}` is installed: {} has no {ENTRY} or {MANIFEST_FILE}. \
`{cmd} list` shows what is installed; to run a directory, use `{cmd} run -d {name}`.",
directory.display(),
cmd = brand.name()
))
}
fn is_application(directory: &Path) -> bool {
directory.join(MANIFEST_FILE).is_file() || directory.join(ENTRY).is_file()
}
/// Where a packaged application is looked for, relative to the executable, in
/// the words the help prints.
pub(super) const BUNDLED_LOCATIONS: &str =
"`app` beside the executable, or `../Resources/app` inside a macOS bundle";
/// The application packaged beside the running executable, if there is one.
pub(super) fn bundled() -> Option<PathBuf> {
let executable = std::env::current_exe().ok()?;
let executable = executable.canonicalize().unwrap_or(executable);
bundled_beside(executable.parent()?)
}
fn bundled_beside(executable_directory: &Path) -> Option<PathBuf> {
[
// `Foo.app/Contents/MacOS/gpui-shell` with `Foo.app/Contents/Resources/app`.
executable_directory
.join("..")
.join("Resources")
.join("app"),
// A Linux or Windows package, or an unpacked archive.
executable_directory.join("app"),
]
.into_iter()
.find(|candidate| is_application(candidate))
}
/// `list`: every installed application and what it asks for, executing none.
pub(super) fn list(brand: HostBrand) -> ! {
let directory = applications_directory(brand);
let mut names: Vec<PathBuf> = std::fs::read_dir(&directory)
.into_iter()
.flatten()
.flatten()
.map(|entry| entry.path())
.filter(|path| is_application(path))
.collect();
names.sort();
if names.is_empty() {
eprintln!(
"no application is installed in {}. Each application is a directory there \
holding {ENTRY} and, usually, {MANIFEST_FILE}.",
directory.display()
);
std::process::exit(1);
}
for root in names {
let name = root
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
if !root.join(MANIFEST_FILE).is_file() {
println!("{name}\n no {MANIFEST_FILE}: local defaults (storage only)");
continue;
}
match PluginManifest::read(&root) {
Ok(manifest) => {
println!(
"{name} {} {} ({})",
manifest.name(),
manifest.version(),
manifest.id()
);
let data = crate::runtime::app_data_dir(manifest.id()).unwrap_or_default();
// What `run` will actually grant: the manifest's request plus the
// application's own directory and storage.
let grants = super::local_capabilities(&root, &data, Some(&manifest)).summary();
if grants.is_empty() {
println!(" no capabilities");
}
for grant in grants {
println!(" {grant}");
}
}
Err(error) => println!("{name}\n invalid: {error}"),
}
}
std::process::exit(0);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bare_name_is_installed_and_anything_path_like_is_a_path() {
assert!(is_application_name("clock"));
assert!(is_application_name("my-app.v2"));
for path in [
".",
"..",
"./clock",
"examples/js_todolist",
"/abs",
"~/clock",
"a\\b",
"",
] {
assert!(!is_application_name(path), "`{path}` reads as a path");
}
}
#[test]
fn installed_applications_live_under_the_command_name() {
let directory = applications_directory(HostBrand::new("gpui-shell", "0.0.0"));
assert!(directory.ends_with(Path::new(".gpui-shell").join("apps")));
let branded = applications_directory(HostBrand::new("market-shell", "0.0.0"));
assert!(branded.ends_with(Path::new(".market-shell").join("apps")));
}
#[test]
fn the_shell_home_variable_replaces_the_dot_directory() {
let brand = HostBrand::new("gpui-shell", "0.0.0");
let moved = shell_home(brand, |variable| {
(variable == crate::dependencies::SHELL_HOME_VARIABLE).then(|| "/opt/shell".into())
});
assert_eq!(moved, PathBuf::from("/opt/shell"));
let unset = shell_home(brand, |_| None);
assert!(unset.ends_with(".gpui-shell"));
}
#[test]
fn a_bundled_application_is_found_in_a_macos_bundle_or_beside_the_binary() {
let root = std::env::temp_dir().join(format!("gpui-shell-bundle-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let executables = root.join("Contents").join("MacOS");
std::fs::create_dir_all(&executables).expect("bundle");
assert_eq!(
bundled_beside(&executables),
None,
"nothing is packaged yet"
);
let resources = root.join("Contents").join("Resources").join("app");
std::fs::create_dir_all(&resources).expect("resources");
std::fs::write(resources.join(ENTRY), "").expect("entry");
assert_eq!(
bundled_beside(&executables),
Some(executables.join("..").join("Resources").join("app"))
);
let beside = executables.join("app");
std::fs::create_dir_all(&beside).expect("app beside");
std::fs::write(beside.join(ENTRY), "").expect("entry");
assert!(bundled_beside(&executables).is_some());
let _ = std::fs::remove_dir_all(&root);
}
}