| //! `gpui-shell` — runs a script application directory. | |
| //! | |
| //! ```text | |
| //! gpui-shell run|dev [<name> | --dir <directory>] [--help] [--version] | |
| //! ``` | |
| //! | |
| //! The binary is the thin host the library documents: it parses a command line, | |
| //! installs a log sink, builds one runtime, opens one window, and — when asked — | |
| //! drives the source watcher from a GPUI timer. Every decision that outlives a | |
| //! single invocation lives in the library instead. | |
| use std::{ | |
| cell::RefCell, | |
| fmt::{self, Write as _}, | |
| path::{Path, PathBuf}, | |
| rc::Rc, | |
| }; | |
| use crate::{AppAssets, Capabilities, ShellRoot, ShellRuntime}; | |
| use gpui::{ | |
| AnyWindowHandle, App, AppContext as _, Bounds, Context, Entity, IntoElement, Render, | |
| TitlebarOptions, Window, WindowBounds, WindowOptions, px, size, | |
| }; | |
| use tracing::{ | |
| Event, Level, Metadata, Subscriber, | |
| field::{Field, Visit}, | |
| level_filters::LevelFilter, | |
| span, | |
| }; | |
| /// The entry file an application directory must contain. Duplicated from the | |
| /// engine because the host resolves the application root itself: it needs the | |
| /// resolved directory for the window title and for the watcher, both of which | |
| /// are decided before any script is loaded. | |
| const ENTRY: &str = "main.js"; | |
| /// The exit code for a command line this binary could not act on. Distinct from | |
| /// 1, which is a runtime that failed to start. | |
| const EXIT_USAGE: i32 = 2; | |
| mod agents; | |
| mod bundle; | |
| mod installed; | |
| mod scaffold; | |
| /// Runs the shipped command-line host with one frozen component catalog. | |
| /// | |
| /// Embedding binaries use this entry point so command parsing, capabilities, | |
| /// assets, theming, checking, and diagnostics stay identical to `gpui-shell`. | |
| pub fn main_with_components(components: crate::FrozenComponentRegistry) { | |
| main_with_brand_and_components(HostBrand::gpui_shell(), components); | |
| } | |
| /// Runs the shared host under an embedding command's public name and version. | |
| pub fn main_with_brand_and_components( | |
| brand: HostBrand, | |
| components: crate::FrozenComponentRegistry, | |
| ) { | |
| let invocation = match parse(std::env::args().skip(1), brand) { | |
| Ok(invocation) => invocation, | |
| Err(message) => usage_error(brand, &message), | |
| }; | |
| let arguments = match invocation { | |
| Invocation::Run(arguments) => arguments, | |
| Invocation::Bare(arguments) => { | |
| if installed::bundled().is_none() { | |
| println!("{}", help(brand)); | |
| return; | |
| } | |
| arguments | |
| } | |
| Invocation::Types(application) => { | |
| let directory = resolve_or_exit(&application, brand); | |
| // The root always, whether or not anything there imports the module: | |
| // this command was asked for explicitly, and an empty directory is a | |
| // reasonable place to start an application. Every other directory | |
| // that imports `gpui-kit` comes along with it. | |
| match crate::write_type_declarations_with_components(&directory, &components) { | |
| Ok(_) => {} | |
| Err(error) => { | |
| eprintln!("{}: {error}", brand.name()); | |
| std::process::exit(1); | |
| } | |
| } | |
| return; | |
| } | |
| Invocation::List => installed::list(brand), | |
| Invocation::Agents(application) => { | |
| let Some(application) = application else { | |
| print!("{}", agents::render(brand, &components)); | |
| return; | |
| }; | |
| let directory = resolve_or_exit(&application, brand); | |
| match agents::write(&directory, brand, &components) { | |
| Ok(Some(path)) => println!("wrote {}", path.display()), | |
| Ok(None) => println!( | |
| "{} is up to date", | |
| directory.join(agents::AGENTS_FILE).display() | |
| ), | |
| Err(error) => { | |
| eprintln!("{}: {error}", brand.name()); | |
| std::process::exit(1); | |
| } | |
| } | |
| return; | |
| } | |
| Invocation::New(directory) => match scaffold::create(&directory, brand, &components) { | |
| Ok(created) => { | |
| let shown = directory.display(); | |
| println!("created {}", created.display()); | |
| println!(); | |
| println!( | |
| " {} dev -d {shown} # run it, reloading on save", | |
| brand.name() | |
| ); | |
| println!( | |
| " {} check -d {shown} # check it after every change", | |
| brand.name() | |
| ); | |
| println!(); | |
| println!( | |
| "To install it, copy or link it into {}.", | |
| installed::applications_directory(brand).display() | |
| ); | |
| return; | |
| } | |
| Err(error) => { | |
| eprintln!("{}: {error}", brand.name()); | |
| std::process::exit(1); | |
| } | |
| }, | |
| Invocation::Check(arguments) => { | |
| let directory = resolve_or_exit(&arguments.application, brand); | |
| // A check reports diagnostics, not progress, so only warnings and | |
| // errors reach the terminal. | |
| install_log_sink(Level::WARN); | |
| check(directory, arguments, components); | |
| } | |
| Invocation::Bundle(arguments) => { | |
| let directory = resolve_or_exit(&arguments.application, brand); | |
| match bundle::create(&directory, &arguments.options, brand) { | |
| Ok(report) => println!("{report}"), | |
| Err(error) => { | |
| eprintln!("{}: {error:#}", brand.name()); | |
| std::process::exit(1); | |
| } | |
| } | |
| return; | |
| } | |
| Invocation::Print(text) => { | |
| println!("{text}"); | |
| return; | |
| } | |
| }; | |
| let directory = resolve_or_exit(&arguments.application, brand); | |
| if arguments.is_packaged() { | |
| let root = crate::resolve_app_root(&directory, ENTRY).unwrap_or_else(|_| directory.clone()); | |
| // Without a manifest there is no `id` to keep the data under, and the | |
| // data would follow the path instead — lost when the bundle moves. | |
| if !root.join(crate::plugin::MANIFEST_FILE).is_file() { | |
| eprintln!( | |
| "{}: {} has no {}; a packaged application needs one for its `id`", | |
| brand.name(), | |
| root.display(), | |
| crate::plugin::MANIFEST_FILE | |
| ); | |
| std::process::exit(1); | |
| } | |
| } | |
| // Installed before anything else: the runtime reports script errors, | |
| // unhandled promise rejections and illegal-phase calls through `tracing`, | |
| // and until a subscriber exists every one of them is discarded silently. | |
| install_log_sink(if arguments.is_verbose() { | |
| Level::DEBUG | |
| } else { | |
| Level::INFO | |
| }); | |
| run(directory, arguments, components, brand); | |
| } | |
| fn usage_error(brand: HostBrand, message: &str) -> ! { | |
| eprintln!("{}: {message}", brand.name()); | |
| eprintln!("Try `{} --help` for the accepted arguments.", brand.name()); | |
| std::process::exit(EXIT_USAGE); | |
| } | |
| fn resolve_or_exit(application: &Application, brand: HostBrand) -> PathBuf { | |
| match application.resolve(brand) { | |
| Ok(directory) => directory, | |
| Err(message) => { | |
| eprintln!("{}: {message}", brand.name()); | |
| std::process::exit(1); | |
| } | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Command line | |
| // --------------------------------------------------------------------------- | |
| /// What a successfully parsed command line asks the host to do. | |
| /// | |
| /// `--help` and `--version` answer without starting a runtime, so they are a | |
| /// separate outcome rather than a flag on [`Arguments`]: nothing downstream | |
| /// should have to remember to check them. | |
| #[derive(Debug, PartialEq, Eq)] | |
| enum Invocation { | |
| Run(Arguments), | |
| /// No command at all: the packaged application when there is one, and | |
| /// the help otherwise. A bundle's executable is started with no | |
| /// arguments, and so is a freshly installed binary typed at a prompt. | |
| Bare(Arguments), | |
| /// Print the installed applications, executing none. | |
| List, | |
| /// Load and render once, report, and exit with a status. | |
| Check(CheckArguments), | |
| /// Write TypeScript declarations next to an application. | |
| Types(Application), | |
| /// Print the development instructions, or write them into an application. | |
| Agents(Option<Application>), | |
| /// Create a blank application in a new directory. | |
| New(PathBuf), | |
| /// Package an application with the runtime as a desktop application. | |
| Bundle(BundleArguments), | |
| /// Text for stdout, followed by a successful exit. | |
| Print(String), | |
| } | |
| /// Which application a command line names. | |
| /// | |
| /// Kept unresolved by the parser so parsing stays free of the environment: the | |
| /// installed-applications directory depends on the home directory, and the | |
| /// bundled application on where the executable is. | |
| #[derive(Clone, Debug, PartialEq, Eq)] | |
| enum Application { | |
| /// A directory name under the installed-applications directory: | |
| /// `gpui-shell run clock` runs `~/.gpui-shell/apps/clock`. | |
| Installed(String), | |
| /// A directory, or the entry file inside it, given as a path. | |
| Path(PathBuf), | |
| /// Nothing named: the application packaged beside the executable. | |
| Bundled, | |
| } | |
| impl Application { | |
| /// A bare argument is an installed application's name; anything that reads | |
| /// as a path is a path. `run clock` and `run ./clock` are | |
| /// therefore different applications, and `--dir` removes the doubt. | |
| fn from_argument(argument: &str) -> Self { | |
| if installed::is_application_name(argument) { | |
| Self::Installed(argument.to_owned()) | |
| } else { | |
| Self::Path(PathBuf::from(argument)) | |
| } | |
| } | |
| /// The directory the command runs in, absolute so a report names it. | |
| fn current_directory() -> Self { | |
| Self::Path(std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))) | |
| } | |
| /// How the command line names it, for a message suggesting the command. | |
| fn describe(&self) -> String { | |
| match self { | |
| Self::Installed(name) => name.clone(), | |
| Self::Path(path) => format!("-d {}", path.display()), | |
| Self::Bundled => String::new(), | |
| } | |
| } | |
| fn resolve(&self, brand: HostBrand) -> Result<PathBuf, String> { | |
| match self { | |
| Self::Installed(name) => installed::resolve(name, brand), | |
| Self::Path(path) => Ok(path.clone()), | |
| Self::Bundled => installed::bundled().ok_or_else(|| { | |
| format!( | |
| "nothing to run: no application is packaged beside this executable. \ | |
| Run one with `{name} run <name>` or `{name} run -d <directory>`; \ | |
| `{name} list` shows what is installed in {installed}.", | |
| name = brand.name(), | |
| installed = installed::applications_directory(brand).display() | |
| ) | |
| }), | |
| } | |
| } | |
| } | |
| /// The command name and version shown by the reusable CLI host. | |
| /// | |
| /// Private fields for the same reason the component descriptors have them: an | |
| /// embedding binary constructs one across the crate boundary, so a later field | |
| /// should be an added builder method rather than a break. | |
| #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| pub struct HostBrand { | |
| name: &'static str, | |
| version: &'static str, | |
| } | |
| impl HostBrand { | |
| pub const fn new(name: &'static str, version: &'static str) -> Self { | |
| Self { name, version } | |
| } | |
| pub fn name(&self) -> &'static str { | |
| self.name | |
| } | |
| pub fn version(&self) -> &'static str { | |
| self.version | |
| } | |
| const fn gpui_shell() -> Self { | |
| Self::new("gpui-shell", env!("CARGO_PKG_VERSION")) | |
| } | |
| } | |
| /// Stable command classification exposed for wrapper-binary parser tests. | |
| #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| pub enum InvocationKind { | |
| Run, | |
| /// Lists the installed applications without running them. | |
| List, | |
| Check, | |
| Types, | |
| /// Prints or writes the development instructions for coding agents. | |
| Agents, | |
| /// Creates a blank application. | |
| New, | |
| /// Packages an application with the runtime as a desktop application. | |
| Bundle, | |
| Print, | |
| } | |
| /// Parses a command line without starting GPUI or terminating the process. | |
| pub fn parse_invocation( | |
| arguments: impl IntoIterator<Item = impl Into<String>>, | |
| ) -> Result<InvocationKind, String> { | |
| parse( | |
| arguments.into_iter().map(Into::into), | |
| HostBrand::gpui_shell(), | |
| ) | |
| .map(|invocation| match invocation { | |
| Invocation::Run(_) | Invocation::Bare(_) => InvocationKind::Run, | |
| Invocation::List => InvocationKind::List, | |
| Invocation::Check(_) => InvocationKind::Check, | |
| Invocation::Types(_) => InvocationKind::Types, | |
| Invocation::Agents(_) => InvocationKind::Agents, | |
| Invocation::New(_) => InvocationKind::New, | |
| Invocation::Bundle(_) => InvocationKind::Bundle, | |
| Invocation::Print(_) => InvocationKind::Print, | |
| }) | |
| } | |
| /// The command line, parsed. | |
| /// | |
| /// A value rather than a set of locals so the parsing can be tested without | |
| /// opening a window, which is the only part of this binary that has to run on a | |
| /// desktop. | |
| #[derive(Debug, PartialEq, Eq)] | |
| struct Arguments { | |
| application: Application, | |
| watch: bool, | |
| verbose: bool, | |
| development: bool, | |
| packaged: bool, | |
| } | |
| impl Arguments { | |
| /// Whether sources are polled for changes: `run --watch`, or `dev`. | |
| fn is_watching(&self) -> bool { | |
| self.watch | |
| } | |
| /// Whether the log goes down to debug level: under `dev` only. | |
| fn is_verbose(&self) -> bool { | |
| self.verbose | |
| } | |
| /// Whether the sandbox relaxations are on: `--dev`, and nothing else. | |
| fn is_development(&self) -> bool { | |
| self.development | |
| } | |
| /// Whether the application is a packaged one rather than a directory | |
| /// being edited: the one beside the executable, or one a bundle's | |
| /// launcher runs with `--packaged`. It must have a manifest, is titled by | |
| /// its `name`, and has no editor files written into it. | |
| fn is_packaged(&self) -> bool { | |
| self.packaged || self.application == Application::Bundled | |
| } | |
| } | |
| /// The subcommands, which do something other than run an application. | |
| #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| enum Command { | |
| Run, | |
| /// `run` for an application being edited: reload on change, sandbox relaxed. | |
| Dev, | |
| New, | |
| Check, | |
| Types, | |
| List, | |
| Agents, | |
| Bundle, | |
| } | |
| /// Parses the arguments after the program name. | |
| /// | |
| /// Hand-rolled because the whole surface is a few flags and one application: | |
| /// a parser dependency would be larger than the thing it parses. The error is | |
| /// the exact sentence printed to stderr, so the caller decides nothing about | |
| /// wording. | |
| fn parse( | |
| arguments: impl IntoIterator<Item = String>, | |
| brand: HostBrand, | |
| ) -> Result<Invocation, String> { | |
| let arguments: Vec<String> = arguments.into_iter().collect(); | |
| // Answered before anything else can fail. A caller who mistyped one flag is | |
| // exactly the caller who needs `--help` to still work. | |
| if arguments.iter().any(|it| it == "--help" || it == "-h") { | |
| return Ok(Invocation::Print(help(brand))); | |
| } | |
| if arguments.iter().any(|it| it == "--version" || it == "-V") { | |
| return Ok(Invocation::Print(version(brand))); | |
| } | |
| let mut application: Option<Application> = None; | |
| let mut command: Option<Command> = None; | |
| let mut watch = false; | |
| let mut development = false; | |
| let mut print_spec = false; | |
| let mut packaged = false; | |
| let mut bundle_options = bundle::BundleOptions::default(); | |
| // The first `bundle`-only flag, reported if the command is not `bundle`. | |
| let mut bundle_flag: Option<&'static str> = None; | |
| let mut arguments = arguments.into_iter(); | |
| while let Some(argument) = arguments.next() { | |
| let named = |application: &Option<Application>, next: Application| { | |
| if application.is_some() { | |
| return Err(format!( | |
| "unexpected argument `{}`; {} runs one application", | |
| match &next { | |
| Application::Installed(name) => name.clone(), | |
| Application::Path(path) => path.display().to_string(), | |
| Application::Bundled => String::new(), | |
| }, | |
| brand.name() | |
| )); | |
| } | |
| Ok(Some(next)) | |
| }; | |
| match argument.as_str() { | |
| // A subcommand rather than a flag, because it does something else | |
| // entirely: it never shows a window and it exits with a status. | |
| "new" if application.is_none() && command.is_none() => { | |
| command = Some(Command::New); | |
| } | |
| "run" if application.is_none() && command.is_none() => { | |
| command = Some(Command::Run); | |
| } | |
| "dev" if application.is_none() && command.is_none() => { | |
| command = Some(Command::Dev); | |
| } | |
| "check" if application.is_none() && command.is_none() => { | |
| command = Some(Command::Check); | |
| } | |
| "types" if application.is_none() && command.is_none() => { | |
| command = Some(Command::Types); | |
| } | |
| "list" if application.is_none() && command.is_none() => { | |
| command = Some(Command::List); | |
| } | |
| "agents" if application.is_none() && command.is_none() => { | |
| command = Some(Command::Agents); | |
| } | |
| "bundle" if application.is_none() && command.is_none() => { | |
| command = Some(Command::Bundle); | |
| } | |
| "--platform" => { | |
| let value = flag_value(&mut arguments, "--platform", "macos, linux or windows")?; | |
| bundle_options.platform = | |
| Some(bundle::Platform::parse(&value).ok_or_else(|| { | |
| format!( | |
| "unknown platform `{value}`; `--platform` is macos, linux or windows" | |
| ) | |
| })?); | |
| bundle_flag.get_or_insert("--platform"); | |
| } | |
| "--icon" => { | |
| let value = flag_value(&mut arguments, "--icon", "a PNG image")?; | |
| bundle_options.icon = Some(value.into()); | |
| bundle_flag.get_or_insert("--icon"); | |
| } | |
| "--download-url" => { | |
| let value = flag_value(&mut arguments, "--download-url", "a URL")?; | |
| if !value.starts_with("https://") && !value.starts_with("http://") { | |
| return Err(format!( | |
| "`--download-url` expects an http(s) URL, not `{value}`" | |
| )); | |
| } | |
| bundle_options.download_url = Some(value); | |
| bundle_flag.get_or_insert("--download-url"); | |
| } | |
| "--out" => { | |
| let value = flag_value(&mut arguments, "--out", "a directory")?; | |
| bundle_options.out = Some(value.into()); | |
| bundle_flag.get_or_insert("--out"); | |
| } | |
| "--dir" | "-d" => match arguments.next() { | |
| Some(path) if !path.starts_with('-') => { | |
| application = named(&application, Application::Path(path.into()))?; | |
| } | |
| _ => return Err("`--dir` expects an application directory".to_owned()), | |
| }, | |
| "--watch" => watch = true, | |
| "--packaged" => packaged = true, | |
| "--dev" => development = true, | |
| "--print-spec" => print_spec = true, | |
| // A path is never mistaken for a flag, and an unknown flag is never | |
| // mistaken for a path: silently treating a mistyped `--watch` as a directory | |
| // would report a missing entry file instead of the typo. | |
| other if other.starts_with('-') => { | |
| return Err(format!("unknown flag `{other}`")); | |
| } | |
| other => application = named(&application, Application::from_argument(other))?, | |
| } | |
| } | |
| if let Some(flag) = bundle_flag | |
| && command != Some(Command::Bundle) | |
| { | |
| return Err(format!("`{flag}` is only for `bundle`")); | |
| } | |
| if (watch || development) && !matches!(command, Some(Command::Run | Command::Dev) | None) { | |
| return Err(format!( | |
| "`{}` is only for `run` and `dev`", | |
| if watch { "--watch" } else { "--dev" } | |
| )); | |
| } | |
| if packaged && command != Some(Command::Run) { | |
| return Err("`--packaged` is only for `run`".to_owned()); | |
| } | |
| match command { | |
| Some(Command::Bundle) => Ok(Invocation::Bundle(BundleArguments { | |
| application: application.unwrap_or_else(Application::current_directory), | |
| options: bundle_options, | |
| })), | |
| Some(Command::List) => match application { | |
| None => Ok(Invocation::List), | |
| Some(_) => Err("`list` takes no application".to_owned()), | |
| }, | |
| Some(Command::Agents) => Ok(Invocation::Agents(application)), | |
| // Created where the command runs, the way `cargo new` does: a bare | |
| // name is a new directory here, not an installed application. | |
| Some(Command::New) => match application { | |
| Some(Application::Installed(name)) => Ok(Invocation::New(PathBuf::from(name))), | |
| Some(Application::Path(path)) => Ok(Invocation::New(path)), | |
| _ => Err("`new` expects the directory to create".to_owned()), | |
| }, | |
| // With nothing named, `run`, `dev`, `check` and `types` take the directory the | |
| // command runs in, the way `cargo run` takes the package it is in. | |
| Some(Command::Types) => Ok(Invocation::Types( | |
| application.unwrap_or_else(Application::current_directory), | |
| )), | |
| Some(Command::Check) => Ok(Invocation::Check(CheckArguments { | |
| application: application.unwrap_or_else(Application::current_directory), | |
| print_spec, | |
| })), | |
| Some(Command::Run) => Ok(Invocation::Run(Arguments { | |
| application: application.unwrap_or_else(Application::current_directory), | |
| watch, | |
| verbose: false, | |
| development, | |
| packaged, | |
| })), | |
| // How an application being edited is run: reloading, with the debug | |
| // log. The sandbox stays as shipped unless `--dev` asks otherwise. | |
| Some(Command::Dev) => Ok(Invocation::Run(Arguments { | |
| application: application.unwrap_or_else(Application::current_directory), | |
| watch: true, | |
| verbose: true, | |
| development, | |
| packaged: false, | |
| })), | |
| // A bare `gpui-shell` runs the application packaged beside the | |
| // executable: a bundle's executable is started with no arguments at | |
| // all, so that is the one place a command cannot be required. | |
| None => match application { | |
| None => Ok(Invocation::Bare(Arguments { | |
| application: Application::Bundled, | |
| watch, | |
| verbose: false, | |
| development, | |
| packaged, | |
| })), | |
| Some(application) => Err(format!( | |
| "an application runs with `{name} run {}`", | |
| application.describe(), | |
| name = brand.name() | |
| )), | |
| }, | |
| } | |
| } | |
| /// The value after a flag that takes one. A following flag is never taken | |
| /// as the value: `--out --icon x` is a missing directory, not one named | |
| /// `--icon`. | |
| fn flag_value( | |
| arguments: &mut impl Iterator<Item = String>, | |
| flag: &str, | |
| expected: &str, | |
| ) -> Result<String, String> { | |
| match arguments.next() { | |
| Some(value) if !value.starts_with('-') => Ok(value), | |
| _ => Err(format!("`{flag}` expects {expected}")), | |
| } | |
| } | |
| /// What `check` was asked to do. | |
| #[derive(Debug, PartialEq, Eq)] | |
| struct CheckArguments { | |
| application: Application, | |
| print_spec: bool, | |
| } | |
| /// What `bundle` was asked to do. | |
| #[derive(Debug, PartialEq, Eq)] | |
| struct BundleArguments { | |
| application: Application, | |
| options: bundle::BundleOptions, | |
| } | |
| /// One line per flag, no decoration — the tone the repository's documentation | |
| /// uses everywhere else. | |
| fn help(brand: HostBrand) -> String { | |
| format!( | |
| "\ | |
| {version} | |
| Usage: {name} dev [<name> | --dir <directory>] [--dev] | |
| {name} run [<name> | --dir <directory>] [--watch] [--dev] [--packaged] | |
| {name} new <directory> | |
| {name} list | |
| {name} check [<name> | --dir <directory>] [--print-spec] | |
| {name} types [<name> | --dir <directory>] | |
| {name} agents [<name> | --dir <directory>] | |
| {name} bundle [<name> | --dir <directory>] [--platform <platform>] | |
| [--icon <png>] [--out <directory>] [--download-url <url>] | |
| Arguments: | |
| <name> An installed application: the directory of that name in | |
| {installed}. An argument containing a path | |
| separator is a path instead, as if given with --dir. | |
| `run`, `dev`, `check`, `types` and `bundle` with no application use the | |
| current directory. With no arguments at all, {name} runs the application packaged beside its | |
| executable ({bundled}), and prints this help when there is none. | |
| Commands: | |
| dev Run an application while you edit it: reload it when its | |
| sources change, and log at debug level. | |
| run Run one application in a window. | |
| new Create a blank application in a new directory, relative to | |
| where the command runs: manifest, entry module, editor types | |
| and {AGENTS}. | |
| list Print the installed applications — id, name, version and | |
| requested capabilities — without running any. | |
| agents Print the development instructions a coding agent needs to write | |
| an application for this runtime, or write them into the | |
| application's {AGENTS} (only ever replacing one it generated). | |
| types Write gpui-kit.d.ts next to the application, so an editor — or a | |
| model writing the code — sees the whole API and catches a | |
| mistyped style method before it runs. | |
| check Load the application, build its description, and materialize its | |
| eager native elements in a hidden window. Exit 0 on success or | |
| 1 on load, render, or registered-component materialization errors. | |
| Does not check layout, paint, deferred slots, nested view renders, | |
| or later interaction and asynchronous states. | |
| bundle Package the application as a desktop application. Needs | |
| {MANIFEST}. macOS: an unsigned .app. Linux: a .tar.gz, and an | |
| install.sh beside it that installs it. Both hold no runtime: | |
| they use an installed {name} {version_number} or later, or | |
| download that release. Windows: a portable .zip whose .exe is | |
| {name} {version_number} itself. | |
| Options: | |
| -d, --dir <directory> | |
| The application root, or the {ENTRY} inside it. | |
| --packaged With run, run the directory as a packaged application, the way a | |
| bundle's launcher does: it must have a manifest, is titled by | |
| its name, and no editor files are written into it. | |
| --watch With run, reload the application when its sources change. | |
| `dev` always does. | |
| --dev With run or dev, development mode: relax the sandbox, opening | |
| eval and the built-in prototypes. Capabilities are unchanged. | |
| --print-spec With check, also print the element description that was built. | |
| --platform <platform> | |
| With bundle, the platform: macos, linux or windows. Defaults to | |
| the one this runs on. | |
| --icon <png> With bundle, a square PNG icon, ideally 1024×1024. | |
| --out <directory> | |
| With bundle, where the bundle is written. Defaults to ./dist. | |
| --download-url <url> | |
| With bundle for Linux, where the archive will be published, so | |
| install.sh run on its own downloads it from there. | |
| --help Print this message and exit. | |
| --version Print the version and exit.", | |
| installed = installed::applications_directory(brand).display(), | |
| bundled = installed::BUNDLED_LOCATIONS, | |
| AGENTS = agents::AGENTS_FILE, | |
| MANIFEST = crate::plugin::MANIFEST_FILE, | |
| version_number = brand.version(), | |
| name = brand.name(), | |
| version = version(brand) | |
| ) | |
| } | |
| fn version(brand: HostBrand) -> String { | |
| format!("{} {}", brand.name(), brand.version()) | |
| } | |
| // --------------------------------------------------------------------------- | |
| // The host | |
| // --------------------------------------------------------------------------- | |
| fn run( | |
| directory: PathBuf, | |
| arguments: Arguments, | |
| components: crate::FrozenComponentRegistry, | |
| brand: HostBrand, | |
| ) { | |
| // Assets are served from the application directory, so the source has to be | |
| // installed on the `Application` before the loop starts. Resolving the root | |
| // here rather than inside the loop is what makes that possible; a path that | |
| // does not resolve falls back to the argument and fails later with the | |
| // message that explains why. | |
| let asset_root = | |
| crate::resolve_app_root(&directory, ENTRY).unwrap_or_else(|_| directory.clone()); | |
| gpui_platform::application() | |
| .with_assets(AppAssets::new(asset_root)) | |
| .run(move |cx| { | |
| crate::init_with_components(cx, &components); | |
| install_palette(cx); | |
| install_quit(cx); | |
| if arguments.is_development() { | |
| // Before `ShellRuntime::new`: the policy is read when the | |
| // context is created. | |
| crate::set_development_mode(true); | |
| tracing::debug!("development mode: eval and the built-in prototypes are open"); | |
| } | |
| // Read before the catalog is handed to the runtime, which takes it. | |
| let window_opener = components.window_opener(); | |
| let runtime = match ShellRuntime::new_with_components(cx, components) { | |
| Ok(runtime) => runtime, | |
| Err(error) => { | |
| eprintln!("failed to start the script runtime: {error}"); | |
| std::process::exit(1); | |
| } | |
| }; | |
| // Local development hook: `GPUI_SHELL_METRICS_LOG` prints the | |
| // runtime's script counters to stderr once a second. | |
| if std::env::var_os("GPUI_SHELL_METRICS_LOG").is_some() { | |
| let metrics_runtime = runtime.clone(); | |
| cx.spawn(async move |cx| { | |
| let mut last = metrics_runtime.read_metrics(); | |
| loop { | |
| cx.background_executor() | |
| .timer(std::time::Duration::from_secs(1)) | |
| .await; | |
| let now = metrics_runtime.read_metrics(); | |
| let delta = now.since(&last); | |
| last = now; | |
| eprintln!( | |
| "[metrics] renders={} render_ms_total={:.3} mean_render_ms={:.3} slowest_ms={:.3} frame_calls={} materializations={} materialize_ms_total={:.3} mean_materialize_ms={:.3}", | |
| delta.script_renders(), | |
| delta.script_render_time().as_secs_f64() * 1000., | |
| delta.mean_script_render().as_secs_f64() * 1000., | |
| delta.slowest_script_render().as_secs_f64() * 1000., | |
| delta.frame_script_calls(), | |
| delta.materializations(), | |
| delta.materialize_time().as_secs_f64() * 1000., | |
| delta.mean_materialize().as_secs_f64() * 1000., | |
| ); | |
| } | |
| }) | |
| .detach(); | |
| } | |
| // What `process.exit(code)` means here. The runtime never decides | |
| // this — one plugin must not be able to end an application somebody | |
| // is working in — but this host *is* the process, so ending it is | |
| // the honest answer. An embedded host installs something else: | |
| // closing a panel, closing a window, or refusing. | |
| crate::on_exit_request(|request, _, _| { | |
| let code = request.code(); | |
| tracing::info!("the application asked to exit with {code}"); | |
| std::process::exit(code); | |
| }); | |
| // Resolving here rather than leaving it to `load_app` gives the window | |
| // title and the watcher the real application root even when the command | |
| // line named `main.js`. A path that does not resolve is not reported | |
| // twice: the load below fails with the message that explains why. | |
| let root = | |
| crate::resolve_app_root(&directory, ENTRY).unwrap_or_else(|_| directory.clone()); | |
| // A view's `init` can create GPUI entities, so the facade loads the | |
| // application inside the window builder. | |
| let manifest = read_local_manifest(&root); | |
| if let Ok(manifest) = &manifest { | |
| grant_local_access(&root, manifest.as_ref()); | |
| } | |
| // The declarations describe the runtime that is about to run this | |
| // script, which is the only version worth editing against. Doing it | |
| // here rather than asking the author to remember a command is what | |
| // keeps a stale `gpui-kit.d.ts` from being possible at all. | |
| // | |
| // Every launch rather than development mode only: this binary runs an | |
| // application from its source directory, which is where somebody is | |
| // editing. Nothing is written when the file already matches, and a | |
| // directory that refuses the write is logged rather than fatal. | |
| let window_size = manifest | |
| .as_ref() | |
| .ok() | |
| .and_then(|manifest| manifest.as_ref()) | |
| .and_then(|manifest| manifest.window()); | |
| let identity = manifest | |
| .as_ref() | |
| .ok() | |
| .and_then(|manifest| manifest.as_ref()) | |
| .map(|manifest| (manifest.id().to_owned(), manifest.name().to_owned())); | |
| let packaged = arguments.is_packaged(); | |
| // A packaged application is not a directory anybody edits, and its | |
| // bundle may be signed or installed read-only. | |
| runtime.set_editor_files(!packaged); | |
| let manifest_error = manifest.err(); | |
| let built: Rc<RefCell<Option<Entity<ShellRoot>>>> = Rc::new(RefCell::new(None)); | |
| let sink = built.clone(); | |
| let builder_runtime = runtime.clone(); | |
| let application_root = root.clone(); | |
| let build = move |window: &mut Window, cx: &mut App| -> Entity<ShellRoot> { | |
| let loaded = match &manifest_error { | |
| Some(message) => Err(anyhow::anyhow!(message.clone())), | |
| None => builder_runtime.try_load(&application_root, window, cx), | |
| }; | |
| match loaded { | |
| Ok(root) => { | |
| *sink.borrow_mut() = Some(root.clone()); | |
| root | |
| } | |
| Err(error) => { | |
| eprintln!("{error:#}"); | |
| let content = cx.new(|_| LoadFailure(format!("{error:#}"))); | |
| cx.new(|cx| ShellRoot::new(content.into(), window, cx)) | |
| } | |
| } | |
| }; | |
| // A catalog whose components require a particular window root opens | |
| // the window itself; see `ComponentWindowOpener`. Everything after | |
| // this point only wants a `&mut Window`, so the handle is untyped | |
| // either way. | |
| let options = window_options(&root, brand, window_size, identity, packaged, cx); | |
| let window = match window_opener { | |
| Some(open) => { | |
| let mut build = |window: &mut Window, cx: &mut App| build(window, cx).into(); | |
| open(cx, options, &mut build).expect("failed to open window") | |
| } | |
| None => AnyWindowHandle::from( | |
| cx.open_window(options, build) | |
| .expect("failed to open window"), | |
| ), | |
| }; | |
| // Launched from a terminal, a GPUI process opens its window behind | |
| // whatever was in front — the terminal itself — and an application | |
| // that does not come forward looks like one that has not started. | |
| // This command *is* the application, so it brings itself forward | |
| // the way a double-clicked application would. | |
| // | |
| // Deferred to the next turn of the loop: asked for while the | |
| // application is still finishing its launch, the request is dropped. | |
| // | |
| // A spawned task rather than `cx.defer`: a deferred effect runs at | |
| // the end of whatever update flushes next, and on Wayland that can | |
| // be a platform callback. The desktop portal's button-layout and | |
| // appearance events call into the window while GPUI still holds the | |
| // Wayland client state borrowed, so activating from inside one | |
| // panics on that borrow, and the window dropped while unwinding | |
| // panics again and aborts. Whether the portal answers before the | |
| // first flush is a race, which is why only some launches crashed. | |
| // A task is polled from the executor's own event source, outside | |
| // every platform callback. | |
| cx.spawn(async move |cx| { | |
| cx.update(|cx| { | |
| // Local development hook: open without taking focus. | |
| if std::env::var_os("GPUI_SHELL_PASSIVE").is_none() { | |
| cx.activate(true); | |
| let _ = window.update(cx, |_, window, _| window.activate_window()); | |
| } | |
| }); | |
| }) | |
| .detach(); | |
| let loaded = built.borrow_mut().take().ok_or(()); | |
| if arguments.is_watching() { | |
| match loaded { | |
| Ok(root) => watch_sources(runtime, root, window, cx), | |
| // A failed first load never produced a mounted application. | |
| // Saying so is better than a `--watch` that looks armed and | |
| // never fires. | |
| Err(()) => eprintln!( | |
| "--watch is inactive: the application did not load, so there is \ | |
| no view to reload into. Fix the declared entry and start gpui-shell again." | |
| ), | |
| } | |
| } | |
| }); | |
| } | |
| /// Checks the application description and eager native materialization in a | |
| /// hidden window. This does not draw or validate a complete frame. | |
| fn check( | |
| directory: PathBuf, | |
| arguments: CheckArguments, | |
| components: crate::FrozenComponentRegistry, | |
| ) -> ! { | |
| // Assets are served from the application directory, so the source has to be | |
| // installed on the `Application` before the loop starts. Resolving the root | |
| // here rather than inside the loop is what makes that possible; a path that | |
| // does not resolve falls back to the argument and fails later with the | |
| // message that explains why. | |
| let asset_root = | |
| crate::resolve_app_root(&directory, ENTRY).unwrap_or_else(|_| directory.clone()); | |
| gpui_platform::application() | |
| .with_assets(AppAssets::new(asset_root)) | |
| .run(move |cx| { | |
| let mut outcome = CheckOutcome::default(); | |
| crate::init_with_components(cx, &components); | |
| install_palette(cx); | |
| // Read before the catalog is handed to the runtime, which takes it. | |
| let window_opener = components.window_opener(); | |
| let runtime = match ShellRuntime::new_with_components(cx, components) { | |
| Ok(runtime) => runtime, | |
| Err(error) => { | |
| outcome.fail(format!("{error:#}")); | |
| outcome.exit(&directory); | |
| } | |
| }; | |
| let root = match crate::resolve_app_root(&directory, ENTRY) { | |
| Ok(root) => root, | |
| Err(error) => { | |
| outcome.fail(format!("{error:#}")); | |
| outcome.exit(&directory); | |
| } | |
| }; | |
| let manifest = match read_local_manifest(&root) { | |
| Ok(manifest) => manifest, | |
| Err(error) => { | |
| outcome.fail(error); | |
| outcome.exit(&directory); | |
| } | |
| }; | |
| grant_local_access(&root, manifest.as_ref()); | |
| // Finish opening before checking so catalog-owned roots and window | |
| // facilities are installed when native materializers use them. | |
| let build = |_: &mut Window, cx: &mut App| cx.new(|_| gpui::Empty); | |
| let options = hidden_window_options(cx); | |
| let opened = match window_opener { | |
| Some(open) => { | |
| let mut build = |window: &mut Window, cx: &mut App| build(window, cx).into(); | |
| open(cx, options, &mut build) | |
| } | |
| None => cx.open_window(options, build).map(Into::into), | |
| }; | |
| let result = opened.and_then(|window| { | |
| window.update(cx, |_, window, cx| runtime.check(&root, window, cx))? | |
| }); | |
| match result { | |
| Ok(spec) => outcome.succeed(spec, arguments.print_spec), | |
| Err(error) => outcome.fail(format!("{error:#}")), | |
| } | |
| // Reporting and exiting happen here rather than after `run` returns: | |
| // an application loop that has opened a window does not unwind just | |
| // because nothing is shown, and a check that never terminates is worse | |
| // than one that reports nothing. | |
| outcome.exit(&directory); | |
| }); | |
| unreachable!("the check exits from inside the application loop") | |
| } | |
| /// A window is needed to render, but nothing should appear on screen for a | |
| /// check: it runs in editors, in CI, and in an agent's loop. | |
| fn hidden_window_options(cx: &App) -> WindowOptions { | |
| WindowOptions { | |
| show: false, | |
| focus: false, | |
| window_bounds: Some(WindowBounds::Windowed(Bounds::centered( | |
| None, | |
| size(px(320.), px(240.)), | |
| cx, | |
| ))), | |
| ..Default::default() | |
| } | |
| } | |
| /// What the check found. Defaults to "nothing ran", which is itself a failure: | |
| /// a check that silently does nothing must not report success. | |
| #[derive(Default)] | |
| struct CheckOutcome { | |
| error: Option<String>, | |
| spec: Option<String>, | |
| ran: bool, | |
| } | |
| impl CheckOutcome { | |
| // Quitting GPUI can terminate the native process with status zero before | |
| // the application loop returns, including failures before a window opens. | |
| fn exit(&self, directory: &Path) -> ! { | |
| self.report(directory); | |
| std::process::exit(self.status()); | |
| } | |
| fn fail(&mut self, error: String) { | |
| self.ran = true; | |
| self.error = Some(error); | |
| } | |
| fn succeed(&mut self, spec: String, keep: bool) { | |
| self.ran = true; | |
| if keep { | |
| self.spec = Some(spec); | |
| } | |
| } | |
| fn status(&self) -> i32 { | |
| if self.ran && self.error.is_none() { | |
| 0 | |
| } else { | |
| 1 | |
| } | |
| } | |
| fn report(&self, directory: &Path) { | |
| match &self.error { | |
| Some(error) => { | |
| eprintln!("{error}"); | |
| eprintln!("\ncheck failed: {}", directory.display()); | |
| } | |
| None if !self.ran => { | |
| eprintln!("check did not run: the window never opened"); | |
| } | |
| None => { | |
| if let Some(spec) = &self.spec { | |
| println!("{spec}"); | |
| } | |
| println!("check passed: {}", directory.display()); | |
| } | |
| } | |
| } | |
| } | |
| /// Installs the storage location and the capability grant for a local run. | |
| /// | |
| /// An application with a manifest keeps its data under the manifest `id`, so | |
| /// running it from its source directory, installing it, moving it or packaging | |
| /// it all reach the same data — and the path an author shows a user is one they | |
| /// can predict. | |
| fn grant_local_access(root: &Path, manifest: Option<&crate::plugin::PluginManifest>) { | |
| // A directory without a manifest has no name of its own, so its path is its | |
| // identity: the same directory always reaches the same data. | |
| let id = match manifest { | |
| Some(manifest) => manifest.id().to_owned(), | |
| None => crate::bundle_id_for_path(root), | |
| }; | |
| let store = match crate::set_bundle_id(&id) { | |
| Ok(store) => store, | |
| Err(error) => { | |
| tracing::warn!("storage is unavailable: {error}"); | |
| return; | |
| } | |
| }; | |
| if let Err(error) = std::fs::create_dir_all(&store) { | |
| tracing::warn!( | |
| "storage is unavailable: cannot create {}: {error}", | |
| store.display() | |
| ); | |
| return; | |
| } | |
| crate::set_capabilities(local_capabilities(root, &store, manifest)); | |
| tracing::debug!("storage: {}", store.display()); | |
| } | |
| gpui::actions!(gpui_shell, [Quit]); | |
| /// The platform's quit shortcut: ⌘Q on macOS, Alt-F4 elsewhere. | |
| /// | |
| /// This command is the whole application, so quitting it is its to answer — | |
| /// the same reason `process.exit` ends the process here. A script that binds | |
| /// the same keystroke itself still wins, because its binding is installed | |
| /// later and GPUI prefers the most recent one. | |
| fn install_quit(cx: &mut App) { | |
| cx.on_action(|_: &Quit, cx| cx.quit()); | |
| cx.bind_keys([gpui::KeyBinding::new(quit_keystroke(), Quit, None)]); | |
| } | |
| const fn quit_keystroke() -> &'static str { | |
| if cfg!(target_os = "macos") { | |
| "cmd-q" | |
| } else { | |
| "alt-f4" | |
| } | |
| } | |
| /// The palette this command ships. | |
| /// | |
| /// It lives with the binary rather than with the runtime because a palette is a | |
| /// product decision: `gpui-shell` the command is an application and gets to | |
| /// have a look, while `gpui_shell` the library must not decide how everything | |
| /// built on it appears. An embedder installs its own the same way. | |
| const PALETTE: &str = include_str!("default-tokens.json"); | |
| #[derive(serde::Deserialize)] | |
| struct CommandThemes { | |
| light: CommandTheme, | |
| } | |
| #[derive(serde::Deserialize)] | |
| struct CommandTheme { | |
| colors: gpui_base::ColorTokens, | |
| #[serde(default)] | |
| radius: gpui_base::RadiusTokens, | |
| #[serde(default)] | |
| spacing: gpui_base::SpacingTokens, | |
| #[serde(default)] | |
| typography: gpui_base::TypographyTokens, | |
| #[serde(default)] | |
| shadow: gpui_base::ShadowTokens, | |
| } | |
| fn install_palette(cx: &mut App) { | |
| match serde_json::from_str::<CommandThemes>(PALETTE) { | |
| Ok(themes) => { | |
| gpui_base::Theme::global_mut(cx).tokens = gpui_base::SemanticThemeTokens { | |
| colors: themes.light.colors, | |
| radius: themes.light.radius, | |
| spacing: themes.light.spacing, | |
| typography: themes.light.typography, | |
| shadow: themes.light.shadow, | |
| }; | |
| } | |
| // The file is compiled in, so a parse error is a build-time mistake | |
| // that reached a user; the neutral fallback keeps the window legible. | |
| Err(error) => tracing::error!("shipped palette did not parse: {error}"), | |
| } | |
| } | |
| /// Reads and validates inert application metadata before any entry code runs. | |
| fn read_local_manifest(root: &Path) -> Result<Option<crate::plugin::PluginManifest>, String> { | |
| let path = root.join(crate::plugin::MANIFEST_FILE); | |
| if !path.exists() { | |
| return Ok(None); | |
| } | |
| crate::plugin::PluginManifest::read(root) | |
| .map(Some) | |
| .map_err(|error| format!("invalid {}: {error}", path.display())) | |
| } | |
| /// What a locally run application is allowed to do. | |
| /// | |
| /// Running a directory from the command line is an explicit act of trust, the | |
| /// same as `node app.js`: the application may read its own sources and use its | |
| /// own storage, and whatever else its manifest asks for. Without a manifest it | |
| /// is deliberately narrower than "everything" — no network, no process | |
| /// execution, no clipboard, and no filesystem access outside those two | |
| /// directories. | |
| /// | |
| /// The two directories are *added* to the manifest's roots. Replacing them | |
| /// would silently drop every other directory the manifest names, such as a | |
| /// folder under `${homeDir}` the application exists to manage. | |
| fn local_capabilities( | |
| root: &Path, | |
| store: &Path, | |
| manifest: Option<&crate::plugin::PluginManifest>, | |
| ) -> Capabilities { | |
| let capabilities = if let Some(manifest) = manifest { | |
| manifest.capabilities(root, store) | |
| } else { | |
| // Preserve the original local-app experience for source directories | |
| // that are not plugin bundles. Their private store is the only ambient | |
| // state they receive; network and clipboard stay denied. | |
| Capabilities::new().storage(true) | |
| }; | |
| capabilities | |
| .add_read_roots([root.to_path_buf(), store.to_path_buf()]) | |
| .add_write_roots([store.to_path_buf()]) | |
| .exit(true) | |
| } | |
| /// The window an application opens in. `window_size` is the manifest's | |
| /// `window`; without one the host's default applies. | |
| /// | |
| /// `identity` is the manifest's `id` and `name`. The `id` is the window's | |
| /// application id, which is how a Linux desktop matches the window to the | |
| /// `<id>.desktop` entry `bundle` installs. A packaged application is titled | |
| /// by its `name`: its directory is always `app`, which names nothing. | |
| fn window_options( | |
| root: &Path, | |
| brand: HostBrand, | |
| window_size: Option<crate::plugin::WindowSize>, | |
| identity: Option<(String, String)>, | |
| packaged: bool, | |
| cx: &App, | |
| ) -> WindowOptions { | |
| let (app_id, name) = identity.unzip(); | |
| let title = match name { | |
| Some(name) if packaged => name, | |
| _ => root | |
| .file_name() | |
| .map(|name| format!("{} — {}", name.to_string_lossy(), brand.name())) | |
| .unwrap_or_else(|| brand.name().to_owned()), | |
| }; | |
| WindowOptions { | |
| window_bounds: Some(WindowBounds::Windowed(Bounds::centered( | |
| None, | |
| window_size | |
| .map(|window| size(px(window.width()), px(window.height()))) | |
| .unwrap_or(size(px(880.), px(720.))), | |
| cx, | |
| ))), | |
| // A window with no title is unidentifiable in a switcher or a tiling | |
| // layout, which is how this one first reached a user. | |
| titlebar: Some(TitlebarOptions { | |
| title: Some(title.into()), | |
| ..Default::default() | |
| }), | |
| app_id, | |
| // Local development hook, as above: a passive window does not take focus. | |
| focus: std::env::var_os("GPUI_SHELL_PASSIVE").is_none(), | |
| ..Default::default() | |
| } | |
| } | |
| /// Polls the application directory and reloads the view when it changes. | |
| /// | |
| /// The loop, the failure toast and the exit conditions all live in | |
| /// `ShellRuntime` — the binary used to carry its own copy of them, which | |
| /// meant an embedded host got a quieter hot-reload than the CLI for no reason | |
| /// anyone had decided. `--watch` is the one thing that is this binary's to | |
| /// answer, because the person running it is the person editing; an embedded | |
| /// host has no such flag and takes the debug build as the answer. | |
| fn watch_sources( | |
| runtime: Rc<ShellRuntime>, | |
| root: Entity<ShellRoot>, | |
| window: AnyWindowHandle, | |
| cx: &mut App, | |
| ) { | |
| // Untyped on purpose. A typed `WindowHandle::update` leases the root view | |
| // for the length of the closure, and `watch` reads that same entity to find | |
| // the mounted application — a second borrow GPUI answers with a panic. All | |
| // this call wants from the window is a `&mut Window`. | |
| let started = window.update(cx, |_, window, cx| runtime.watch(&root, window, cx)); | |
| match started { | |
| // Detached on purpose: this watcher lasts as long as the window, and | |
| // the window is the whole application. | |
| Ok(Ok(watch)) => watch.forget(), | |
| Ok(Err(error)) => tracing::error!("cannot watch for changes: {error}"), | |
| Err(error) => tracing::error!("cannot watch for changes: {error}"), | |
| } | |
| } | |
| /// What the window shows when the application could not be loaded. | |
| /// | |
| /// A failed load still opens a window: the error belongs on screen, not only in | |
| /// a terminal the user may not be watching. | |
| struct LoadFailure(String); | |
| impl Render for LoadFailure { | |
| fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { | |
| crate::failure_surface( | |
| "This application could not be loaded", | |
| &self.0, | |
| "An application directory needs main.js, or the entry its gpui-shell.json \ | |
| names, default-exporting a class that extends View.", | |
| window, | |
| cx, | |
| ) | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Logging | |
| // --------------------------------------------------------------------------- | |
| /// Sends `tracing` events to stderr. | |
| /// | |
| /// The crate depends on `tracing` but not on `tracing-subscriber`, so without | |
| /// this every `tracing::error!` the runtime emits — a throwing event handler, an | |
| /// unhandled promise rejection, an overlay opened from the wrong phase — is | |
| /// dropped on the floor and the author sees a view that simply stopped | |
| /// responding. One line per event on stderr is the whole requirement here; | |
| /// filtering, spans, and formatting are what `tracing-subscriber` is for, and | |
| /// the day this binary wants any of them it should take that dependency rather | |
| /// than grow the subscriber below. | |
| fn install_log_sink(max_level: Level) { | |
| // An error means something already installed a subscriber, which is a | |
| // better sink than this one by definition. | |
| let _ = tracing::subscriber::set_global_default(StderrSubscriber { max_level }); | |
| } | |
| struct StderrSubscriber { | |
| max_level: Level, | |
| } | |
| impl Subscriber for StderrSubscriber { | |
| fn enabled(&self, metadata: &Metadata<'_>) -> bool { | |
| *metadata.level() <= self.max_level | |
| } | |
| /// Lets `tracing` skip the callsite entirely instead of asking on every | |
| /// event, which is what keeps a disabled `debug!` in a render path free. | |
| fn max_level_hint(&self) -> Option<LevelFilter> { | |
| Some(LevelFilter::from_level(self.max_level)) | |
| } | |
| /// Spans are not recorded, so every span shares one id. A subscriber that | |
| /// prints events and nothing else has no use for span identity. | |
| fn new_span(&self, _: &span::Attributes<'_>) -> span::Id { | |
| span::Id::from_u64(1) | |
| } | |
| fn record(&self, _: &span::Id, _: &span::Record<'_>) {} | |
| fn record_follows_from(&self, _: &span::Id, _: &span::Id) {} | |
| fn event(&self, event: &Event<'_>) { | |
| let mut fields = EventFields::default(); | |
| event.record(&mut fields); | |
| eprintln!( | |
| "{:<5} {}: {}", | |
| event.metadata().level(), | |
| event.metadata().target(), | |
| fields.0 | |
| ); | |
| } | |
| fn enter(&self, _: &span::Id) {} | |
| fn exit(&self, _: &span::Id) {} | |
| } | |
| /// One event's fields, flattened to a line. | |
| #[derive(Default)] | |
| struct EventFields(String); | |
| impl Visit for EventFields { | |
| fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { | |
| if !self.0.is_empty() { | |
| self.0.push(' '); | |
| } | |
| // The `message` field is the event's own text and is printed bare; | |
| // everything else is structured data and keeps its name. | |
| let _ = if field.name() == "message" { | |
| write!(self.0, "{value:?}") | |
| } else { | |
| write!(self.0, "{}={value:?}", field.name()) | |
| }; | |
| } | |
| } | |
| #[cfg(test)] | |
| mod tests { | |
| use super::*; | |
| fn parse_args(arguments: &[&str]) -> Result<Invocation, String> { | |
| parse( | |
| arguments.iter().map(|argument| (*argument).to_string()), | |
| HostBrand::gpui_shell(), | |
| ) | |
| } | |
| fn run_args(arguments: &[&str]) -> Arguments { | |
| match parse_args(arguments).expect("the arguments should parse") { | |
| Invocation::Run(arguments) => arguments, | |
| other => panic!("expected a run, got {other:?}"), | |
| } | |
| } | |
| fn check_args(arguments: &[&str]) -> CheckArguments { | |
| match parse_args(arguments).expect("the arguments should parse") { | |
| Invocation::Check(arguments) => arguments, | |
| other => panic!("expected a check, got {other:?}"), | |
| } | |
| } | |
| #[test] | |
| fn check_takes_a_directory_and_an_optional_spec_dump() { | |
| let arguments = check_args(&["check", "examples/js_todolist"]); | |
| assert_eq!( | |
| arguments.application, | |
| Application::Path(PathBuf::from("examples/js_todolist")) | |
| ); | |
| assert!(!arguments.print_spec); | |
| let arguments = check_args(&["check", "examples/js_todolist", "--print-spec"]); | |
| assert!(arguments.print_spec); | |
| } | |
| #[test] | |
| fn check_is_a_command_not_a_directory() { | |
| // A directory literally named `check` would be ambiguous; the command | |
| // wins, which is why the error for a missing path has to be clear. | |
| assert_eq!( | |
| check_args(&["check"]).application, | |
| Application::Path(std::env::current_dir().unwrap()), | |
| "`check` alone checks the application in the current directory" | |
| ); | |
| } | |
| fn bundle_args(arguments: &[&str]) -> BundleArguments { | |
| match parse_args(arguments).expect("the arguments should parse") { | |
| Invocation::Bundle(arguments) => arguments, | |
| other => panic!("expected a bundle, got {other:?}"), | |
| } | |
| } | |
| #[test] | |
| fn bundle_takes_a_directory_and_its_own_flags() { | |
| let arguments = bundle_args(&[ | |
| "bundle", | |
| "-d", | |
| "clock", | |
| "--platform", | |
| "linux", | |
| "--icon", | |
| "icon.png", | |
| "--out", | |
| "out", | |
| "--download-url", | |
| "https://example.com/clock", | |
| ]); | |
| assert_eq!(arguments.application, Application::Path("clock".into())); | |
| assert_eq!( | |
| arguments.options, | |
| bundle::BundleOptions { | |
| platform: Some(bundle::Platform::Linux), | |
| icon: Some("icon.png".into()), | |
| out: Some("out".into()), | |
| download_url: Some("https://example.com/clock".into()), | |
| } | |
| ); | |
| assert!(parse_args(&["bundle", "--download-url", "example.com"]).is_err()); | |
| assert!(parse_args(&["run", "--download-url", "https://example.com"]).is_err()); | |
| let arguments = bundle_args(&["bundle"]); | |
| assert_eq!( | |
| arguments.application, | |
| Application::Path(std::env::current_dir().unwrap()), | |
| "`bundle` alone bundles the application in the current directory" | |
| ); | |
| assert_eq!(arguments.options, bundle::BundleOptions::default()); | |
| } | |
| #[test] | |
| fn dev_is_run_with_development_mode() { | |
| let arguments = run_args(&["dev", "-d", "app"]); | |
| assert_eq!(arguments.application, Application::Path("app".into())); | |
| assert!(arguments.is_watching() && arguments.is_verbose()); | |
| assert!(!arguments.is_development(), "the sandbox stays as shipped"); | |
| assert!(run_args(&["dev", "app", "--dev"]).is_development()); | |
| assert!(!arguments.is_packaged()); | |
| assert_eq!( | |
| run_args(&["dev"]).application, | |
| Application::Path(std::env::current_dir().unwrap()), | |
| "`dev` alone runs the application in the current directory" | |
| ); | |
| let error = parse_args(&["check", "app", "--watch"]).expect_err("only `run` watches"); | |
| assert!(error.contains("only for `run` and `dev`"), "{error}"); | |
| assert!(parse_args(&["dev", "app", "--packaged"]).is_err()); | |
| } | |
| #[test] | |
| fn packaged_is_a_run_flag() { | |
| let arguments = run_args(&["run", "-d", "app", "--packaged"]); | |
| assert!(arguments.is_packaged()); | |
| assert!(!run_args(&["run", "-d", "app"]).is_packaged()); | |
| let error = parse_args(&["check", "app", "--packaged"]).expect_err("only `run` packages"); | |
| assert!(error.contains("only for `run`"), "{error}"); | |
| } | |
| #[test] | |
| fn bundle_flags_are_checked() { | |
| let error = parse_args(&["run", "./app", "--platform", "linux"]) | |
| .expect_err("only `bundle` has a platform"); | |
| assert!(error.contains("only for `bundle`"), "{error}"); | |
| let error = parse_args(&["bundle", "--platform", "beos"]).expect_err("an unknown platform"); | |
| assert!(error.contains("macos, linux or windows"), "{error}"); | |
| let error = | |
| parse_args(&["bundle", "--out", "--icon", "x.png"]).expect_err("a missing value"); | |
| assert!(error.contains("`--out` expects"), "{error}"); | |
| } | |
| #[test] | |
| fn a_bare_directory_runs_without_watching() { | |
| let arguments = run_args(&["run", "examples/js_todolist"]); | |
| assert_eq!( | |
| arguments.application, | |
| Application::Path(PathBuf::from("examples/js_todolist")) | |
| ); | |
| assert!(!arguments.is_watching()); | |
| assert!(!arguments.is_development()); | |
| } | |
| #[test] | |
| fn watch_is_accepted_on_either_side_of_the_directory() { | |
| assert!(run_args(&["run", "./app", "--watch"]).is_watching()); | |
| assert!(run_args(&["--watch", "run", "./app"]).is_watching()); | |
| } | |
| #[test] | |
| fn the_dev_flag_only_relaxes_the_sandbox() { | |
| let arguments = run_args(&["run", "./app", "--dev"]); | |
| assert!(arguments.is_development()); | |
| assert!( | |
| !arguments.is_watching(), | |
| "`dev` is the command that reloads" | |
| ); | |
| assert!(!arguments.is_verbose()); | |
| } | |
| #[test] | |
| fn an_unknown_flag_is_reported_rather_than_taken_as_a_path() { | |
| let error = | |
| parse_args(&["run", "./app", "--wtach"]).expect_err("a typo is not a directory"); | |
| assert!(error.contains("--wtach"), "the message names the flag"); | |
| } | |
| #[test] | |
| fn a_second_directory_is_reported() { | |
| let error = parse_args(&["run", "./app", "other"]).expect_err("only one application runs"); | |
| assert!(error.contains("other")); | |
| } | |
| #[test] | |
| fn a_bare_name_runs_an_installed_application_and_a_path_runs_a_directory() { | |
| assert_eq!( | |
| run_args(&["run", "clock"]).application, | |
| Application::Installed("clock".to_owned()) | |
| ); | |
| assert_eq!( | |
| run_args(&["run", "--dir", "clock"]).application, | |
| Application::Path(PathBuf::from("clock")) | |
| ); | |
| assert_eq!( | |
| run_args(&["run", "-d", "clock"]).application, | |
| Application::Path(PathBuf::from("clock")) | |
| ); | |
| assert_eq!( | |
| run_args(&["run", "./clock", "--watch"]).application, | |
| Application::Path(PathBuf::from("./clock")) | |
| ); | |
| assert_eq!( | |
| check_args(&["check", "clock"]).application, | |
| Application::Installed("clock".to_owned()) | |
| ); | |
| assert!( | |
| parse_args(&["--dir"]).is_err(), | |
| "the flag needs a directory" | |
| ); | |
| assert!(parse_args(&["--dir", "--watch"]).is_err()); | |
| assert!(parse_args(&["run", "clock", "--dir", "other"]).is_err()); | |
| } | |
| #[test] | |
| fn an_application_needs_the_run_command() { | |
| let error = parse_args(&["clock"]).expect_err("a bare name is not a command"); | |
| assert!(error.contains("gpui-shell run clock"), "{error}"); | |
| let error = parse_args(&["-d", "app"]).expect_err("a bare directory is not a command"); | |
| assert!(error.contains("gpui-shell run -d app"), "{error}"); | |
| assert_eq!( | |
| run_args(&["run"]).application, | |
| Application::Path(std::env::current_dir().unwrap()), | |
| "`run` alone runs the application in the current directory" | |
| ); | |
| } | |
| #[test] | |
| fn no_command_is_the_packaged_application_or_the_help() { | |
| let Ok(Invocation::Bare(arguments)) = parse_args(&["--dev"]) else { | |
| panic!("no command is decided when the command runs"); | |
| }; | |
| assert_eq!(arguments.application, Application::Bundled); | |
| assert!(matches!(parse_args(&[]), Ok(Invocation::Bare(_)))); | |
| } | |
| #[test] | |
| fn new_takes_the_directory_to_create() { | |
| assert_eq!( | |
| parse_args(&["new", "clock"]).unwrap(), | |
| Invocation::New(PathBuf::from("clock")), | |
| "a bare name is a directory here, the way `cargo new` reads it" | |
| ); | |
| assert_eq!( | |
| parse_args(&["new", "-d", "apps/clock"]).unwrap(), | |
| Invocation::New(PathBuf::from("apps/clock")) | |
| ); | |
| assert!(parse_args(&["new"]).is_err()); | |
| } | |
| #[test] | |
| fn list_takes_no_application() { | |
| assert_eq!(parse_args(&["list"]).unwrap(), Invocation::List); | |
| assert!(parse_args(&["list", "clock"]).is_err()); | |
| } | |
| #[test] | |
| fn agents_prints_or_writes_into_one_application() { | |
| assert_eq!(parse_args(&["agents"]).unwrap(), Invocation::Agents(None)); | |
| assert_eq!( | |
| parse_args(&["agents", "--dir", "app"]).unwrap(), | |
| Invocation::Agents(Some(Application::Path(PathBuf::from("app")))) | |
| ); | |
| assert!(parse_args(&["agents", "app", "other"]).is_err()); | |
| } | |
| #[test] | |
| fn help_and_version_answer_before_anything_else() { | |
| // Reachable even when the rest of the line is unusable, which is when a | |
| // caller most needs to be told what the accepted arguments are. | |
| for arguments in [&["--help"][..], &["--nope", "--help"][..]] { | |
| assert!(matches!(parse_args(arguments), Ok(Invocation::Print(_)))); | |
| } | |
| let Ok(Invocation::Print(text)) = parse_args(&["--version"]) else { | |
| panic!("--version prints"); | |
| }; | |
| assert!(text.contains(env!("CARGO_PKG_VERSION"))); | |
| } | |
| #[test] | |
| fn help_lists_every_flag() { | |
| let help = help(HostBrand::gpui_shell()); | |
| for flag in ["run", "--dir", "--watch", "--dev", "--help", "--version"] { | |
| assert!(help.contains(flag), "`{flag}` is missing from the help"); | |
| } | |
| } | |
| #[test] | |
| fn a_local_application_manifest_grants_its_declared_network_capabilities() { | |
| let root = std::env::temp_dir().join(format!( | |
| "gpui-shell-local-manifest-current-{}", | |
| std::process::id() | |
| )); | |
| let data = root.join("data"); | |
| std::fs::create_dir_all(&root).expect("temporary app directory"); | |
| std::fs::write( | |
| root.join("gpui-shell.json"), | |
| r#"{ | |
| "id": "com.example.market", | |
| "name": "Market", | |
| "version": "1.0.0", | |
| "entry": "main.js", | |
| "capabilities": { | |
| "network": { "hosts": ["quotes.example.com"] }, | |
| "storage": true, | |
| "clipboard": { "write": true } | |
| } | |
| }"#, | |
| ) | |
| .expect("manifest"); | |
| let manifest = read_local_manifest(&root) | |
| .expect("valid manifest") | |
| .expect("present manifest"); | |
| let capabilities = local_capabilities(&root, &data, Some(&manifest)); | |
| assert!(capabilities.may_reach("quotes.example.com")); | |
| assert!(capabilities.has_storage()); | |
| assert!(capabilities.is_clipboard_writable()); | |
| assert!(!capabilities.is_clipboard_readable()); | |
| assert!(!capabilities.may_reach("trade.example.com")); | |
| let _ = std::fs::remove_dir_all(root); | |
| } | |
| #[test] | |
| fn a_local_application_keeps_the_directories_its_manifest_grants() { | |
| let root = std::env::temp_dir().join(format!( | |
| "gpui-shell-local-manifest-roots-{}", | |
| std::process::id() | |
| )); | |
| let data = root.join("data"); | |
| std::fs::create_dir_all(&root).expect("temporary app directory"); | |
| std::fs::write( | |
| root.join("gpui-shell.json"), | |
| r#"{ | |
| "id": "com.example.skills", | |
| "name": "Skills", | |
| "entry": "main.js", | |
| "capabilities": { | |
| "fs": { | |
| "read": ["/srv/shared", "${pluginDir}"], | |
| "write": ["/srv/shared/out"] | |
| } | |
| } | |
| }"#, | |
| ) | |
| .expect("manifest"); | |
| let manifest = read_local_manifest(&root) | |
| .expect("valid manifest") | |
| .expect("present manifest"); | |
| let summary = local_capabilities(&root, &data, Some(&manifest)).summary(); | |
| let line = |prefix: &str| { | |
| summary | |
| .iter() | |
| .find(|line| line.starts_with(prefix)) | |
| .cloned() | |
| .unwrap_or_default() | |
| }; | |
| // The manifest's own roots survive, and the application's directory and | |
| // storage are added rather than put in their place — once each. | |
| let read = line("fs read: "); | |
| let shared = Path::new("/srv/shared").display().to_string(); | |
| assert!(read.contains(&shared), "{read}"); | |
| assert!(read.contains(&data.display().to_string()), "{read}"); | |
| assert_eq!( | |
| read.matches(&root.display().to_string()).count(), | |
| 2, | |
| "{read}" | |
| ); | |
| let write = line("fs write: "); | |
| let out = Path::new("/srv/shared/out").display().to_string(); | |
| assert!(write.contains(&out), "{write}"); | |
| assert!(write.contains(&data.display().to_string()), "{write}"); | |
| let _ = std::fs::remove_dir_all(root); | |
| } | |
| #[test] | |
| fn an_incompatible_local_manifest_is_refused_before_loading_script() { | |
| let root = std::env::temp_dir().join(format!( | |
| "gpui-shell-local-manifest-version-{}", | |
| std::process::id() | |
| )); | |
| std::fs::create_dir_all(&root).expect("temporary app directory"); | |
| std::fs::write( | |
| root.join("gpui-shell.json"), | |
| r#"{ | |
| "id": "com.example.future", | |
| "name": "Future", | |
| "version": "1.0.0", | |
| "shell-version": "99.0.0", | |
| "entry": "main.js" | |
| }"#, | |
| ) | |
| .expect("manifest"); | |
| let error = read_local_manifest(&root).expect_err("future shells are incompatible"); | |
| assert!(error.contains("requires gpui-shell 99.0.0"), "{error}"); | |
| let _ = std::fs::remove_dir_all(root); | |
| } | |
| } |