diff --git a/.claude/skills/update-gpui/SKILL.md b/.claude/skills/update-gpui/SKILL.md index 76e2d283..4d37c57e 100644 --- a/.claude/skills/update-gpui/SKILL.md +++ b/.claude/skills/update-gpui/SKILL.md @@ -47,7 +47,12 @@ Update the GPUI git submodule and all related Cargo.toml references to the lates cargo clippy -- --deny warnings ``` -8. **Report summary**: Show old commit, new commit, what changed (list new commits), and whether build/clippy passed. +8. **Version + compatibility bookkeeping** (this skill is the ONLY place a gpui rev changes — see docs/learned/app-platform-plan.md D6): + - Bump `[workspace.package] version` in the root `Cargo.toml` (minor bump for a gpui rev change or breaking API change while pre-1.0; patch otherwise). Keep the `version = "X.Y.Z"` values inside `[workspace.dependencies]` for the path crates (`gpui-component`, `gpui-component-*`) in sync. + - Add a row to `docs/COMPATIBILITY.md` pairing the new workspace version with the new gpui rev. + - After the change lands on `main`, create the annotated tag: `git tag -a v -m "gpui-component v (gpui )"` and push it. Apps pin these tags — never bare revs. + +9. **Report summary**: Show old commit, new commit, what changed (list new commits), whether build/clippy passed, and the new workspace version + tag. ### Notes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d61595c5..913a5b1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,9 @@ jobs: - name: Typo check if: ${{ matrix.run_on == 'macos-latest' }} run: | - cargo install typos-cli || echo "typos-cli already installed" + # --locked for reproducibility; no `|| echo` mask — a failed install + # must fail the step, not surface later as `typos: command not found`. + which typos >/dev/null 2>&1 || cargo install typos-cli --locked typos - name: Lint (macOS) if: ${{ matrix.run_on == 'macos-latest' }} @@ -60,6 +62,11 @@ jobs: - name: Lint (Windows) if: ${{ matrix.run_on == 'windows-latest' }} run: ./script/clippy.ps1 + # Platform crates are pure-Rust (no display server) and their tests are + # MANDATORY on every OS — unlike the rest of the repo, where tests are + # optional. See docs/learned/app-platform-plan.md §5 Phase 0 item 2/3. + - name: Test platform crates + run: cargo test -p gpui-component-storage -p gpui-component-manifest - name: Test Linux if: ${{ matrix.run_on == 'ubuntu-latest' }} run: | @@ -73,3 +80,52 @@ jobs: run: | cargo test --all cargo test -p gpui-component --doc + + # Native launch smoke test for the app-platform conformance examples + # (examples/app_shell single-window, examples/app_shell_tray tray-first). Each + # example boots the real shell, opens its window(s), then `--smoke` requests a + # clean quit and exits 0. See docs/learned/app-platform-plan.md §5 Phase 1 and + # §6 gate 7. Keep the matrix in sync with the `test` job above. + native-launch-smoke: + name: Native launch smoke + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + run_on: macos-latest + - target: x86_64-linux-gnu + run_on: ubuntu-latest + - target: x86_64-windows-msvc + run_on: windows-latest + runs-on: ${{ matrix.run_on }} + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Install system dependencies + if: ${{ matrix.run_on != 'windows-latest' }} + run: script/bootstrap + # Build on every OS: this exercises the full D4 identity chain (build.rs + + # include_identity!) cross-platform, even where we do not launch a + # GPU-backed window below. + - name: Build conformance examples + run: cargo build -p app_shell -p app_shell_tray + # Launch smoke runs on macOS only. Platform parity (CLAUDE.md) requires + # documenting why the other two are gated off rather than silently skipped: + # - macOS: GitHub arm64 runners provide a working Metal device; the + # `--smoke` launch is verified to reach a ready window and exit 0. + # - Linux: GPUI renders via Vulkan. Stock ubuntu runners have no display + # and no Vulkan device, so a native launch needs both xvfb and a + # software driver (lavapipe) that is not guaranteed present. Gated off + # to avoid flaky reds; enable once the runner image ships them: + # xvfb-run -a cargo run -p app_shell -- --smoke + # xvfb-run -a cargo run -p app_shell_tray -- --smoke + # - Windows: GPUI renders via DirectX; headless runners have no reliable + # present surface. Gated off pending a WARP-backed offscreen path: + # cargo run -p app_shell -- --smoke + # cargo run -p app_shell_tray -- --smoke + - name: Native launch smoke (macOS) + if: ${{ matrix.run_on == 'macos-latest' }} + run: | + cargo run -p app_shell -- --smoke + cargo run -p app_shell_tray -- --smoke diff --git a/Cargo.lock b/Cargo.lock index 722e256b..709632e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -197,10 +197,28 @@ dependencies = [ "anyhow", "gpui", "gpui-component", + "gpui-component-assets", "gpui_platform", "rust-embed", ] +[[package]] +name = "app_shell" +version = "0.5.1" +dependencies = [ + "gpui-component-app", + "gpui-component-manifest", + "serde", +] + +[[package]] +name = "app_shell_tray" +version = "0.5.1" +dependencies = [ + "gpui-component-app", + "gpui-component-manifest", +] + [[package]] name = "ar_archive_writer" version = "0.5.1" @@ -3159,6 +3177,24 @@ dependencies = [ "zed-sum-tree", ] +[[package]] +name = "gpui-component-app" +version = "0.5.1" +dependencies = [ + "anyhow", + "gpui", + "gpui-component", + "gpui-component-manifest", + "gpui-component-storage", + "gpui_platform", + "log", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", +] + [[package]] name = "gpui-component-assets" version = "0.5.1" @@ -3177,6 +3213,28 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "gpui-component-manifest" +version = "0.5.1" +dependencies = [ + "serde", + "tempfile", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "gpui-component-storage" +version = "0.5.1" +dependencies = [ + "dirs 6.0.0", + "log", + "serde", + "tempfile", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", +] + [[package]] name = "gpui-component-story" version = "0.5.1" @@ -7994,7 +8052,6 @@ dependencies = [ "gpui-component-assets", "gpui_platform", "metal 0.33.0", - "smol", "sysinfo 0.37.2", "windows 0.62.2", ] diff --git a/Cargo.toml b/Cargo.toml index 9216cf83..fcf7e401 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,9 @@ [workspace] default-members = ["crates/ui", "crates/story", "crates/assets"] members = [ + "crates/app", + "crates/app-manifest", + "crates/app-storage", "crates/macros", "crates/story", "crates/ui", @@ -8,6 +11,8 @@ members = [ "crates/reqwest_client", "crates/webview", "examples/app_assets", + "examples/app_shell", + "examples/app_shell_tray", "examples/hello_world", "examples/input", "examples/window_title", @@ -21,9 +26,13 @@ resolver = "2" [workspace.package] publish = false edition = "2024" +version = "0.5.1" [workspace.dependencies] gpui-component = { path = "crates/ui", version = "0.5.1" } +gpui-component-app = { path = "crates/app", version = "0.5.1" } +gpui-component-manifest = { path = "crates/app-manifest", version = "0.5.1" } +gpui-component-storage = { path = "crates/app-storage", version = "0.5.1" } gpui-component-macros = { path = "crates/macros", version = "0.5.1" } gpui-component-assets = { path = "crates/assets", version = "0.5.1" } story = { path = "crates/story" } @@ -50,6 +59,7 @@ circular-buffer = "1.0" chrono = { version = "0.4", features = ["serde"] } ctor = "0.4.0" derive_more = "0.99.17" +dirs = "6" env_logger = "0.11" futures = "0.3" image = "0.25.1" @@ -78,7 +88,9 @@ smallvec = "1.15.1" smol = "2" stacksafe = "0.1" strum = { version = "0.27.2", features = ["derive"] } +tempfile = "3" thiserror = "2.0.12" +toml = "0.9" tracing = "0.1.44" uuid = { version = "1.1.2", features = ["v4", "v5", "v7", "serde"] } scap = { git = "https://github.com/zed-industries/scap", rev = "4afea48c3b002197176fb19cd0f9b180dd36eaac", default-features = false, package = "zed-scap", version = "0.0.8-zed" } diff --git a/crates/app-manifest/Cargo.toml b/crates/app-manifest/Cargo.toml new file mode 100644 index 00000000..6b9f7cf1 --- /dev/null +++ b/crates/app-manifest/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "gpui-component-manifest" +description = "App identity schema, validation, and build-time codegen for gpui-component apps. No GPUI dependency; usable from build scripts and packaging tools." +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +serde.workspace = true +thiserror.workspace = true +toml.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/app-manifest/src/bin/gpui-app-doctor.rs b/crates/app-manifest/src/bin/gpui-app-doctor.rs new file mode 100644 index 00000000..f1afcca3 --- /dev/null +++ b/crates/app-manifest/src/bin/gpui-app-doctor.rs @@ -0,0 +1,444 @@ +//! Read-only packaging identity verifier. + +use std::{ + env, + io::{self, Write}, + path::PathBuf, + process::ExitCode, +}; + +#[path = "../doctor.rs"] +mod doctor; + +fn main() -> ExitCode { + let mut stdout = io::stdout().lock(); + let mut stderr = io::stderr().lock(); + ExitCode::from(run(env::args().skip(1), &mut stdout, &mut stderr)) +} + +fn run( + args: impl IntoIterator, + stdout: &mut impl Write, + stderr: &mut impl Write, +) -> u8 { + match parse_args(args) { + Ok(ParsedArgs::Help) => { + let _ = writeln!(stdout, "usage: gpui-app-doctor [--json] [PATH]"); + 0 + } + Ok(ParsedArgs::Options(options)) => match doctor::verify(&options.root) { + Ok(report) => { + let result = if options.json { + write_json(stdout, &report) + } else { + write_table(stdout, &report) + }; + if let Err(error) = result { + let _ = writeln!(stderr, "error: failed to write report: {error}"); + return 2; + } + u8::from(report.has_failures()) + } + Err(error) => { + let _ = writeln!(stderr, "error: {error}"); + 2 + } + }, + Err(message) => { + let _ = writeln!( + stderr, + "error: {message}\nusage: gpui-app-doctor [--json] [PATH]" + ); + 2 + } + } +} + +struct Options { + root: PathBuf, + json: bool, +} + +enum ParsedArgs { + Help, + Options(Options), +} + +fn parse_args(args: impl IntoIterator) -> Result { + let mut root = None; + let mut json = false; + for arg in args { + match arg.as_str() { + "--json" => json = true, + "-h" | "--help" => return Ok(ParsedArgs::Help), + value if value.starts_with('-') => return Err(format!("unknown option {value:?}")), + value if root.is_none() => root = Some(PathBuf::from(value)), + value => return Err(format!("unexpected argument {value:?}")), + } + } + Ok(ParsedArgs::Options(Options { + root: root.unwrap_or_else(|| PathBuf::from(".")), + json, + })) +} + +fn write_table(output: &mut impl Write, report: &doctor::Report) -> io::Result<()> { + let field_width = report + .checks + .iter() + .map(|row| row.field.len()) + .max() + .unwrap_or(5) + .max(5); + let manifest_width = report + .checks + .iter() + .map(|row| row.manifest_value.len()) + .max() + .unwrap_or(14) + .max(14); + let artifact_width = report + .checks + .iter() + .map(|row| row.artifact_value.len()) + .max() + .unwrap_or(14) + .max(14); + writeln!( + output, + "{: io::Result<()> { + writeln!(output, "[")?; + for (index, row) in report.checks.iter().enumerate() { + write!( + output, + " {{\"field\":\"{}\",\"manifest_value\":\"{}\",\"artifact_value\":\"{}\",\"status\":\"{}\"}}", + escape_json(&row.field), + escape_json(&row.manifest_value), + escape_json(&row.artifact_value), + row.status.as_str() + )?; + writeln!( + output, + "{}", + if index + 1 == report.checks.len() { + "" + } else { + "," + } + )?; + } + writeln!(output, "]") +} + +fn escape_json(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + match character { + '"' => escaped.push_str("\\\""), + '\\' => escaped.push_str("\\\\"), + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + value if value <= '\u{1f}' => { + use std::fmt::Write as _; + let _ = write!(escaped, "\\u{:04x}", value as u32); + } + value => escaped.push(value), + } + } + escaped +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{fs, path::Path}; + use tempfile::tempdir; + + fn manifest(identifier: &str) -> String { + format!( + r#"[package] +name = "example" +[package.metadata.gpui-app] +app_id = "com.example.app" +display_name = "Example" +[package.metadata.bundle] +identifier = "{identifier}" +name = "Example" +icon = "icon.png" +"# + ) + } + + fn write(root: &Path, path: &str, source: &str) { + let path = root.join(path); + fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture dir"); + fs::write(path, source).expect("write fixture"); + } + + fn ansible_fixture(root: &Path) { + write( + root, + "Cargo.toml", + r#"[package] +name = "ansible" +[package.metadata.gpui-app] +app_id = "com.weakly-design.ansible" +display_name = "Ansible" +binary_name = "ansible" +[package.metadata.gpui-app.macos] +entitlements = ["com.apple.security.device.audio-input"] +[package.metadata.gpui-app.macos.usage_strings] +NSMicrophoneUsageDescription = "Ansible needs microphone access" +[package.metadata.bundle] +identifier = "com.weakly-design.ansible" +name = "Ansible" +icon = ["icon.icns", "icon.png"] +"#, + ); + write( + root, + "Info.plist", + r#"CFBundleIdentifiercom.weakly-design.ansibleCFBundleNameAnsibleNSMicrophoneUsageDescriptionAnsible needs microphone access"#, + ); + write( + root, + "entitlements.plist", + "com.apple.security.device.audio-input", + ); + write( + root, + "packaging/ansible.desktop", + "[Desktop Entry]\nName=Ansible\nExec=/usr/bin/ansible %U\nIcon=ansible\nStartupWMClass=ansible\n", + ); + write( + root, + "packaging/windows/AppxManifest.xml", + "", + ); + write( + root, + "packaging/linux/com.weakly-design.ansible.json", + r#"{"app-id":"com.weakly-design.ansible","runtime":"org.freedesktop.Platform"}"#, + ); + write( + root, + "packaging/windows/ansible.wxs", + r#""#, + ); + } + + #[test] + fn ansible_fixture_exercises_every_artifact_field() { + let dir = tempdir().expect("temp fixture"); + ansible_fixture(dir.path()); + let report = doctor::verify(dir.path()).expect("verify fixture"); + assert!(!report.has_failures(), "{:#?}", report.checks); + for field in [ + "Cargo.toml:bundle.identifier", + "Cargo.toml:bundle.name", + "Cargo.toml:bundle.icon", + "Info.plist:CFBundleIdentifier", + "Info.plist:CFBundleName", + "Info.plist:NSMicrophoneUsageDescription", + "entitlements.plist:com.apple.security.device.audio-input", + "packaging/ansible.desktop:Name", + "packaging/ansible.desktop:Exec", + "packaging/ansible.desktop:Icon", + "packaging/ansible.desktop:StartupWMClass", + "packaging/windows/AppxManifest.xml:Identity.Name", + "packaging/linux/com.weakly-design.ansible.json:id", + "packaging/windows/ansible.wxs:GUID", + "packaging/windows/ansible.wxs:Name", + ] { + assert!( + report.checks.iter().any(|check| check.field == field), + "missing {field}: {:#?}", + report.checks + ); + } + } + + #[test] + fn declared_entitlement_absent_from_file_is_missing() { + // A present-but-wrong-keyed entitlements file must not satisfy a declared + // entitlement; the required key is reported Missing. + let dir = tempdir().expect("temp fixture"); + ansible_fixture(dir.path()); + write( + dir.path(), + "entitlements.plist", + "com.apple.security.network.client", + ); + let report = doctor::verify(dir.path()).expect("verify fixture"); + assert!(report.has_failures()); + assert!(report.checks.iter().any(|check| { + check.field == "entitlements.plist:com.apple.security.device.audio-input" + && check.status == doctor::Status::Missing + })); + } + + #[test] + fn reports_mismatch_and_missing_fields() { + let dir = tempdir().expect("temp fixture"); + ansible_fixture(dir.path()); + write( + dir.path(), + "packaging/ansible.desktop", + "[Desktop Entry]\nName=Wrong\nExec=wrong\nStartupWMClass=ansible\n", + ); + let report = doctor::verify(dir.path()).expect("verify fixture"); + assert!(report.has_failures()); + assert!(report.checks.iter().any( + |check| check.field.ends_with(":Name") && check.status == doctor::Status::Mismatch + )); + assert!( + report + .checks + .iter() + .any(|check| check.field.ends_with(":Icon") + && check.status == doctor::Status::Missing) + ); + } + + #[test] + fn usage_description_checks_presence_not_wording() { + let dir = tempdir().expect("temp fixture"); + ansible_fixture(dir.path()); + write( + dir.path(), + "Info.plist", + r#"CFBundleIdentifiercom.weakly-design.ansibleCFBundleNameAnsibleNSMicrophoneUsageDescriptionDifferent nonempty wording"#, + ); + let report = doctor::verify(dir.path()).expect("verify fixture"); + assert!(!report.has_failures(), "{:#?}", report.checks); + } + + #[test] + fn msix_identity_name_requires_exact_attribute() { + let dir = tempdir().expect("temp fixture"); + ansible_fixture(dir.path()); + write( + dir.path(), + "packaging/windows/AppxManifest.xml", + r#""#, + ); + let report = doctor::verify(dir.path()).expect("verify fixture"); + assert!(report.checks.iter().any(|check| { + check.field.ends_with("Identity.Name") && check.status == doctor::Status::Missing + })); + } + + #[test] + fn accepts_flatpak_id_and_ignores_unrelated_json() { + let dir = tempdir().expect("temp fixture"); + fs::write(dir.path().join("Cargo.toml"), manifest("com.example.app")) + .expect("write fixture"); + write( + dir.path(), + "packaging/linux/manifest.json", + r#"{"note":"id","metadata":{"id":"wrong.nested.id"},"id":"com.example.app","runtime":"org.freedesktop.Platform"}"#, + ); + write(dir.path(), "package.json", r#"{"id":"not-an-app-id"}"#); + let report = doctor::verify(dir.path()).expect("verify fixture"); + assert!(!report.has_failures(), "{:#?}", report.checks); + assert_eq!( + report + .checks + .iter() + .filter(|check| check.field.ends_with(":id")) + .count(), + 1 + ); + } + + #[test] + fn reports_missing_flatpak_id() { + let dir = tempdir().expect("temp fixture"); + fs::write(dir.path().join("Cargo.toml"), manifest("com.example.app")) + .expect("write fixture"); + write( + dir.path(), + "packaging/flatpak/manifest.json", + r#"{"runtime":"org.freedesktop.Platform"}"#, + ); + let report = doctor::verify(dir.path()).expect("verify fixture"); + assert!(report.checks.iter().any(|check| { + check.field.ends_with(":id") && check.status == doctor::Status::Missing + })); + } + + #[test] + fn clean_and_mismatch_exit_codes_follow_contract() { + let dir = tempdir().expect("temp fixture"); + fs::write(dir.path().join("Cargo.toml"), manifest("com.example.app")) + .expect("write fixture"); + let mut output = Vec::new(); + let mut errors = Vec::new(); + assert_eq!( + run([dir.path().display().to_string()], &mut output, &mut errors), + 0 + ); + + fs::write(dir.path().join("Cargo.toml"), manifest("com.example.wrong")) + .expect("write mismatch"); + output.clear(); + assert_eq!( + run([dir.path().display().to_string()], &mut output, &mut errors), + 1 + ); + assert!( + String::from_utf8(output) + .expect("UTF-8 output") + .contains("MISMATCH") + ); + } + + #[test] + fn usage_errors_exit_two_and_json_is_machine_shaped() { + let mut output = Vec::new(); + let mut errors = Vec::new(); + assert_eq!(run(["--bad".to_owned()], &mut output, &mut errors), 2); + assert!( + String::from_utf8(errors) + .expect("UTF-8 error") + .contains("unknown option") + ); + + let report = doctor::Report { + checks: vec![doctor::Check { + field: "a\"b".to_owned(), + manifest_value: "x".to_owned(), + artifact_value: "y".to_owned(), + status: doctor::Status::Ok, + }], + }; + write_json(&mut output, &report).expect("write JSON"); + assert!( + String::from_utf8(output) + .expect("UTF-8 JSON") + .contains(r#""field":"a\"b""#) + ); + } +} diff --git a/crates/app-manifest/src/build.rs b/crates/app-manifest/src/build.rs new file mode 100644 index 00000000..c6f02132 --- /dev/null +++ b/crates/app-manifest/src/build.rs @@ -0,0 +1,211 @@ +//! Code generation called by a consuming application's own `build.rs`. + +use std::{fmt::Write as _, path::PathBuf}; + +use crate::{ + ManifestError, + schema::{AppIdentity, LinuxIdentity, MacosIdentity, MinOsVersions, WindowsIdentity}, + versions::{MsixTarget, derive_cfbundle_short_version, derive_msix_version}, +}; + +/// Reads the consuming app's manifest and writes `gpui_app_identity.rs` to its +/// `OUT_DIR`. +/// +/// # Errors +/// +/// Returns a contextual error when required Cargo environment is absent, the +/// manifest is invalid, a packaging version cannot be derived, or output fails. +pub fn emit_identity() -> Result<(), ManifestError> { + let manifest_dir = required_env("CARGO_MANIFEST_DIR")?; + let version = required_env("CARGO_PKG_VERSION")?; + let _package_name = required_env("CARGO_PKG_NAME")?; + let out_dir = required_env("OUT_DIR")?; + + println!("cargo:rerun-if-changed=Cargo.toml"); + println!("cargo:rerun-if-env-changed=GPUI_APP_BUILD_NUMBER"); + + let identity = crate::parse::parse_identity(&PathBuf::from(manifest_dir).join("Cargo.toml"))?; + let cfbundle_short_version = derive_cfbundle_short_version(&version)?; + let build_number = optional_build_number()?; + // Sideload target: the generated identity carries the build number in the + // fourth component. Store submissions reserve and overwrite that component, + // so a Store-specific version is derived at packaging time, not here. + let msix_version = derive_msix_version(&version, MsixTarget::Sideload, build_number)?; + let source = generate_source(&identity, &version, &cfbundle_short_version, &msix_version); + let output_path = PathBuf::from(out_dir).join("gpui_app_identity.rs"); + std::fs::write(&output_path, source).map_err(|source| ManifestError::WriteGenerated { + path: output_path, + source, + }) +} + +fn required_env(name: &'static str) -> Result { + std::env::var(name).map_err(|source| ManifestError::MissingEnvironment { name, source }) +} + +fn optional_build_number() -> Result, ManifestError> { + match std::env::var("GPUI_APP_BUILD_NUMBER") { + Ok(value) => value + .parse() + .map(Some) + .map_err(|source| ManifestError::InvalidBuildNumber { value, source }), + Err(std::env::VarError::NotPresent) => Ok(None), + Err(source) => Err(ManifestError::MissingEnvironment { + name: "GPUI_APP_BUILD_NUMBER", + source, + }), + } +} + +fn generate_source( + identity: &AppIdentity, + version: &str, + cfbundle_short_version: &str, + msix_version: &str, +) -> String { + let mut source = String::new(); + writeln!( + source, + "pub static APP_IDENTITY: ::gpui_component_manifest::schema::IdentityRef = ::gpui_component_manifest::schema::IdentityRef {{" + ) + .expect("writing generated source to a String cannot fail"); + write_field(&mut source, "app_id", &format!("{:?}", identity.app_id)); + write_field( + &mut source, + "display_name", + &format!("{:?}", identity.display_name), + ); + write_field( + &mut source, + "data_namespace", + &format!("{:?}", identity.resolved_data_namespace()), + ); + write_field( + &mut source, + "binary_name", + &format!("{:?}", identity.binary_name), + ); + write_field(&mut source, "org", &format!("{:?}", identity.org)); + write_field( + &mut source, + "publisher", + &format!("{:?}", identity.publisher), + ); + write_field( + &mut source, + "url_schemes", + &format!("&{:?}", identity.url_schemes), + ); + write_field( + &mut source, + "categories", + &format!("&{:?}", identity.categories), + ); + write_field(&mut source, "macos", &macos_source(identity.macos.as_ref())); + write_field(&mut source, "linux", &linux_source(identity.linux.as_ref())); + write_field( + &mut source, + "windows", + &windows_source(identity.windows.as_ref()), + ); + write_field( + &mut source, + "legacy_ids", + &format!("&{:?}", identity.legacy_ids), + ); + write_field( + &mut source, + "min_os", + &min_os_source(identity.min_os.as_ref()), + ); + write_field(&mut source, "version", &format!("{version:?}")); + write_field( + &mut source, + "cfbundle_short_version", + &format!("{cfbundle_short_version:?}"), + ); + write_field(&mut source, "msix_version", &format!("{msix_version:?}")); + source.push_str("};\n"); + source +} + +fn write_field(source: &mut String, name: &str, value: &str) { + writeln!(source, " {name}: {value},") + .expect("writing generated source to a String cannot fail"); +} + +fn macos_source(value: Option<&MacosIdentity>) -> String { + value.map_or_else( + || "None".to_owned(), + |value| { + let usage_strings: Vec<_> = value.usage_strings.iter().collect(); + format!( + "Some(::gpui_component_manifest::schema::MacosIdentityRef {{ category: {:?}, entitlements: &{:?}, usage_strings: &{:?} }})", + value.category, value.entitlements, usage_strings + ) + }, + ) +} + +fn linux_source(value: Option<&LinuxIdentity>) -> String { + value.map_or_else( + || "None".to_owned(), + |value| { + format!( + "Some(::gpui_component_manifest::schema::LinuxIdentityRef {{ categories: &{:?}, desktop_keywords: &{:?} }})", + value.categories, value.desktop_keywords + ) + }, + ) +} + +fn windows_source(value: Option<&WindowsIdentity>) -> String { + value.map_or_else( + || "None".to_owned(), + |value| { + format!( + "Some(::gpui_component_manifest::schema::WindowsIdentityRef {{ publisher: {:?} }})", + value.publisher + ) + }, + ) +} + +fn min_os_source(value: Option<&MinOsVersions>) -> String { + value.map_or_else( + || "None".to_owned(), + |value| { + format!( + "Some(::gpui_component_manifest::schema::MinOsVersionsRef {{ macos: {:?}, windows: {:?}, linux: {:?} }})", + value.macos, value.windows, value.linux + ) + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_literals_escape_strings() { + let identity = AppIdentity { + app_id: "com.example.escaped".to_owned(), + display_name: "Quoted \"Name\"\n".to_owned(), + data_namespace: "escaped".to_owned(), + binary_name: Some("bin\\name".to_owned()), + org: None, + publisher: None, + url_schemes: vec!["escaped".to_owned()], + categories: Vec::new(), + macos: None, + linux: None, + windows: None, + legacy_ids: Vec::new(), + min_os: None, + }; + let source = generate_source(&identity, "1.2.3", "1.2.3", "1.2.3.0"); + assert!(source.contains(r#"display_name: "Quoted \"Name\"\n""#)); + assert!(source.contains(r#"binary_name: Some("bin\\name")"#)); + } +} diff --git a/crates/app-manifest/src/doctor.rs b/crates/app-manifest/src/doctor.rs new file mode 100644 index 00000000..ff7ad6f2 --- /dev/null +++ b/crates/app-manifest/src/doctor.rs @@ -0,0 +1,549 @@ +//! Read-only verification of app identity in existing packaging artifacts. +//! +//! Artifact parsing is deliberately targeted for v1: plist and XML readers only +//! recognize simple string keys/quoted attributes, desktop files use line-based +//! `key=value` matching, and Flatpak JSON uses quoted top-level identity keys. +//! This is not a general parser for any of those formats. + +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use gpui_component_manifest::schema::AppIdentity; + +/// Outcome of one identity comparison. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Status { + Ok, + Mismatch, + Missing, +} + +impl Status { + /// Stable display and JSON representation. + pub const fn as_str(self) -> &'static str { + match self { + Self::Ok => "OK", + Self::Mismatch => "MISMATCH", + Self::Missing => "MISSING", + } + } +} + +/// One row in a doctor report. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Check { + pub field: String, + pub manifest_value: String, + pub artifact_value: String, + pub status: Status, +} + +/// Complete verification result. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Report { + pub checks: Vec, +} + +impl Report { + /// Missing artifact fields fail verification, just like differing values. + pub fn has_failures(&self) -> bool { + self.checks.iter().any(|check| check.status != Status::Ok) + } +} + +/// Errors that prevent verification from completing. +/// +/// The two heavy payloads (`ManifestError`, `toml::de::Error`) are boxed so the +/// error stays small: it rides in every `Result<_, DoctorError>` on the success +/// path, and an unboxed variant pushes the type past clippy's +/// `result_large_err` threshold (caught only by CI's newer toolchain). The const +/// assertion below guards against a regression. +#[derive(Debug, thiserror::Error)] +pub enum DoctorError { + #[error(transparent)] + Manifest(Box), + #[error("failed to read {path}: {source}")] + Read { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to parse packaging metadata in {path}: {source}")] + Toml { + path: PathBuf, + #[source] + source: Box, + }, +} + +const _: () = assert!(std::mem::size_of::() <= 64); + +/// Verifies identity metadata against recognized artifacts below `root`. +/// +/// # Errors +/// +/// Returns an error when the canonical manifest, a directory, or a recognized +/// artifact cannot be read, or when the root Cargo manifest is malformed. +pub fn verify(root: &Path) -> Result { + let cargo_path = root.join("Cargo.toml"); + let identity = gpui_component_manifest::parse::parse_identity(&cargo_path) + .map_err(|e| DoctorError::Manifest(Box::new(e)))?; + let cargo_source = read(&cargo_path)?; + let cargo: toml::Value = toml::from_str(&cargo_source).map_err(|source| DoctorError::Toml { + path: cargo_path, + source: Box::new(source), + })?; + let mut report = Report::default(); + verify_bundle(&cargo, &identity, &mut report); + + let mut files = Vec::new(); + collect_files(root, root, &mut files)?; + files.sort(); + // Accumulate the contents of every candidate entitlements file so each + // declared entitlement key can be verified against the union below — a + // matching filename alone (possibly empty or carrying unrelated keys) must + // not pass a required entitlement. + let mut entitlement_source = String::new(); + for path in files { + // Check-field names are stable identifiers, not I/O paths: always use + // forward slashes so reports (and tests) are byte-identical on Windows. + let relative = path + .strip_prefix(root) + .unwrap_or(&path) + .display() + .to_string() + .replace('\\', "/"); + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); + let lower = name.to_ascii_lowercase(); + if name == "Info.plist" { + verify_info(&relative, &read(&path)?, &identity, &mut report); + } else if lower.ends_with("entitlements.plist") { + entitlement_source.push_str(&read(&path)?); + entitlement_source.push('\n'); + } else if path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("desktop")) + { + verify_desktop(&relative, &read(&path)?, &identity, &mut report); + } else if name.eq_ignore_ascii_case("AppxManifest.xml") { + verify_msix(&relative, &read(&path)?, &identity, &mut report); + } else if path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("wxs")) + { + verify_wix(&relative, &read(&path)?, &identity, &mut report); + } else if path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("json")) + { + let source = read(&path)?; + let value = json_string(&source, "app-id").or_else(|| json_string(&source, "id")); + if is_flatpak_candidate(&path, &identity.app_id, &source, value.is_some()) { + compare( + &mut report, + format!("{relative}:id"), + &identity.app_id, + value, + ); + } + } + } + // Verify every declared entitlement key is actually present, not merely that + // some entitlements file exists. A missing file leaves `entitlement_source` + // empty, so each required key is reported `Missing`. + if let Some(macos) = identity.macos.as_ref() { + for key in &macos.entitlements { + presence( + &mut report, + format!("entitlements.plist:{key}"), + plist_has_key(&entitlement_source, key), + ); + } + } + Ok(report) +} + +fn read(path: &Path) -> Result { + fs::read_to_string(path).map_err(|source| DoctorError::Read { + path: path.to_owned(), + source, + }) +} + +fn collect_files(root: &Path, path: &Path, files: &mut Vec) -> Result<(), DoctorError> { + let entries = fs::read_dir(path).map_err(|source| DoctorError::Read { + path: path.to_owned(), + source, + })?; + for entry in entries { + let entry = entry.map_err(|source| DoctorError::Read { + path: path.to_owned(), + source, + })?; + let file_type = entry.file_type().map_err(|source| DoctorError::Read { + path: entry.path(), + source, + })?; + if file_type.is_dir() { + let name = entry.file_name(); + if name != ".git" && name != "target" { + collect_files(root, &entry.path(), files)?; + } + } else if file_type.is_file() && entry.path() != root.join("Cargo.toml") { + files.push(entry.path()); + } + } + Ok(()) +} + +fn verify_bundle(cargo: &toml::Value, identity: &AppIdentity, report: &mut Report) { + let Some(bundle) = cargo + .get("package") + .and_then(|v| v.get("metadata")) + .and_then(|v| v.get("bundle")) + else { + return; + }; + compare( + report, + "Cargo.toml:bundle.identifier".to_owned(), + &identity.app_id, + bundle.get("identifier").and_then(toml::Value::as_str), + ); + compare( + report, + "Cargo.toml:bundle.name".to_owned(), + &identity.display_name, + bundle.get("name").and_then(toml::Value::as_str), + ); + let icon = bundle.get("icon").filter(|value| match value { + toml::Value::String(v) => !v.trim().is_empty(), + toml::Value::Array(values) => { + !values.is_empty() + && values + .iter() + .all(|value| value.as_str().is_some_and(|value| !value.trim().is_empty())) + } + _ => false, + }); + report.checks.push(Check { + field: "Cargo.toml:bundle.icon".to_owned(), + manifest_value: "present".to_owned(), + artifact_value: icon.map(toml_value).unwrap_or_else(|| "-".to_owned()), + status: if icon.is_some() { + Status::Ok + } else { + Status::Missing + }, + }); +} + +fn verify_info(path: &str, source: &str, identity: &AppIdentity, report: &mut Report) { + compare( + report, + format!("{path}:CFBundleIdentifier"), + &identity.app_id, + plist_string(source, "CFBundleIdentifier"), + ); + compare( + report, + format!("{path}:CFBundleName"), + &identity.display_name, + plist_string(source, "CFBundleName"), + ); + if let Some(macos) = &identity.macos { + for (key, expected) in &macos.usage_strings { + presence_string( + report, + format!("{path}:{key}"), + expected, + plist_string(source, key), + ); + } + } +} + +fn verify_desktop(path: &str, source: &str, identity: &AppIdentity, report: &mut Report) { + compare( + report, + format!("{path}:Name"), + &identity.display_name, + desktop_value(source, "Name"), + ); + if let Some(binary) = identity.binary_name.as_deref() { + compare( + report, + format!("{path}:Exec"), + binary, + desktop_value(source, "Exec").map(exec_program), + ); + compare( + report, + format!("{path}:StartupWMClass"), + binary, + desktop_value(source, "StartupWMClass"), + ); + } else { + presence_value( + report, + format!("{path}:Exec"), + desktop_value(source, "Exec"), + ); + presence_value( + report, + format!("{path}:StartupWMClass"), + desktop_value(source, "StartupWMClass"), + ); + } + presence_value( + report, + format!("{path}:Icon"), + desktop_value(source, "Icon"), + ); +} + +fn verify_msix(path: &str, source: &str, identity: &AppIdentity, report: &mut Report) { + compare( + report, + format!("{path}:Identity.Name"), + &identity.app_id, + xml_attribute(source, "Identity", "Name"), + ); +} + +fn verify_wix(path: &str, source: &str, _identity: &AppIdentity, report: &mut Report) { + presence( + report, + format!("{path}:GUID"), + has_assignment(source, "Guid"), + ); + presence( + report, + format!("{path}:Name"), + has_assignment(source, "Name"), + ); +} + +fn compare(report: &mut Report, field: String, expected: &str, actual: Option<&str>) { + let status = match actual { + None => Status::Missing, + Some(value) if value == expected => Status::Ok, + Some(_) => Status::Mismatch, + }; + report.checks.push(Check { + field, + manifest_value: expected.to_owned(), + artifact_value: actual.unwrap_or("-").to_owned(), + status, + }); +} + +fn presence(report: &mut Report, field: String, found: bool) { + report.checks.push(Check { + field, + manifest_value: "present".to_owned(), + artifact_value: if found { "present" } else { "-" }.to_owned(), + status: if found { Status::Ok } else { Status::Missing }, + }); +} + +fn presence_value(report: &mut Report, field: String, actual: Option<&str>) { + report.checks.push(Check { + field, + manifest_value: "present".to_owned(), + artifact_value: actual.unwrap_or("-").to_owned(), + status: if actual.is_some_and(|v| !v.trim().is_empty()) { + Status::Ok + } else { + Status::Missing + }, + }); +} + +fn presence_string(report: &mut Report, field: String, expected: &str, actual: Option<&str>) { + report.checks.push(Check { + field, + manifest_value: expected.to_owned(), + artifact_value: actual.unwrap_or("-").to_owned(), + status: if actual.is_some_and(|value| !value.trim().is_empty()) { + Status::Ok + } else { + Status::Missing + }, + }); +} + +fn toml_value(value: &toml::Value) -> String { + match value { + toml::Value::String(v) => v.clone(), + toml::Value::Array(v) => v.iter().map(toml_value).collect::>().join(", "), + _ => value.to_string(), + } +} + +fn plist_string<'a>(source: &'a str, key: &str) -> Option<&'a str> { + let after_key = source.split_once(&format!("{key}"))?.1; + let after_open = after_key.split_once("")?.1; + Some(after_open.split_once("")?.0.trim()) +} + +fn desktop_value<'a>(source: &'a str, key: &str) -> Option<&'a str> { + // Only the `[Desktop Entry]` group defines the main launcher fields. Keys in + // `[Desktop Action ...]` groups must not satisfy a missing launcher field. + let mut in_desktop_entry = false; + for line in source.lines().map(str::trim) { + if line.starts_with('[') && line.ends_with(']') { + in_desktop_entry = line == "[Desktop Entry]"; + continue; + } + if !in_desktop_entry || line.starts_with('#') { + continue; + } + if let Some(value) = line + .strip_prefix(key) + .and_then(|rest| rest.strip_prefix('=')) + { + return Some(value.trim()); + } + } + None +} + +fn plist_has_key(source: &str, key: &str) -> bool { + source.contains(&format!("{key}")) +} + +fn exec_program(value: &str) -> &str { + let program = value.split_whitespace().next().unwrap_or_default(); + Path::new(program) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(program) +} + +fn xml_attribute<'a>(source: &'a str, tag: &str, attribute: &str) -> Option<&'a str> { + let tag_start = source.find(&format!("<{tag}"))?; + let tag_source = source.get(tag_start..source.get(tag_start..)?.find('>')? + tag_start)?; + attribute_value(tag_source, attribute) +} + +fn json_string<'a>(source: &'a str, key: &str) -> Option<&'a str> { + let bytes = source.as_bytes(); + let mut depth = 0_u32; + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'{' => depth += 1, + b'}' => depth = depth.saturating_sub(1), + b'"' => { + let end = json_quote_end(bytes, index + 1)?; + if depth == 1 && source.get(index + 1..end) == Some(key) { + let after_key = source.get(end + 1..)?.trim_start(); + if let Some(after_colon) = after_key.strip_prefix(':') { + return quoted(after_colon.trim_start()); + } + } + index = end; + } + _ => {} + } + index += 1; + } + None +} + +fn json_quote_end(bytes: &[u8], mut index: usize) -> Option { + let mut escaped = false; + while index < bytes.len() { + match bytes[index] { + b'"' if !escaped => return Some(index), + b'\\' => escaped = !escaped, + _ => escaped = false, + } + index += 1; + } + None +} + +fn is_flatpak_candidate(path: &Path, app_id: &str, source: &str, has_id: bool) -> bool { + path.file_stem().and_then(|name| name.to_str()) == Some(app_id) + || path.components().any(|part| { + part.as_os_str() + .to_str() + .is_some_and(|part| part.to_ascii_lowercase().contains("flatpak")) + }) + || has_id + && ["runtime", "sdk", "command"] + .iter() + .any(|key| json_string(source, key).is_some()) +} + +fn has_assignment(source: &str, key: &str) -> bool { + source.match_indices(key).any(|(index, _)| { + let before = source + .get(..index) + .and_then(|value| value.chars().next_back()); + let after = source.get(index + key.len()..).map(str::trim_start); + before.is_some_and(|value| value.is_ascii_whitespace()) + && after + .and_then(|value| value.strip_prefix('=')) + .map(str::trim_start) + .is_some_and(|value| quoted(value).is_some_and(|value| !value.is_empty())) + }) +} + +fn attribute_value<'a>(source: &'a str, key: &str) -> Option<&'a str> { + source.match_indices(key).find_map(|(index, _)| { + let before = source.get(..index)?.chars().next_back()?; + let after = source.get(index + key.len()..)?.trim_start(); + (before.is_ascii_whitespace() && after.starts_with('=')) + .then(|| quoted(after.strip_prefix('=')?.trim_start())) + .flatten() + }) +} + +fn quoted(source: &str) -> Option<&str> { + let quote = source.chars().next()?; + if quote != '\'' && quote != '"' { + return None; + } + source.get(1..)?.split_once(quote).map(|(value, _)| value) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn desktop_value_only_reads_desktop_entry_group() { + let source = "\ +[Desktop Entry] +Name=Real App + +[Desktop Action new] +Name=New Window +Exec=/opt/app --new"; + assert_eq!(desktop_value(source, "Name"), Some("Real App")); + // `Exec` exists only in the action group, so the launcher field is absent. + assert_eq!(desktop_value(source, "Exec"), None); + } + + #[test] + fn desktop_value_skips_keys_before_first_group() { + let source = "Name=Stray\n[Desktop Entry]\nName=Real"; + assert_eq!(desktop_value(source, "Name"), Some("Real")); + } + + #[test] + fn plist_has_key_matches_declared_entitlements() { + let source = "com.apple.security.network.client"; + assert!(plist_has_key(source, "com.apple.security.network.client")); + assert!(!plist_has_key(source, "com.apple.security.network.server")); + assert!(!plist_has_key("", "com.apple.security.network.client")); + } +} diff --git a/crates/app-manifest/src/error.rs b/crates/app-manifest/src/error.rs new file mode 100644 index 00000000..57c5c57a --- /dev/null +++ b/crates/app-manifest/src/error.rs @@ -0,0 +1,91 @@ +use std::path::PathBuf; + +/// Errors returned while validating, parsing, or generating app identity data. +#[derive(Debug, thiserror::Error)] +pub enum ManifestError { + #[error( + "invalid `{field}` value {value:?}: expected at least two dot-separated segments, each starting with an ASCII letter and containing only ASCII letters, digits, or hyphens (underscores are rejected because `app_id` becomes the macOS CFBundleIdentifier, which permits only alphanumerics, hyphens, and periods)" + )] + InvalidAppId { field: String, value: String }, + + #[error( + "invalid `display_name` value {value:?}: expected a non-empty name after trimming whitespace" + )] + EmptyDisplayName { value: String }, + + #[error( + "invalid `data_namespace` value {value:?}: expected a non-empty namespace after trimming whitespace (omit the field to derive it from `app_id`)" + )] + EmptyDataNamespace { value: String }, + + #[error( + "invalid `url_schemes` value {value:?}: expected a lowercase ASCII scheme starting with a letter and containing only letters, digits, '+', '-', or '.'" + )] + InvalidUrlScheme { value: String }, + + #[error("invalid semantic version {value:?}: {reason}")] + InvalidSemver { value: String, reason: String }, + + #[error( + "invalid MSIX version component `{field}` value {value}: expected an integer from 0 through 65535" + )] + InvalidMsixComponent { field: &'static str, value: u64 }, + + #[error( + "invalid Microsoft Store MSIX version {value:?}: the first component (major) must be nonzero for Store packages" + )] + MsixStoreZeroMajor { value: String }, + + #[error("failed to read Cargo manifest at {path}: {source}")] + ReadManifest { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("failed to parse Cargo manifest at {path}: {source}")] + ParseToml { + path: PathBuf, + #[source] + source: toml::de::Error, + }, + + #[error("Cargo manifest at {path} is missing the `[package]` table")] + MissingPackage { path: PathBuf }, + + #[error("Cargo manifest at {path} is missing `[package.metadata.gpui-app]`{suggestion}")] + MissingIdentity { + path: PathBuf, + suggestion: &'static str, + }, + + #[error("invalid `[package.metadata.gpui-app]` in {path}: {source}")] + InvalidIdentityTable { + path: PathBuf, + #[source] + source: toml::de::Error, + }, + + #[error("required build environment variable `{name}` is unavailable: {source}")] + MissingEnvironment { + name: &'static str, + #[source] + source: std::env::VarError, + }, + + #[error( + "invalid build environment variable `GPUI_APP_BUILD_NUMBER` value {value:?}: expected an unsigned 32-bit integer: {source}" + )] + InvalidBuildNumber { + value: String, + #[source] + source: std::num::ParseIntError, + }, + + #[error("failed to write generated app identity to {path}: {source}")] + WriteGenerated { + path: PathBuf, + #[source] + source: std::io::Error, + }, +} diff --git a/crates/app-manifest/src/lib.rs b/crates/app-manifest/src/lib.rs new file mode 100644 index 00000000..c248cedd --- /dev/null +++ b/crates/app-manifest/src/lib.rs @@ -0,0 +1,22 @@ +//! App identity schema, validation, and app-local build-time code generation. + +pub mod build; +mod error; +pub mod parse; +pub mod schema; +pub mod versions; + +pub use error::ManifestError; + +/// Includes identity source generated for the consuming application. +/// +/// Call this from the consuming application's own crate after its `build.rs` has +/// called [`build::emit_identity`]. Do not call it from a library crate: `OUT_DIR` +/// must belong to the application. The planned `gpui-component-app` crate will +/// re-export this macro for ergonomic access. +#[macro_export] +macro_rules! include_identity { + () => { + include!(concat!(env!("OUT_DIR"), "/gpui_app_identity.rs")); + }; +} diff --git a/crates/app-manifest/src/parse.rs b/crates/app-manifest/src/parse.rs new file mode 100644 index 00000000..e65e58d7 --- /dev/null +++ b/crates/app-manifest/src/parse.rs @@ -0,0 +1,124 @@ +//! Cargo manifest identity parsing. + +use std::path::Path; + +use crate::{ManifestError, schema::AppIdentity}; + +/// Reads and validates `[package.metadata.gpui-app]` from a Cargo manifest. +/// +/// # Errors +/// +/// Returns a contextual error for file I/O, malformed TOML, missing tables, +/// schema deserialization, or identity validation failures. +pub fn parse_identity(cargo_toml_path: &Path) -> Result { + let source = + std::fs::read_to_string(cargo_toml_path).map_err(|source| ManifestError::ReadManifest { + path: cargo_toml_path.to_owned(), + source, + })?; + let document: toml::Value = + toml::from_str(&source).map_err(|source| ManifestError::ParseToml { + path: cargo_toml_path.to_owned(), + source, + })?; + let package = document + .get("package") + .and_then(toml::Value::as_table) + .ok_or_else(|| ManifestError::MissingPackage { + path: cargo_toml_path.to_owned(), + })?; + let metadata = package.get("metadata").and_then(toml::Value::as_table); + let identity = metadata.and_then(|table| table.get("gpui-app")); + let Some(identity) = identity else { + let suggestion = if metadata.is_some_and(|table| table.contains_key("gpui_app")) { + "; found `gpui_app`; rename it to `gpui-app`" + } else { + "" + }; + return Err(ManifestError::MissingIdentity { + path: cargo_toml_path.to_owned(), + suggestion, + }); + }; + let identity: AppIdentity = + identity + .clone() + .try_into() + .map_err(|source| ManifestError::InvalidIdentityTable { + path: cargo_toml_path.to_owned(), + source, + })?; + identity.validate()?; + Ok(identity) +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use tempfile::NamedTempFile; + + use super::*; + + fn parse(source: &str) -> Result { + let mut file = NamedTempFile::new().expect("create manifest"); + file.write_all(source.as_bytes()).expect("write manifest"); + parse_identity(file.path()) + } + + #[test] + fn parses_valid_minimal_identity() { + let identity = parse( + r#" + [package] + name = "example" + [package.metadata.gpui-app] + app_id = "com.example.app" + display_name = "Example" + "#, + ) + .expect("valid identity"); + assert_eq!(identity.app_id, "com.example.app"); + assert_eq!(identity.display_name, "Example"); + assert_eq!(identity.resolved_data_namespace(), "app"); + } + + #[test] + fn reports_missing_identity_table() { + let error = parse("[package]\nname = \"example\"\n") + .expect_err("identity metadata should be required"); + assert!(error.to_string().contains("[package.metadata.gpui-app]")); + } + + #[test] + fn suggests_hyphenated_key_for_underscore_typo() { + let error = parse( + "[package]\nname = \"example\"\n[package.metadata.gpui_app]\napp_id = \"com.example.app\"\ndisplay_name = \"Example\"\n", + ) + .expect_err("underscore spelling should not parse"); + let message = error.to_string(); + assert!(message.contains("gpui_app")); + assert!(message.contains("gpui-app")); + } + + #[test] + fn reports_malformed_toml() { + let error = parse("[package\nname = ???").expect_err("malformed TOML"); + assert!(error.to_string().contains("failed to parse Cargo manifest")); + } + + #[test] + fn reports_missing_package_table() { + let error = parse("title = \"not a package\"\n").expect_err("missing package"); + assert!(error.to_string().contains("missing the `[package]` table")); + } + + #[test] + fn rejects_version_in_identity_metadata() { + let error = parse( + "[package]\nname = \"example\"\n[package.metadata.gpui-app]\napp_id = \"com.example.app\"\ndisplay_name = \"Example\"\nversion = \"1.0.0\"\n", + ) + .expect_err("version has a canonical Cargo source"); + assert!(error.to_string().contains("unknown field `version`")); + } +} diff --git a/crates/app-manifest/src/schema.rs b/crates/app-manifest/src/schema.rs new file mode 100644 index 00000000..a733baaf --- /dev/null +++ b/crates/app-manifest/src/schema.rs @@ -0,0 +1,346 @@ +//! Owned manifest schema and const-friendly borrowed codegen types. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::ManifestError; + +/// Application identity read from `[package.metadata.gpui-app]`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct AppIdentity { + pub app_id: String, + pub display_name: String, + pub data_namespace: String, + #[serde(default)] + pub binary_name: Option, + #[serde(default)] + pub org: Option, + #[serde(default)] + pub publisher: Option, + #[serde(default)] + pub url_schemes: Vec, + #[serde(default)] + pub categories: Vec, + #[serde(default)] + pub macos: Option, + #[serde(default)] + pub linux: Option, + #[serde(default)] + pub windows: Option, + #[serde(default)] + pub legacy_ids: Vec, + #[serde(default)] + pub min_os: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AppIdentityWire { + app_id: String, + display_name: String, + data_namespace: Option, + #[serde(default)] + binary_name: Option, + #[serde(default)] + org: Option, + #[serde(default)] + publisher: Option, + #[serde(default)] + url_schemes: Vec, + #[serde(default)] + categories: Vec, + #[serde(default)] + macos: Option, + #[serde(default)] + linux: Option, + #[serde(default)] + windows: Option, + #[serde(default)] + legacy_ids: Vec, + #[serde(default)] + min_os: Option, +} + +impl<'de> Deserialize<'de> for AppIdentity { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let wire = AppIdentityWire::deserialize(deserializer)?; + let data_namespace = wire.data_namespace.unwrap_or_else(|| { + wire.app_id + .rsplit('.') + .next() + .unwrap_or(&wire.app_id) + .to_owned() + }); + Ok(Self { + app_id: wire.app_id, + display_name: wire.display_name, + data_namespace, + binary_name: wire.binary_name, + org: wire.org, + publisher: wire.publisher, + url_schemes: wire.url_schemes, + categories: wire.categories, + macos: wire.macos, + linux: wire.linux, + windows: wire.windows, + legacy_ids: wire.legacy_ids, + min_os: wire.min_os, + }) + } +} + +/// macOS-specific identity metadata. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MacosIdentity { + #[serde(default)] + pub category: Option, + #[serde(default)] + pub entitlements: Vec, + #[serde(default)] + pub usage_strings: BTreeMap, +} + +/// Linux-specific identity metadata. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LinuxIdentity { + #[serde(default)] + pub categories: Vec, + #[serde(default)] + pub desktop_keywords: Vec, +} + +/// Windows-specific identity metadata. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WindowsIdentity { + #[serde(default)] + pub publisher: Option, +} + +/// Per-platform minimum OS version strings. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MinOsVersions { + #[serde(default)] + pub macos: Option, + #[serde(default)] + pub windows: Option, + #[serde(default)] + pub linux: Option, +} + +impl AppIdentity { + /// Validates identity fields with portable, target-independent rules. + pub fn validate(&self) -> Result<(), ManifestError> { + validate_app_id("app_id", &self.app_id)?; + if self.display_name.trim().is_empty() { + return Err(ManifestError::EmptyDisplayName { + value: self.display_name.clone(), + }); + } + // An omitted namespace is derived from `app_id` (never empty). An + // explicit empty value would otherwise pass here and only fail later in + // `AppShell`, so reject it at the manifest boundary. + if self.data_namespace.trim().is_empty() { + return Err(ManifestError::EmptyDataNamespace { + value: self.data_namespace.clone(), + }); + } + for scheme in &self.url_schemes { + if !is_valid_url_scheme(scheme) { + return Err(ManifestError::InvalidUrlScheme { + value: scheme.clone(), + }); + } + } + for (index, legacy_id) in self.legacy_ids.iter().enumerate() { + validate_app_id(&format!("legacy_ids[{index}]"), legacy_id)?; + } + Ok(()) + } + + /// Returns the explicit data namespace, or the final `app_id` segment. + /// + /// The fallback is never derived from `display_name`. Once an app ships, + /// persist this value explicitly and never change it, including when `app_id` + /// changes. + #[must_use] + pub fn resolved_data_namespace(&self) -> &str { + &self.data_namespace + } +} + +fn validate_app_id(field: &str, value: &str) -> Result<(), ManifestError> { + // `app_id` becomes the macOS `CFBundleIdentifier`, which permits only + // alphanumerics, hyphens, and periods. Underscores would pass a looser + // grammar here but fail code signing and App Store validation, so they are + // rejected up front. + let segments: Vec<_> = value.split('.').collect(); + let valid = segments.len() >= 2 + && segments.iter().all(|segment| { + segment.is_ascii() + && segment + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphabetic) + && segment + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }); + if valid { + Ok(()) + } else { + Err(ManifestError::InvalidAppId { + field: field.to_owned(), + value: value.to_owned(), + }) + } +} + +fn is_valid_url_scheme(value: &str) -> bool { + value.is_ascii() + && value.as_bytes().first().is_some_and(u8::is_ascii_lowercase) + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'+' | b'-' | b'.') + }) +} + +/// Const-friendly borrowed application identity emitted into a consuming app. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct IdentityRef { + pub app_id: &'static str, + pub display_name: &'static str, + pub data_namespace: &'static str, + pub binary_name: Option<&'static str>, + pub org: Option<&'static str>, + pub publisher: Option<&'static str>, + pub url_schemes: &'static [&'static str], + pub categories: &'static [&'static str], + pub macos: Option, + pub linux: Option, + pub windows: Option, + pub legacy_ids: &'static [&'static str], + pub min_os: Option, + pub version: &'static str, + pub cfbundle_short_version: &'static str, + pub msix_version: &'static str, +} + +/// Borrowed macOS-specific identity metadata. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MacosIdentityRef { + pub category: Option<&'static str>, + pub entitlements: &'static [&'static str], + pub usage_strings: &'static [(&'static str, &'static str)], +} + +/// Borrowed Linux-specific identity metadata. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LinuxIdentityRef { + pub categories: &'static [&'static str], + pub desktop_keywords: &'static [&'static str], +} + +/// Borrowed Windows-specific identity metadata. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct WindowsIdentityRef { + pub publisher: Option<&'static str>, +} + +/// Borrowed per-platform minimum OS version strings. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MinOsVersionsRef { + pub macos: Option<&'static str>, + pub windows: Option<&'static str>, + pub linux: Option<&'static str>, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn identity(app_id: &str, display_name: &str) -> AppIdentity { + AppIdentity { + app_id: app_id.to_owned(), + display_name: display_name.to_owned(), + data_namespace: app_id.rsplit('.').next().unwrap_or(app_id).to_owned(), + binary_name: None, + org: None, + publisher: None, + url_schemes: Vec::new(), + categories: Vec::new(), + macos: None, + linux: None, + windows: None, + legacy_ids: Vec::new(), + min_os: None, + } + } + + #[test] + fn validates_app_id_and_display_name() { + assert!(identity("com.example", "Example").validate().is_ok()); + assert!(identity("com.example-app", "Example").validate().is_ok()); + assert!(identity("example", "Example").validate().is_err()); + assert!(identity("com.exa$mple", "Example").validate().is_err()); + assert!(identity("com.example", "").validate().is_err()); + assert!(identity("com.example", " \n").validate().is_err()); + } + + #[test] + fn rejects_underscore_in_app_id() { + // `app_id` becomes the macOS CFBundleIdentifier, which forbids `_`. + let error = identity("com.example.my_app", "Example") + .validate() + .expect_err("underscore is invalid in CFBundleIdentifier"); + assert!(error.to_string().contains("CFBundleIdentifier")); + } + + #[test] + fn validates_url_schemes() { + for scheme in ["example", "example+dev", "example-2.0"] { + let mut value = identity("com.example", "Example"); + value.url_schemes.push(scheme.to_owned()); + assert!(value.validate().is_ok(), "scheme {scheme:?} should pass"); + } + for scheme in ["Example", "example app", "example_app", "1example"] { + let mut value = identity("com.example", "Example"); + value.url_schemes.push(scheme.to_owned()); + assert!(value.validate().is_err(), "scheme {scheme:?} should fail"); + } + } + + #[test] + fn rejects_explicit_empty_data_namespace() { + let mut value = identity("com.example.app", "Example"); + assert!(value.validate().is_ok()); + value.data_namespace = String::new(); + assert!(value.validate().is_err()); + value.data_namespace = " ".to_owned(); + assert!(value.validate().is_err()); + } + + #[test] + fn resolves_stable_data_namespace() { + let mut value = identity("com.example.my_app", "Mutable Name"); + assert_eq!(value.resolved_data_namespace(), "my_app"); + value.data_namespace = "stable-data".to_owned(); + assert_eq!(value.resolved_data_namespace(), "stable-data"); + } + + #[test] + fn validates_legacy_ids_like_app_id() { + let mut value = identity("com.example.current", "Example"); + value.legacy_ids.push("org.example.previous".to_owned()); + assert!(value.validate().is_ok()); + value.legacy_ids.push("invalid".to_owned()); + let error = value.validate().expect_err("single-segment legacy ID"); + assert!(error.to_string().contains("legacy_ids[1]")); + } +} diff --git a/crates/app-manifest/src/versions.rs b/crates/app-manifest/src/versions.rs new file mode 100644 index 00000000..74088975 --- /dev/null +++ b/crates/app-manifest/src/versions.rs @@ -0,0 +1,257 @@ +//! Deterministic platform packaging version derivation. + +use crate::ManifestError; + +/// Derives Apple's `CFBundleShortVersionString` from canonical SemVer. +/// +/// Apple requires `CFBundleShortVersionString` to use exactly three dotted +/// integers. SemVer prerelease and build metadata are therefore removed. +/// +/// # Errors +/// +/// Returns [`ManifestError::InvalidSemver`] when `semver` is malformed. +pub fn derive_cfbundle_short_version(semver: &str) -> Result { + let core = parse_semver(semver)?; + Ok(format!("{}.{}.{}", core.major, core.minor, core.patch)) +} + +/// The packaging target that determines the MSIX version-component rules. +/// +/// The two targets cannot share one version string: the Store requires a +/// nonzero first component and reserves the fourth, while sideloaded packages +/// are free to carry a build number in the fourth component. +/// +/// See the MSIX app package requirements: +/// +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MsixTarget { + /// Microsoft Store submission. The Store requires a nonzero first component + /// (major) and reserves the fourth component, which must be zero. + Store, + /// Sideloaded or otherwise non-Store MSIX. The fourth component may carry a + /// build number. + Sideload, +} + +/// Derives the four-part numeric MSIX package version for the given `target`. +/// +/// For [`MsixTarget::Store`], the fourth component is always zero (the Store +/// reserves and overwrites it) and a zero `major` is rejected, because Store +/// packages require a nonzero first component; `build` is ignored. For +/// [`MsixTarget::Sideload`], `build` fills the fourth component and defaults to +/// zero. +/// +/// See the MSIX app package requirements: +/// +/// +/// # Errors +/// +/// Returns [`ManifestError::MsixStoreZeroMajor`] when `target` is +/// [`MsixTarget::Store`] and `major` is zero, [`ManifestError::InvalidSemver`] +/// for malformed SemVer, or [`ManifestError::InvalidMsixComponent`] for any +/// component above 65535. +pub fn derive_msix_version( + semver: &str, + target: MsixTarget, + build: Option, +) -> Result { + let core = parse_semver(semver)?; + let build = match target { + MsixTarget::Store => { + if core.major == 0 { + return Err(ManifestError::MsixStoreZeroMajor { + value: semver.to_owned(), + }); + } + 0 + } + MsixTarget::Sideload => u64::from(build.unwrap_or(0)), + }; + for (field, value) in [ + ("major", core.major), + ("minor", core.minor), + ("patch", core.patch), + ("build", build), + ] { + if value > u64::from(u16::MAX) { + return Err(ManifestError::InvalidMsixComponent { field, value }); + } + } + Ok(format!( + "{}.{}.{}.{}", + core.major, core.minor, core.patch, build + )) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct SemverCore { + major: u64, + minor: u64, + patch: u64, +} + +fn parse_semver(value: &str) -> Result { + let (without_build, build) = split_build(value)?; + if let Some(build) = build { + validate_identifiers(value, build, false, "build metadata")?; + } + let (core, prerelease) = without_build + .split_once('-') + .map_or((without_build, None), |(core, suffix)| (core, Some(suffix))); + if let Some(prerelease) = prerelease { + validate_identifiers(value, prerelease, true, "prerelease")?; + } + + let mut parts = core.split('.'); + let major = parse_core_part(value, parts.next(), "major")?; + let minor = parse_core_part(value, parts.next(), "minor")?; + let patch = parse_core_part(value, parts.next(), "patch")?; + if parts.next().is_some() { + return invalid_semver(value, "expected exactly MAJOR.MINOR.PATCH"); + } + Ok(SemverCore { + major, + minor, + patch, + }) +} + +fn split_build(value: &str) -> Result<(&str, Option<&str>), ManifestError> { + let Some((head, tail)) = value.split_once('+') else { + return Ok((value, None)); + }; + if tail.contains('+') { + return invalid_semver(value, "multiple '+' separators"); + } + Ok((head, Some(tail))) +} + +fn parse_core_part(input: &str, part: Option<&str>, field: &str) -> Result { + let Some(part) = part else { + return invalid_semver(input, "expected exactly MAJOR.MINOR.PATCH"); + }; + if part.is_empty() || !part.bytes().all(|byte| byte.is_ascii_digit()) { + return invalid_semver(input, &format!("{field} must be an unsigned integer")); + } + if part.len() > 1 && part.starts_with('0') { + return invalid_semver(input, &format!("{field} must not contain leading zeros")); + } + part.parse().map_err(|error| ManifestError::InvalidSemver { + value: input.to_owned(), + reason: format!("{field} is outside the supported integer range: {error}"), + }) +} + +fn validate_identifiers( + input: &str, + suffix: &str, + reject_numeric_leading_zero: bool, + field: &str, +) -> Result<(), ManifestError> { + for identifier in suffix.split('.') { + let valid_chars = !identifier.is_empty() + && identifier + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'); + let invalid_numeric_zero = reject_numeric_leading_zero + && identifier.len() > 1 + && identifier.bytes().all(|byte| byte.is_ascii_digit()) + && identifier.starts_with('0'); + if !valid_chars || invalid_numeric_zero { + return invalid_semver(input, &format!("invalid {field} identifier {identifier:?}")); + } + } + Ok(()) +} + +fn invalid_semver(value: &str, reason: &str) -> Result { + Err(ManifestError::InvalidSemver { + value: value.to_owned(), + reason: reason.to_owned(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derives_platform_versions_from_valid_semver() { + for value in [ + "1.2.3", + "1.2.3-beta.1", + "1.2.3+abc", + "1.2.3-beta.1+abc123", + "1.2.3-alpha-beta", + ] { + assert_eq!(derive_cfbundle_short_version(value).unwrap(), "1.2.3"); + assert_eq!( + derive_msix_version(value, MsixTarget::Sideload, None).unwrap(), + "1.2.3.0" + ); + assert_eq!( + derive_msix_version(value, MsixTarget::Store, None).unwrap(), + "1.2.3.0" + ); + } + assert_eq!( + derive_msix_version("1.2.3", MsixTarget::Sideload, Some(42)).unwrap(), + "1.2.3.42" + ); + } + + #[test] + fn store_reserves_fourth_component_and_rejects_zero_major() { + // The Store overwrites the fourth component, so `build` is ignored. + assert_eq!( + derive_msix_version("1.2.3", MsixTarget::Store, Some(42)).unwrap(), + "1.2.3.0" + ); + // The Store requires a nonzero first component; sideload allows it. + assert!(matches!( + derive_msix_version("0.4.2", MsixTarget::Store, None), + Err(ManifestError::MsixStoreZeroMajor { .. }) + )); + assert_eq!( + derive_msix_version("0.4.2", MsixTarget::Sideload, Some(7)).unwrap(), + "0.4.2.7" + ); + } + + #[test] + fn accepts_max_msix_component_and_rejects_overflow() { + // 65535 is the largest value each MSIX component may hold. + assert_eq!( + derive_msix_version("1.2.65535", MsixTarget::Sideload, None).unwrap(), + "1.2.65535.0" + ); + // 65536 overflows the patch component. + assert!(matches!( + derive_msix_version("1.2.65536", MsixTarget::Sideload, None), + Err(ManifestError::InvalidMsixComponent { + field: "patch", + value: 65536 + }) + )); + // The sideload build component is validated on the same boundary. + assert_eq!( + derive_msix_version("1.2.3", MsixTarget::Sideload, Some(65535)).unwrap(), + "1.2.3.65535" + ); + assert!(matches!( + derive_msix_version("1.2.3", MsixTarget::Sideload, Some(65536)), + Err(ManifestError::InvalidMsixComponent { + field: "build", + value: 65536 + }) + )); + } + + #[test] + fn rejects_malformed_semver_without_panicking() { + for value in ["1.2", "not-a-version", "01.2.3", "1.2.3-beta..1"] { + assert!(derive_cfbundle_short_version(value).is_err()); + assert!(derive_msix_version(value, MsixTarget::Sideload, None).is_err()); + } + } +} diff --git a/crates/app-manifest/tests/downstream.rs b/crates/app-manifest/tests/downstream.rs new file mode 100644 index 00000000..b34d4653 --- /dev/null +++ b/crates/app-manifest/tests/downstream.rs @@ -0,0 +1,33 @@ +use std::process::Command; + +#[test] +fn consuming_app_generates_identity_from_its_own_manifest() { + let fixture = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/downstream-app"); + let target = tempfile::tempdir().expect("create isolated target directory"); + // The fixture is a separate workspace with no committed Cargo.lock, so cargo + // resolves its dependency graph fresh here. `--offline`/`--locked` are + // intentionally omitted: pinning fixture-only versions in a committed + // lockfile made this test fail on clean CI runners whose cargo cache holds + // only workspace-locked deps. Network access is permitted for cargo tests on + // CI, matching every other build in the suite; the regenerated Cargo.lock is + // gitignored so it never dirties the tree. + let output = Command::new(env!("CARGO")) + .args(["run", "--quiet", "--target-dir"]) + .arg(target.path()) + .current_dir(fixture) + .output() + .expect("run downstream fixture"); + + assert!( + output.status.success(), + "fixture failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("IDENTITY_OK com.example.downstreamfixture 0.4.2"), + "unexpected stdout: {stdout:?}" + ); +} diff --git a/crates/app-manifest/tests/fixtures/downstream-app/.gitignore b/crates/app-manifest/tests/fixtures/downstream-app/.gitignore new file mode 100644 index 00000000..5a44eef0 --- /dev/null +++ b/crates/app-manifest/tests/fixtures/downstream-app/.gitignore @@ -0,0 +1 @@ +/Cargo.lock diff --git a/crates/app-manifest/tests/fixtures/downstream-app/Cargo.toml b/crates/app-manifest/tests/fixtures/downstream-app/Cargo.toml new file mode 100644 index 00000000..f21b493a --- /dev/null +++ b/crates/app-manifest/tests/fixtures/downstream-app/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "downstream-fixture-app" +version = "0.4.2" +edition = "2024" +publish = false + +[dependencies] +gpui-component-manifest = { path = "../../.." } + +[build-dependencies] +gpui-component-manifest = { path = "../../.." } + +[package.metadata.gpui-app] +app_id = "com.example.downstreamfixture" +display_name = "Downstream Fixture" +url_schemes = ["fixtureapp"] +categories = ["Development"] + +[workspace] diff --git a/crates/app-manifest/tests/fixtures/downstream-app/build.rs b/crates/app-manifest/tests/fixtures/downstream-app/build.rs new file mode 100644 index 00000000..0e513b81 --- /dev/null +++ b/crates/app-manifest/tests/fixtures/downstream-app/build.rs @@ -0,0 +1,3 @@ +fn main() { + gpui_component_manifest::build::emit_identity().expect("invalid [package.metadata.gpui-app]"); +} diff --git a/crates/app-manifest/tests/fixtures/downstream-app/src/main.rs b/crates/app-manifest/tests/fixtures/downstream-app/src/main.rs new file mode 100644 index 00000000..68a49e82 --- /dev/null +++ b/crates/app-manifest/tests/fixtures/downstream-app/src/main.rs @@ -0,0 +1,10 @@ +gpui_component_manifest::include_identity!(); + +fn main() { + assert_eq!(APP_IDENTITY.app_id, "com.example.downstreamfixture"); + assert_eq!(APP_IDENTITY.version, env!("CARGO_PKG_VERSION")); + println!( + "IDENTITY_OK {} {}", + APP_IDENTITY.app_id, APP_IDENTITY.version + ); +} diff --git a/crates/app-storage/Cargo.toml b/crates/app-storage/Cargo.toml new file mode 100644 index 00000000..563f3b1b --- /dev/null +++ b/crates/app-storage/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "gpui-component-storage" +description = "Foundation storage layer for gpui-component apps: platform paths, atomic writes, schema-versioned envelopes, debounced stores. No GPUI dependency." +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +dirs.workspace = true +log.workspace = true +serde.workspace = true +thiserror.workspace = true +toml.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/app-storage/src/atomic.rs b/crates/app-storage/src/atomic.rs new file mode 100644 index 00000000..57156f34 --- /dev/null +++ b/crates/app-storage/src/atomic.rs @@ -0,0 +1,239 @@ +//! Atomic (and optionally crash-durable) file writes. +//! +//! Two guarantees, deliberately distinct: +//! +//! - **Atomic** ([`write_atomic`]): a reader opening the target path sees either +//! the complete previous file or the complete new file, never a torn +//! intermediate. Achieved by writing a uniquely-named temp file in the *same* +//! directory and then renaming it over the target. `std::fs::rename` is atomic +//! within one filesystem on Unix, and on Windows resolves to `MoveFileEx` with +//! `MOVEFILE_REPLACE_EXISTING`, which replaces an existing destination in a +//! single operation. The temp file must share the target's directory so the +//! rename stays within one filesystem. +//! - **Durable** ([`write_atomic_durable`]): additionally `fsync`s the temp file +//! before the rename and (on Unix) `fsync`s the parent directory after, so the +//! replacement survives a power loss / kernel panic. This costs an extra disk +//! flush per write and is only needed for data you cannot afford to lose to an +//! abrupt machine failure. Plain [`write_atomic`] does not promise durability. + +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::error::StorageError; + +/// Monotonic per-process counter guaranteeing distinct temp names even when two +/// writes land within the same nanosecond. +static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// Ensure the parent directory of `path` exists, creating it if necessary. +pub(crate) fn ensure_parent(path: &Path) -> Result<(), StorageError> { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent).map_err(|e| StorageError::io(parent, e))?; + } + } + Ok(()) +} + +/// Compute a unique temp path in the same directory as `path`. +fn temp_path_for(path: &Path) -> PathBuf { + let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let pid = std::process::id(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + + let file_name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "tmp".to_string()); + let temp_name = format!(".{file_name}.tmp.{pid}.{counter}.{nanos}"); + + match path.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent.join(temp_name), + _ => PathBuf::from(temp_name), + } +} + +/// Write `bytes` to `path` atomically (no torn reads). See the module docs for +/// the atomic-vs-durable distinction — this does **not** promise crash durability. +pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), StorageError> { + write_impl(path, bytes, false) +} + +/// Write `bytes` to `path` atomically *and* durably: the file is `fsync`ed +/// before the rename and (on Unix) the parent directory is `fsync`ed after, so +/// the write survives power loss. Slower than [`write_atomic`]. +pub fn write_atomic_durable(path: &Path, bytes: &[u8]) -> Result<(), StorageError> { + write_impl(path, bytes, true) +} + +fn write_impl(path: &Path, bytes: &[u8], durable: bool) -> Result<(), StorageError> { + ensure_parent(path)?; + let temp = temp_path_for(path); + + // Write the staging file. The handle is scoped inside the closure so it is + // closed before any rename/remove — on Windows an open handle can block + // replacing or deleting the file. + let staged = (|| { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp) + .map_err(|e| StorageError::io(&temp, e))?; + file.write_all(bytes) + .map_err(|e| StorageError::io(&temp, e))?; + if durable { + file.sync_all().map_err(|e| StorageError::io(&temp, e))?; + } + Ok(()) + })(); + + if let Err(e) = staged { + // A failed write/sync (e.g. disk full) must not leave a partial temp + // file behind. Best-effort cleanup; the create_new failure case leaves + // nothing to remove. + let _ = fs::remove_file(&temp); + return Err(e); + } + + // Preserve the target's restrictive permissions across the replacement. The + // staging file was created under the process umask (typically `0644`), so + // renaming it over a `0600` settings/secrets file would silently widen it to + // world-readable. New files get a restrictive `0600` default. + #[cfg(unix)] + if let Err(e) = preserve_permissions(path, &temp) { + let _ = fs::remove_file(&temp); + return Err(e); + } + + if let Err(e) = fs::rename(&temp, path) { + // Clean up the orphaned temp file; ignore any failure removing it. + let _ = fs::remove_file(&temp); + return Err(StorageError::io(path, e)); + } + + if durable { + sync_parent_dir(path)?; + } + + Ok(()) +} + +/// Match the staging file's permissions to the target's before the rename, so an +/// atomic replacement never widens a restrictive mode. A not-yet-existing target +/// gets a restrictive `0600` default; genuine `stat` failures are propagated. +#[cfg(unix)] +fn preserve_permissions(target: &Path, temp: &Path) -> Result<(), StorageError> { + use std::os::unix::fs::PermissionsExt; + + let mode = match fs::metadata(target) { + Ok(meta) => meta.permissions().mode() & 0o777, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => 0o600, + Err(e) => return Err(StorageError::io(target, e)), + }; + fs::set_permissions(temp, fs::Permissions::from_mode(mode)) + .map_err(|e| StorageError::io(temp, e)) +} + +/// `fsync` the parent directory so the rename entry itself is durable. +/// +/// On Unix, failures are propagated so [`write_atomic_durable`] never reports a +/// durability guarantee it did not achieve. On non-Unix this is a no-op: Windows +/// has no directory-fsync primitive, so the parent-entry durability of the +/// rename is left to the filesystem (the file contents were still synced). +fn sync_parent_dir(path: &Path) -> Result<(), StorageError> { + #[cfg(unix)] + { + let parent = path.parent().unwrap_or_else(|| Path::new("")); + let dir = if parent.as_os_str().is_empty() { + Path::new(".") + } else { + parent + }; + let f = fs::File::open(dir).map_err(|e| StorageError::io(dir, e))?; + f.sync_all().map_err(|e| StorageError::io(dir, e))?; + Ok(()) + } + #[cfg(not(unix))] + { + let _ = path; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn writes_and_leaves_no_temp_file() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("data.bin"); + write_atomic(&path, b"hello").unwrap(); + + assert_eq!(fs::read(&path).unwrap(), b"hello"); + // No sibling temp files remain. + let leftovers: Vec<_> = fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().contains(".tmp.")) + .collect(); + assert!(leftovers.is_empty(), "temp files leaked: {leftovers:?}"); + } + + #[test] + fn overwrite_replaces_atomically() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("data.bin"); + write_atomic(&path, b"first").unwrap(); + write_atomic(&path, b"second").unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"second"); + } + + #[test] + fn creates_missing_parent_dirs() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("a").join("b").join("data.bin"); + write_atomic(&path, b"x").unwrap(); + assert!(path.exists()); + } + + #[test] + fn durable_round_trips() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("durable.bin"); + write_atomic_durable(&path, b"safe").unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"safe"); + } + + #[cfg(unix)] + #[test] + fn preserves_restrictive_permissions_on_overwrite() { + use std::os::unix::fs::PermissionsExt; + + let dir = TempDir::new().unwrap(); + let path = dir.path().join("secret.bin"); + + // A brand-new file is created with a restrictive default. + write_atomic(&path, b"first").unwrap(); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + + // Tighten further, then overwrite: the mode must survive the rename. + fs::set_permissions(&path, fs::Permissions::from_mode(0o400)).unwrap(); + write_atomic(&path, b"second").unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"second"); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o400 + ); + } +} diff --git a/crates/app-storage/src/envelope.rs b/crates/app-storage/src/envelope.rs new file mode 100644 index 00000000..80bd3850 --- /dev/null +++ b/crates/app-storage/src/envelope.rs @@ -0,0 +1,354 @@ +//! Schema-versioned TOML envelope with explicit load outcomes. +//! +//! Every persisted document is wrapped in an [`Envelope`] that stamps a +//! `schema_version` alongside the inner payload (flattened, so the on-disk TOML +//! stays flat). On load, the version drives one of five explicit outcomes +//! ([`LoadOutcome`]) so callers can migrate, refuse, or recover deliberately — +//! in particular a *newer* on-disk version is never treated as corruption and +//! is never overwritten. + +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +use crate::atomic::{write_atomic, write_atomic_durable}; +use crate::error::StorageError; + +/// Owned envelope used when deserializing a current-version document. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Envelope { + /// On-disk schema version of the payload. + pub schema_version: u32, + /// The wrapped payload, flattened into the same TOML table. + #[serde(flatten)] + pub inner: T, +} + +/// Borrowing envelope used for serialization so we never clone the payload. +#[derive(Serialize)] +struct EnvelopeRef<'a, T> { + schema_version: u32, + #[serde(flatten)] + inner: &'a T, +} + +/// The five distinct results of loading a versioned document. +#[derive(Debug)] +#[non_exhaustive] +pub enum LoadOutcome { + /// File is present, current-version, and deserialized cleanly. + Loaded(T), + /// File is present but an *older* version. Carries the raw TOML so the + /// caller can run a migration chain. + NeedsMigration { + /// Version found on disk (`< current`). + found: u32, + /// The raw parsed document, for migration. + raw: toml::Value, + }, + /// File is present but a *newer* version. The file is left untouched — the + /// caller must refuse to write so newer data is never clobbered. + FutureVersion { + /// Version found on disk (`> current`). + found: u32, + }, + /// File is present but malformed (unparsable or missing version). It has + /// been archived aside per recovery policy. + Corrupt { + /// Where the malformed file was moved, if archiving succeeded. + archived_to: Option, + }, + /// No file exists at the path. + Missing, +} + +/// Serialize `value` into a versioned TOML string. +pub fn serialize_envelope( + value: &T, + schema_version: u32, +) -> Result { + let envelope = EnvelopeRef { + schema_version, + inner: value, + }; + Ok(toml::to_string_pretty(&envelope)?) +} + +/// Serialize and atomically write `value` (versioned) to `path`. +pub fn save_envelope( + path: &Path, + value: &T, + schema_version: u32, +) -> Result<(), StorageError> { + let contents = serialize_envelope(value, schema_version)?; + write_atomic(path, contents.as_bytes()) +} + +/// Like [`save_envelope`] but crash-durable (`fsync`). +pub fn save_envelope_durable( + path: &Path, + value: &T, + schema_version: u32, +) -> Result<(), StorageError> { + let contents = serialize_envelope(value, schema_version)?; + write_atomic_durable(path, contents.as_bytes()) +} + +/// Load and classify the document at `path` against `current_version`. +/// +/// Genuine filesystem failures (other than "not found") are returned as `Err`; +/// everything else maps onto a [`LoadOutcome`]. A malformed file — unparsable +/// TOML *or* invalid UTF-8 bytes — is archived to `.bak.v` (or a +/// timestamped `.bak.corrupt.` when the version is unknown) before +/// returning [`LoadOutcome::Corrupt`], so it stays backup-recoverable. +pub fn load_envelope( + path: &Path, + current_version: u32, +) -> Result, StorageError> { + // Read bytes rather than `read_to_string`: the latter reports invalid UTF-8 + // as `ErrorKind::InvalidData`, which would surface as an `Err` and bypass + // the corrupt-archive/recovery path. A bad byte sequence is corruption, not + // a filesystem error. + let bytes = match std::fs::read(path) { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(LoadOutcome::Missing), + Err(e) => return Err(StorageError::io(path, e)), + }; + + let contents = match String::from_utf8(bytes) { + Ok(s) => s, + Err(_) => { + let archived_to = archive_corrupt(path, None); + return Ok(LoadOutcome::Corrupt { archived_to }); + } + }; + + let value: toml::Value = match toml::from_str(&contents) { + Ok(v) => v, + Err(_) => { + let archived_to = archive_corrupt(path, None); + return Ok(LoadOutcome::Corrupt { archived_to }); + } + }; + + let found = match value + .get("schema_version") + .and_then(toml::Value::as_integer) + { + // Values above `u32::MAX` cannot be smaller than any real (u32) current + // version, so they are unambiguously "future": refuse to write, never + // archive. We report `u32::MAX` rather than truncating the raw integer, + // which would otherwise wrap to a small number and be misclassified as a + // migration candidate. + Some(n) if n > u32::MAX as i64 => { + return Ok(LoadOutcome::FutureVersion { found: u32::MAX }); + } + Some(n) => match u32::try_from(n) { + Ok(v) => v, + // Negative version: malformed, not a real schema. + Err(_) => { + let archived_to = archive_corrupt(path, None); + return Ok(LoadOutcome::Corrupt { archived_to }); + } + }, + None => { + // No usable version field — treat as malformed. + let archived_to = archive_corrupt(path, None); + return Ok(LoadOutcome::Corrupt { archived_to }); + } + }; + + if found > current_version { + return Ok(LoadOutcome::FutureVersion { found }); + } + if found < current_version { + return Ok(LoadOutcome::NeedsMigration { found, raw: value }); + } + + // Current version: deserialize the payload. + match value.try_into::>() { + Ok(envelope) => Ok(LoadOutcome::Loaded(envelope.inner)), + Err(_) => { + let archived_to = archive_corrupt(path, Some(found)); + Ok(LoadOutcome::Corrupt { archived_to }) + } + } +} + +/// Move a malformed file aside. Returns the archive path on success, `None` if +/// the rename failed (archiving is best-effort — losing the ability to archive +/// must not mask the corruption itself). +fn archive_corrupt(path: &Path, version: Option) -> Option { + let suffix = match version { + Some(v) => format!(".bak.v{v}"), + None => { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!(".bak.corrupt.{nanos}") + } + }; + let mut target = path.as_os_str().to_os_string(); + target.push(suffix); + let target = PathBuf::from(target); + + match std::fs::rename(path, &target) { + Ok(()) => Some(target), + Err(e) => { + log::warn!( + "failed to archive corrupt file {} to {}: {e}", + path.display(), + target.display() + ); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + use tempfile::TempDir; + + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] + struct Doc { + name: String, + count: u32, + } + + fn doc() -> Doc { + Doc { + name: "widget".to_string(), + count: 7, + } + } + + #[test] + fn missing_when_no_file() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("cfg.toml"); + let outcome = load_envelope::(&path, 1).unwrap(); + assert!(matches!(outcome, LoadOutcome::Missing)); + } + + #[test] + fn loaded_current_version_round_trips() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("cfg.toml"); + save_envelope(&path, &doc(), 3).unwrap(); + + match load_envelope::(&path, 3).unwrap() { + LoadOutcome::Loaded(value) => assert_eq!(value, doc()), + other => panic!("expected Loaded, got {other:?}"), + } + } + + #[test] + fn older_version_needs_migration_with_raw() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("cfg.toml"); + std::fs::write(&path, "schema_version = 1\nname = \"old\"\ncount = 2\n").unwrap(); + + match load_envelope::(&path, 3).unwrap() { + LoadOutcome::NeedsMigration { found, raw } => { + assert_eq!(found, 1); + assert_eq!(raw.get("name").unwrap().as_str(), Some("old")); + } + other => panic!("expected NeedsMigration, got {other:?}"), + } + } + + #[test] + fn newer_version_is_preserved_untouched() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("cfg.toml"); + let on_disk = "schema_version = 9\nname = \"future\"\ncount = 100\n"; + std::fs::write(&path, on_disk).unwrap(); + + match load_envelope::(&path, 3).unwrap() { + LoadOutcome::FutureVersion { found } => assert_eq!(found, 9), + other => panic!("expected FutureVersion, got {other:?}"), + } + + // A refused load must not modify or archive the newer file. + assert_eq!(std::fs::read_to_string(&path).unwrap(), on_disk); + assert!(!path.with_extension("toml.bak.v9").exists()); + } + + #[test] + fn malformed_is_archived_and_reported_corrupt() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("cfg.toml"); + std::fs::write(&path, "this is = = not valid toml").unwrap(); + + match load_envelope::(&path, 3).unwrap() { + LoadOutcome::Corrupt { archived_to } => { + let archived = archived_to.expect("corrupt file should be archived"); + assert!(archived.exists()); + assert!(!path.exists(), "corrupt primary moved aside"); + } + other => panic!("expected Corrupt, got {other:?}"), + } + } + + #[test] + fn missing_version_field_is_corrupt() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("cfg.toml"); + std::fs::write(&path, "name = \"x\"\ncount = 1\n").unwrap(); + assert!(matches!( + load_envelope::(&path, 1).unwrap(), + LoadOutcome::Corrupt { .. } + )); + } + + #[test] + fn version_above_u32_max_is_future_not_migration() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("cfg.toml"); + // u32::MAX + 1: naive `as u32` would wrap to 0 and look like an ancient + // migration candidate; it must instead be treated as a future version. + let on_disk = "schema_version = 4294967296\nname = \"x\"\ncount = 1\n"; + std::fs::write(&path, on_disk).unwrap(); + + match load_envelope::(&path, 3).unwrap() { + LoadOutcome::FutureVersion { found } => assert_eq!(found, u32::MAX), + other => panic!("expected FutureVersion, got {other:?}"), + } + // Untouched — a future version is never archived. + assert_eq!(std::fs::read_to_string(&path).unwrap(), on_disk); + } + + #[test] + fn negative_version_is_corrupt() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("cfg.toml"); + std::fs::write(&path, "schema_version = -1\nname = \"x\"\ncount = 1\n").unwrap(); + assert!(matches!( + load_envelope::(&path, 3).unwrap(), + LoadOutcome::Corrupt { .. } + )); + } + + #[test] + fn invalid_utf8_is_corrupt_not_io_error() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("cfg.toml"); + // Invalid UTF-8 byte sequence: `read_to_string` would surface this as an + // I/O error; it must instead be archived as corruption. + std::fs::write(&path, [0xff, 0xfe, 0x00, 0x80]).unwrap(); + + match load_envelope::(&path, 3).unwrap() { + LoadOutcome::Corrupt { archived_to } => { + let archived = archived_to.expect("invalid-UTF-8 file should be archived"); + assert!(archived.exists()); + assert!(!path.exists(), "corrupt primary moved aside"); + } + other => panic!("expected Corrupt, got {other:?}"), + } + } +} diff --git a/crates/app-storage/src/error.rs b/crates/app-storage/src/error.rs new file mode 100644 index 00000000..a1cdae16 --- /dev/null +++ b/crates/app-storage/src/error.rs @@ -0,0 +1,77 @@ +//! Error type shared across the storage crate. + +use std::path::PathBuf; + +use thiserror::Error; + +/// Errors produced by the storage layer. +/// +/// Every variant carries enough context (usually the offending path) to be +/// actionable without a separate log line, so callers can surface failures to +/// the user directly. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum StorageError { + /// An I/O operation failed against a specific path. + #[error("i/o error at {path}: {source}")] + Io { + /// Path the failing operation targeted. + path: PathBuf, + /// Underlying OS error. + #[source] + source: std::io::Error, + }, + + /// Serializing a value to TOML failed. + #[error("failed to serialize to TOML: {0}")] + Serialize(#[from] toml::ser::Error), + + /// Deserializing a value from TOML failed. + #[error("failed to deserialize TOML: {0}")] + Deserialize(#[from] toml::de::Error), + + /// A platform directory could not be resolved for the given namespace. + #[error("could not resolve {kind} directory for namespace {namespace:?}")] + PathResolution { + /// Which logical directory failed to resolve (e.g. `"config"`). + kind: &'static str, + /// The app namespace that was being resolved. + namespace: String, + }, + + /// Another process (or another in-process store) already holds the + /// single-writer lock for this store. + #[error("another writer holds the lock for {path}{}", owner_pid.map(|p| format!(" (owner pid {p})")).unwrap_or_default())] + WriterConflict { + /// The lock file that is already held. + path: PathBuf, + /// PID recorded in the lock file, if it could be read. + owner_pid: Option, + }, + + /// The on-disk schema is newer than this build supports; the caller must + /// refuse to write so the newer data is never clobbered. + #[error( + "refusing to write: on-disk schema version {found} is newer than supported {supported}" + )] + FutureVersion { + /// Version found on disk. + found: u32, + /// Highest version this build understands. + supported: u32, + }, + + /// The background store worker has stopped and can no longer accept work. + #[error("storage worker has stopped")] + WorkerStopped, +} + +impl StorageError { + /// Build an [`StorageError::Io`] tagged with the path it occurred at. + pub(crate) fn io(path: impl Into, source: std::io::Error) -> Self { + StorageError::Io { + path: path.into(), + source, + } + } +} diff --git a/crates/app-storage/src/lib.rs b/crates/app-storage/src/lib.rs new file mode 100644 index 00000000..c9fb5633 --- /dev/null +++ b/crates/app-storage/src/lib.rs @@ -0,0 +1,30 @@ +//! Foundation storage layer for gpui-component apps. +//! +//! Pure, GPUI-free building blocks for persisting application data on disk: +//! +//! - [`paths`] — resolve per-app directories from a stable namespace, under +//! either platform-native locations or a single home-relative root. +//! - [`atomic`] — atomic file writes (no torn reads), with an opt-in +//! crash-durable variant. +//! - [`envelope`] — schema-versioned TOML documents with five explicit load +//! outcomes (loaded / needs-migration / future-version / corrupt / missing). +//! - [`lock`] — a single-writer OS advisory lock so two instances cannot +//! silently clobber each other. +//! - [`store`] — [`DebouncedStore`], a debounced, backup-rotating, +//! single-writer store built from the pieces above. +//! +//! See `docs/learned/app-platform-plan.md` §4a for the contract this implements. + +pub mod atomic; +pub mod envelope; +pub mod error; +pub mod lock; +pub mod paths; +pub mod store; + +pub use atomic::{write_atomic, write_atomic_durable}; +pub use envelope::{Envelope, LoadOutcome}; +pub use error::StorageError; +pub use lock::WriterLock; +pub use paths::{AppPaths, BaseDir, PathLayout}; +pub use store::{DebouncedStore, StoreConfig}; diff --git a/crates/app-storage/src/lock.rs b/crates/app-storage/src/lock.rs new file mode 100644 index 00000000..8319299a --- /dev/null +++ b/crates/app-storage/src/lock.rs @@ -0,0 +1,126 @@ +//! Single-writer guard for a store. +//! +//! # Mechanism +//! +//! A sibling lock file (`.lock`) is opened and an **OS advisory lock** +//! taken on it via `std::fs::File::try_lock` (`flock(LOCK_EX|LOCK_NB)` on Unix, +//! `LockFileEx(LOCKFILE_EXCLUSIVE_LOCK|LOCKFILE_FAIL_IMMEDIATELY)` on Windows). +//! The lock is held for the lifetime of the [`WriterLock`] and released on drop +//! — including on process exit or crash, since the OS drops the lock when the +//! owning process's file handles close. This is why an advisory OS lock is used +//! in preference to a hand-rolled PID file: there is **no stale-lock problem** +//! and no liveness probing to get wrong across platforms. +//! +//! # Failure mode +//! +//! The lock is *advisory*: it only excludes other writers that also go through +//! this API (i.e. other instances of an app built on this crate). It does not +//! stop an unrelated process from clobbering the file with raw `write`. Two app +//! instances contending produce [`StorageError::WriterConflict`] for the loser, +//! never a silent last-writer-wins. The PID of the current holder is written +//! into the lock file purely for diagnostics and surfaced in the error when +//! readable. + +use std::fs::{File, OpenOptions, TryLockError}; +use std::io::{Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; + +use crate::error::StorageError; + +/// Held single-writer lock. Releasing happens on drop. +#[derive(Debug)] +pub struct WriterLock { + #[allow(dead_code)] + path: PathBuf, + file: File, +} + +impl WriterLock { + /// Acquire the writer lock at `lock_path`, creating the file if needed. + /// + /// Returns [`StorageError::WriterConflict`] if another writer already holds + /// it. + pub fn acquire(lock_path: &Path) -> Result { + if let Some(parent) = lock_path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).map_err(|e| StorageError::io(parent, e))?; + } + } + + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(lock_path) + .map_err(|e| StorageError::io(lock_path, e))?; + + match file.try_lock() { + Ok(()) => {} + Err(TryLockError::WouldBlock) => { + let owner_pid = std::fs::read_to_string(lock_path) + .ok() + .and_then(|s| s.trim().parse().ok()); + return Err(StorageError::WriterConflict { + path: lock_path.to_path_buf(), + owner_pid, + }); + } + Err(TryLockError::Error(e)) => return Err(StorageError::io(lock_path, e)), + } + + // Record our PID for diagnostics; failure here is non-fatal. + let _ = write_pid(&file); + + Ok(Self { + path: lock_path.to_path_buf(), + file, + }) + } +} + +impl Drop for WriterLock { + fn drop(&mut self) { + let _ = self.file.unlock(); + } +} + +fn write_pid(file: &File) -> std::io::Result<()> { + let mut handle = file; + handle.set_len(0)?; + handle.seek(SeekFrom::Start(0))?; + write!(handle, "{}", std::process::id())?; + handle.flush()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn second_acquire_conflicts_then_releases_on_drop() { + let dir = TempDir::new().unwrap(); + let lock_path = dir.path().join("store.lock"); + + let held = WriterLock::acquire(&lock_path).unwrap(); + + match WriterLock::acquire(&lock_path) { + Err(StorageError::WriterConflict { owner_pid, .. }) => { + // The holder PID is a best-effort diagnostic: on Windows the + // exclusive lock also blocks reads from other handles, so the + // conflicting acquirer may not be able to read it at all. + #[cfg(unix)] + assert_eq!(owner_pid, Some(std::process::id())); + #[cfg(windows)] + assert!(owner_pid.is_none() || owner_pid == Some(std::process::id())); + } + other => panic!("expected WriterConflict, got {other:?}"), + } + + drop(held); + // Once released, a fresh acquisition succeeds. + let _reacquired = WriterLock::acquire(&lock_path).unwrap(); + } +} diff --git a/crates/app-storage/src/paths.rs b/crates/app-storage/src/paths.rs new file mode 100644 index 00000000..5c5a320f --- /dev/null +++ b/crates/app-storage/src/paths.rs @@ -0,0 +1,391 @@ +//! Platform path resolution. +//! +//! [`AppPaths`] resolves the standard set of per-app directories from a stable +//! `namespace` (never a display name — the namespace is what user data is keyed +//! on and must not change when the product is renamed). +//! +//! Two layouts are supported: +//! +//! - [`PathLayout::PlatformDefault`] follows OS conventions via the `dirs` +//! crate (Application Support on macOS, XDG on Linux, `%APPDATA%` / +//! `%LOCALAPPDATA%` on Windows). +//! - [`PathLayout::SingleRoot`] puts everything under one home-relative +//! directory (e.g. `~/.agent-term`), matching apps that keep a single opaque +//! dotfile tree. +//! +//! Resolution never creates directories; writers create them lazily via +//! [`AppPaths::ensure`]. This keeps a read-only `AppPaths` cheap and side-effect +//! free. + +use std::path::{Component, Path, PathBuf}; + +use crate::error::StorageError; + +/// Reject values that are empty, absolute, or contain traversal (`..`)/`.` +/// components. Such values would escape the platform base directory via +/// `PathBuf::join` (which discards the left side on an absolute right side and +/// walks upward on `..`). +fn validate_relative(kind: &'static str, namespace: &str, value: &str) -> Result<(), StorageError> { + let mut components = 0usize; + for component in Path::new(value).components() { + match component { + Component::Normal(_) => components += 1, + _ => { + return Err(StorageError::PathResolution { + kind, + namespace: namespace.to_string(), + }); + } + } + } + if components == 0 { + // Empty, or only separators/current-dir markers. + return Err(StorageError::PathResolution { + kind, + namespace: namespace.to_string(), + }); + } + Ok(()) +} + +/// How the app's directories map onto the filesystem. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PathLayout { + /// OS-native locations resolved through the `dirs` crate. + PlatformDefault, + /// Everything under a single home-relative directory, e.g. `".agent-term"`. + SingleRoot(String), +} + +/// Selects one of the resolved base directories, for use with +/// [`AppPaths::sub`] and [`AppPaths::ensure`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BaseDir { + /// User configuration. + Config, + /// Roaming / primary application data. + Data, + /// Machine-local application data (distinct from `Data` only on Windows). + LocalData, + /// Disposable cache data. + Cache, + /// Log files. + Log, + /// State that should persist but is not user config (e.g. window layout). + State, + /// Scratch/temporary files scoped to this app. + Temp, +} + +/// Resolved per-app directory set. Cheap to clone; holds no OS handles. +#[derive(Debug, Clone)] +pub struct AppPaths { + namespace: String, + layout: PathLayout, + config_dir: PathBuf, + data_dir: PathBuf, + local_data_dir: PathBuf, + cache_dir: PathBuf, + log_dir: PathBuf, + state_dir: PathBuf, + temp_dir: PathBuf, +} + +fn resolve( + kind: &'static str, + namespace: &str, + base: Option, +) -> Result { + base.map(|b| b.join(namespace)) + .ok_or_else(|| StorageError::PathResolution { + kind, + namespace: namespace.to_string(), + }) +} + +impl AppPaths { + /// Resolve directories for `namespace` under the given `layout`. + /// + /// `namespace` is a stable identifier (e.g. `"Ansible"` or `"agent-term"`), + /// never a localized/display name. No directories are created here. + pub fn new(namespace: &str, layout: PathLayout) -> Result { + validate_relative("namespace", namespace, namespace)?; + match &layout { + PathLayout::PlatformDefault => Self::platform_default(namespace, layout.clone()), + PathLayout::SingleRoot(root) => { + validate_relative("root", namespace, root)?; + Self::single_root(namespace, root.clone(), layout.clone()) + } + } + } + + fn platform_default(namespace: &str, layout: PathLayout) -> Result { + let config_dir = resolve("config", namespace, dirs::config_dir())?; + let data_dir = resolve("data", namespace, dirs::data_dir())?; + let local_data_dir = resolve("local data", namespace, dirs::data_local_dir())?; + let cache_dir = resolve("cache", namespace, dirs::cache_dir())?; + + // `dirs::state_dir()` is only populated on Linux; fall back to data. + let state_dir = match dirs::state_dir() { + Some(base) => base.join(namespace), + None => data_dir.clone(), + }; + + // Logs: macOS has a dedicated `~/Library/Logs`; elsewhere nest under the + // machine-local state/data tree. + let log_dir = { + #[cfg(target_os = "macos")] + { + match dirs::home_dir() { + Some(home) => home.join("Library").join("Logs").join(namespace), + None => state_dir.join("logs"), + } + } + #[cfg(target_os = "windows")] + { + local_data_dir.join("logs") + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + state_dir.join("logs") + } + }; + + let temp_dir = std::env::temp_dir().join(namespace); + + Ok(Self { + namespace: namespace.to_string(), + layout, + config_dir, + data_dir, + local_data_dir, + cache_dir, + log_dir, + state_dir, + temp_dir, + }) + } + + fn single_root( + namespace: &str, + root: String, + layout: PathLayout, + ) -> Result { + let base = dirs::home_dir().map(|h| h.join(&root)).ok_or_else(|| { + StorageError::PathResolution { + kind: "home", + namespace: namespace.to_string(), + } + })?; + + let data_dir = base.join("data"); + Ok(Self { + namespace: namespace.to_string(), + layout, + config_dir: base.join("config"), + local_data_dir: data_dir.clone(), + data_dir, + cache_dir: base.join("cache"), + log_dir: base.join("logs"), + state_dir: base.join("state"), + temp_dir: base.join("tmp"), + }) + } + + /// The stable namespace these paths were resolved from. + pub fn namespace(&self) -> &str { + &self.namespace + } + + /// The layout used to resolve these paths. + pub fn layout(&self) -> &PathLayout { + &self.layout + } + + /// User configuration directory. + pub fn config_dir(&self) -> &Path { + &self.config_dir + } + + /// Roaming / primary application data directory. + pub fn data_dir(&self) -> &Path { + &self.data_dir + } + + /// Machine-local application data directory. Equal to [`data_dir`] except on + /// Windows, where it maps to `%LOCALAPPDATA%`. + /// + /// [`data_dir`]: Self::data_dir + pub fn local_data_dir(&self) -> &Path { + &self.local_data_dir + } + + /// Disposable cache directory. + pub fn cache_dir(&self) -> &Path { + &self.cache_dir + } + + /// Log directory. + pub fn log_dir(&self) -> &Path { + &self.log_dir + } + + /// Persistent (non-config) state directory. + pub fn state_dir(&self) -> &Path { + &self.state_dir + } + + /// App-scoped temporary directory. + pub fn temp_dir(&self) -> &Path { + &self.temp_dir + } + + fn base(&self, base: BaseDir) -> &Path { + match base { + BaseDir::Config => &self.config_dir, + BaseDir::Data => &self.data_dir, + BaseDir::LocalData => &self.local_data_dir, + BaseDir::Cache => &self.cache_dir, + BaseDir::Log => &self.log_dir, + BaseDir::State => &self.state_dir, + BaseDir::Temp => &self.temp_dir, + } + } + + /// Join `name` onto one of the resolved base directories without creating + /// anything. + /// + /// # Errors + /// + /// Returns [`StorageError::PathResolution`] if `name` is empty, absolute, or + /// contains `..` — anything that could discard or escape the base directory. + pub fn sub(&self, base: BaseDir, name: &str) -> Result { + validate_relative("sub-path", &self.namespace, name)?; + Ok(self.base(base).join(name)) + } + + /// Create (if needed) and return one of the base directories. This is the + /// lazy-creation entry point writers use before persisting. + pub fn ensure(&self, base: BaseDir) -> Result { + let dir = self.base(base).to_path_buf(); + std::fs::create_dir_all(&dir).map_err(|e| StorageError::io(&dir, e))?; + Ok(dir) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn platform_default_namespaces_every_dir() { + let paths = AppPaths::new("StorageTestApp", PathLayout::PlatformDefault).unwrap(); + for dir in [ + paths.config_dir(), + paths.data_dir(), + paths.local_data_dir(), + paths.cache_dir(), + paths.log_dir(), + paths.state_dir(), + paths.temp_dir(), + ] { + assert!( + dir.to_string_lossy().contains("StorageTestApp"), + "{} missing namespace", + dir.display() + ); + } + } + + #[test] + fn platform_default_creates_nothing() { + // A per-run unique namespace: no such directory can pre-exist, so a + // regression that creates it on resolution is actually detected (the old + // assertion passed whenever the dir happened to exist and be readable). + let namespace = format!( + "StorageTestUncreated-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + let paths = AppPaths::new(&namespace, PathLayout::PlatformDefault).unwrap(); + // Resolution alone must not touch the filesystem. + assert!( + !paths.config_dir().exists(), + "resolution created {:?}", + paths.config_dir() + ); + } + + #[test] + fn single_root_nests_under_home() { + let home = dirs::home_dir().unwrap(); + let paths = + AppPaths::new("app", PathLayout::SingleRoot(".storage-test".to_string())).unwrap(); + let base = home.join(".storage-test"); + assert_eq!(paths.config_dir(), base.join("config")); + assert_eq!(paths.data_dir(), base.join("data")); + assert_eq!(paths.log_dir(), base.join("logs")); + // local data equals data outside Windows' roaming split. + assert_eq!(paths.local_data_dir(), paths.data_dir()); + } + + #[test] + fn sub_and_ensure() { + // Use a per-run unique root so the test only ever creates and deletes a + // directory it exclusively owns — never a pre-existing user path. + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root_name = format!(".storage-ensure-{}-{}", std::process::id(), nanos); + let paths = AppPaths::new("nested", PathLayout::SingleRoot(root_name.clone())).unwrap(); + + // `sub` is pure path arithmetic, no filesystem access. + assert_eq!( + paths.sub(BaseDir::Data, "profiles").unwrap(), + paths.data_dir().join("profiles") + ); + + // A name that would escape or discard the base directory is rejected. + for bad in ["", "..", "../evil", "/etc/passwd", "."] { + assert!( + matches!( + paths.sub(BaseDir::Data, bad), + Err(StorageError::PathResolution { .. }) + ), + "sub name {bad:?} should be rejected" + ); + } + + // `ensure` creates the directory on demand. + let created = paths.ensure(BaseDir::Cache).unwrap(); + assert!(created.is_dir()); + + // Clean up only the uniquely-named root this test created. + let root = dirs::home_dir().unwrap().join(&root_name); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn rejects_traversing_or_absolute_namespaces() { + for bad in ["", "..", "../evil", "/etc", "."] { + assert!( + matches!( + AppPaths::new(bad, PathLayout::PlatformDefault), + Err(StorageError::PathResolution { .. }) + ), + "namespace {bad:?} should be rejected" + ); + } + // A traversing SingleRoot value is rejected too. + assert!(matches!( + AppPaths::new("ok", PathLayout::SingleRoot("../escape".to_string())), + Err(StorageError::PathResolution { .. }) + )); + // A normal namespace still resolves. + assert!(AppPaths::new("GoodApp", PathLayout::PlatformDefault).is_ok()); + } +} diff --git a/crates/app-storage/src/store.rs b/crates/app-storage/src/store.rs new file mode 100644 index 00000000..93865bb5 --- /dev/null +++ b/crates/app-storage/src/store.rs @@ -0,0 +1,476 @@ +//! Debounced, backup-rotating, single-writer store. +//! +//! [`DebouncedStore`] coalesces rapid [`put`](DebouncedStore::put) calls onto a +//! background worker thread that commits at most once per debounce window. Each +//! commit rotates the previous (known-valid) generation into a `.bak` chain and +//! writes the new document atomically. A [`WriterLock`] taken at construction +//! guarantees a single writer per store. +//! +//! # Persistence guarantees +//! +//! - [`flush`](DebouncedStore::flush) and [`shutdown`](DebouncedStore::shutdown) +//! are synchronous and **return** the commit result — use them to persist at +//! quit. +//! - `Drop` performs a best-effort flush only and swallows errors. It is **not** +//! a persistence API; never rely on it for durability. +//! - Worker I/O/serialization errors are surfaced: a synchronous +//! `flush`/`shutdown` returns them directly, and background (debounce-timer) +//! failures are retained and readable via +//! [`last_error`](DebouncedStore::last_error). + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use serde::Serialize; +use serde::de::DeserializeOwned; + +use crate::atomic; +use crate::envelope::{LoadOutcome, load_envelope, serialize_envelope}; +use crate::error::StorageError; +use crate::lock::WriterLock; + +/// Idle timeout the worker parks at when nothing is pending. +const IDLE_TIMEOUT: Duration = Duration::from_secs(60); + +/// Configuration for a [`DebouncedStore`]. +#[derive(Debug, Clone)] +pub struct StoreConfig { + /// Current schema version stamped on every write. + pub schema_version: u32, + /// How long to coalesce writes before committing. + pub debounce: Duration, + /// Number of backup generations to retain (`.bak`, `.bak.1`, ...). + pub backup_generations: usize, + /// Whether commits are crash-durable (`fsync`). + pub durable: bool, +} + +impl StoreConfig { + /// Sensible defaults: 500ms debounce, 3 backup generations, non-durable. + pub fn new(schema_version: u32) -> Self { + Self { + schema_version, + debounce: Duration::from_millis(500), + backup_generations: 3, + durable: false, + } + } + + /// Set the debounce window. + pub fn debounce(mut self, debounce: Duration) -> Self { + self.debounce = debounce; + self + } + + /// Set the number of retained backup generations. + pub fn backup_generations(mut self, generations: usize) -> Self { + self.backup_generations = generations; + self + } + + /// Enable crash-durable (`fsync`) commits. + pub fn durable(mut self, durable: bool) -> Self { + self.durable = durable; + self + } +} + +/// Immutable commit parameters shared with the worker. +#[derive(Clone)] +struct Backend { + path: PathBuf, + schema_version: u32, + backup_generations: usize, + durable: bool, +} + +impl Backend { + fn commit(&self, value: &T) -> Result<(), StorageError> { + atomic::ensure_parent(&self.path)?; + let contents = serialize_envelope(value, self.schema_version)?; + // Rotate first so a rotation failure aborts before the primary is + // touched. Rotation *copies* the primary into `.bak` (rather than moving + // it), so the primary stays intact and loadable if the write below then + // fails — corrupt-primary recovery would otherwise never trigger for a + // primary that had simply gone missing. + rotate_backups(&self.path, self.backup_generations)?; + if self.durable { + atomic::write_atomic_durable(&self.path, contents.as_bytes()) + } else { + atomic::write_atomic(&self.path, contents.as_bytes()) + } + } +} + +enum Command { + Save, + Flush(Sender>), + Shutdown(Sender>), +} + +/// Debounced single-writer store over a schema-versioned TOML file. +pub struct DebouncedStore { + backend: Backend, + tx: Sender, + pending: Arc>>, + last_error: Arc>>, + worker: Option>, + // NOTE: the `WriterLock` is intentionally *not* stored here. It is owned by + // the worker thread and released only when that thread actually exits. This + // keeps the single-writer guarantee honest when `shutdown` times out and + // detaches a still-running worker: a wedged worker that is mid-`commit` + // keeps the lock (so a second process gets `WriterConflict`) until it truly + // finishes, rather than the lock being freed the instant this handle drops. +} + +fn lock(mutex: &Mutex>) -> std::sync::MutexGuard<'_, Option> { + mutex.lock().unwrap_or_else(|e| e.into_inner()) +} + +fn pending_lock(mutex: &Mutex>) -> std::sync::MutexGuard<'_, Option> { + mutex.lock().unwrap_or_else(|e| e.into_inner()) +} + +impl DebouncedStore { + /// Open a store at `path`, acquiring its writer lock and spawning the + /// worker. + /// + /// Returns [`StorageError::WriterConflict`] if another writer already owns + /// this store. + pub fn open(path: impl AsRef, config: StoreConfig) -> Result { + let path = path.as_ref().to_path_buf(); + let lock_path = append_suffix(&path, ".lock"); + let writer_lock = WriterLock::acquire(&lock_path)?; + + let backend = Backend { + path, + schema_version: config.schema_version, + backup_generations: config.backup_generations, + durable: config.durable, + }; + + let (tx, rx) = mpsc::channel(); + let pending: Arc>> = Arc::new(Mutex::new(None)); + let last_error: Arc>> = Arc::new(Mutex::new(None)); + + let worker_backend = backend.clone(); + let worker_pending = Arc::clone(&pending); + let worker_last_error = Arc::clone(&last_error); + let debounce = config.debounce; + + // Move lock ownership into the worker: it is released only when the + // worker thread exits. If `spawn` fails, the closure (and with it the + // lock) is dropped here, releasing the lock. + let worker = std::thread::Builder::new() + .name("gpui-storage".to_string()) + .spawn(move || { + worker_loop( + rx, + worker_backend, + worker_pending, + worker_last_error, + debounce, + writer_lock, + ); + }) + .map_err(|e| StorageError::io(&lock_path, e))?; + + Ok(Self { + backend, + tx, + pending, + last_error, + worker: Some(worker), + }) + } + + /// Load and classify the current on-disk document. + /// + /// If the primary file is [`Corrupt`](LoadOutcome::Corrupt), backups are + /// probed newest-first and the **first usable** generation is surfaced — + /// [`Loaded`](LoadOutcome::Loaded), [`NeedsMigration`](LoadOutcome::NeedsMigration) + /// (a backup written under an older schema, common right after an upgrade), + /// or [`FutureVersion`](LoadOutcome::FutureVersion) (a newer generation left + /// after a downgrade). `Missing`/`Corrupt` backups are skipped. This ensures + /// a corrupt primary never causes a caller to initialize defaults over a + /// migratable or preserved newer generation. A non-corrupt primary passes + /// through untouched. + pub fn load(&self) -> Result, StorageError> { + match load_envelope::(&self.backend.path, self.backend.schema_version)? { + LoadOutcome::Corrupt { archived_to } => { + for backup in backup_paths(&self.backend.path, self.backend.backup_generations) { + let usable = match load_envelope::(&backup, self.backend.schema_version) { + Ok(LoadOutcome::Loaded(value)) => LoadOutcome::Loaded(value), + Ok(LoadOutcome::NeedsMigration { found, raw }) => { + LoadOutcome::NeedsMigration { found, raw } + } + Ok(LoadOutcome::FutureVersion { found }) => { + LoadOutcome::FutureVersion { found } + } + // A Missing or Corrupt backup is genuinely unusable: skip + // it and give the next generation a chance. + Ok(LoadOutcome::Missing | LoadOutcome::Corrupt { .. }) => continue, + // An I/O error (permissions, device) is NOT evidence the + // backup is absent. Swallowing it here would let the + // caller initialize defaults over still-recoverable data, + // so surface it instead. + Err(error) => return Err(error), + }; + log::warn!( + "recovered {} from backup {}", + self.backend.path.display(), + backup.display() + ); + return Ok(usable); + } + Ok(LoadOutcome::Corrupt { archived_to }) + } + other => Ok(other), + } + } + + /// Schedule a debounced write of `value`. The latest value wins if several + /// `put`s land within one debounce window. + pub fn put(&self, value: T) -> Result<(), StorageError> { + *pending_lock(&self.pending) = Some(value); + self.tx + .send(Command::Save) + .map_err(|_| StorageError::WorkerStopped) + } + + /// Synchronously commit any pending value now, returning the commit result. + pub fn flush(&self) -> Result<(), StorageError> { + let (reply_tx, reply_rx) = mpsc::channel(); + self.tx + .send(Command::Flush(reply_tx)) + .map_err(|_| StorageError::WorkerStopped)?; + reply_rx.recv().map_err(|_| StorageError::WorkerStopped)? + } + + /// Flush and shut down the worker, joining its thread. Bounded: the final + /// commit reply is awaited for up to 5s. If the worker does not reply in + /// time it is **detached** (not joined) and [`StorageError::WorkerStopped`] + /// is returned, so a wedged worker can never hang the caller indefinitely. + /// + /// Because the writer lock is owned by the worker thread, a detached worker + /// keeps the lock until it actually exits — a second process opening the + /// same store observes [`StorageError::WriterConflict`] rather than racing a + /// still-running writer. On a normal (non-timed-out) shutdown the worker + /// exits and releases the lock before this returns. + pub fn shutdown(mut self) -> Result<(), StorageError> { + self.shutdown_inner() + } + + /// The most recent background (debounce-timer) commit error, if any. Cleared + /// on the next successful commit. Synchronous `flush`/`shutdown` errors are + /// returned directly rather than only stored here. + pub fn last_error(&self) -> Option { + lock(&self.last_error).clone() + } + + fn shutdown_inner(&mut self) -> Result<(), StorageError> { + let worker = match self.worker.take() { + Some(w) => w, + None => return Ok(()), + }; + + let (reply_tx, reply_rx) = mpsc::channel(); + + match self.tx.send(Command::Shutdown(reply_tx)) { + Ok(()) => match reply_rx.recv_timeout(Duration::from_secs(5)) { + Ok(result) => { + // Worker acknowledged; the join is now bounded (it is exiting). + let _ = worker.join(); + result + } + Err(_) => { + // Worker is unresponsive. Detach rather than block on an + // unbounded join — dropping the handle leaves it running. + Err(StorageError::WorkerStopped) + } + }, + // Channel already closed: the worker has stopped, so this join is + // bounded. + Err(_) => { + let _ = worker.join(); + Err(StorageError::WorkerStopped) + } + } + } +} + +impl Drop for DebouncedStore { + fn drop(&mut self) { + // Best-effort only; errors are intentionally swallowed. Callers that + // need the result must use `flush`/`shutdown` explicitly. + let _ = self.shutdown_inner(); + } +} + +fn worker_loop( + rx: Receiver, + backend: Backend, + pending: Arc>>, + last_error: Arc>>, + debounce: Duration, + // Owned for the worker's lifetime: the single-writer lock is released only + // when this function returns (the thread exits), never before. + _writer_lock: WriterLock, +) { + let mut deadline: Option = None; + + loop { + let timeout = match deadline { + Some(d) => d.saturating_duration_since(Instant::now()), + None => IDLE_TIMEOUT, + }; + + match rx.recv_timeout(timeout) { + Ok(Command::Save) => { + deadline = Some(Instant::now() + debounce); + } + Ok(Command::Flush(reply)) => { + let result = commit_pending(&backend, &pending, &last_error); + deadline = None; + let _ = reply.send(result); + } + Ok(Command::Shutdown(reply)) => { + let result = commit_pending(&backend, &pending, &last_error); + let _ = reply.send(result); + break; + } + Err(RecvTimeoutError::Timeout) => { + if let Some(d) = deadline { + if Instant::now() >= d { + // Background commit: no caller is waiting, so record any + // failure in `last_error` for later inspection. + let _ = commit_pending(&backend, &pending, &last_error); + deadline = None; + } + } + } + Err(RecvTimeoutError::Disconnected) => { + let _ = commit_pending(&backend, &pending, &last_error); + break; + } + } + } +} + +/// Commit the pending snapshot (if any) and update `last_error`. On failure the +/// snapshot is restored to `pending` so a later `flush`/`put` can retry rather +/// than silently dropping the user's data. +fn commit_pending( + backend: &Backend, + pending: &Arc>>, + last_error: &Arc>>, +) -> Result<(), StorageError> { + let value = match pending_lock(pending).take() { + Some(v) => v, + None => return Ok(()), + }; + + match backend.commit(&value) { + Ok(()) => { + *lock(last_error) = None; + Ok(()) + } + Err(e) => { + restore_failed_value(pending, value); + *lock(last_error) = Some(e.to_string()); + Err(e) + } + } +} + +/// Put a failed commit's value back into `pending` for retry — but only if no +/// newer value arrived while the commit was in flight. `take()` released the +/// lock during the (slow) write, so a concurrent `put(newer)` may have already +/// installed a fresher snapshot; overwriting it would silently lose that update. +fn restore_failed_value(pending: &Arc>>, value: T) { + let mut guard = pending_lock(pending); + if guard.is_none() { + *guard = Some(value); + } + // else: a newer put() already replaced it — keep the newer value, drop this. +} + +/// Append a raw suffix to a full path (unlike `with_extension`, which strips the +/// existing extension). `settings.toml` + `.lock` -> `settings.toml.lock`. +fn append_suffix(path: &Path, suffix: &str) -> PathBuf { + let mut s = path.as_os_str().to_os_string(); + s.push(suffix); + PathBuf::from(s) +} + +/// Backup path for generation index `i` (`0` -> `.bak`, `1` -> `.bak.1`, ...). +fn backup_path(path: &Path, i: usize) -> PathBuf { + if i == 0 { + append_suffix(path, ".bak") + } else { + append_suffix(path, &format!(".bak.{i}")) + } +} + +/// All backup paths, newest (`.bak`) first. +fn backup_paths(path: &Path, generations: usize) -> Vec { + (0..generations).map(|i| backup_path(path, i)).collect() +} + +/// Rotate the existing primary into the `.bak` chain before it is overwritten. +/// +/// The primary is only ever written by us with valid content, so `.bak` is +/// always a previously-committed valid generation. The primary is **copied** +/// (not moved) into `.bak` so it remains in place until the new primary is +/// committed — a failed write then leaves the old primary intact rather than +/// stranding the data only in a backup. A no-op when no primary exists yet +/// (first write). Errors are surfaced so the caller can abort the commit. +fn rotate_backups(path: &Path, generations: usize) -> Result<(), StorageError> { + if generations == 0 || !path.exists() { + return Ok(()); + } + + // Drop the oldest generation if present. + let oldest = backup_path(path, generations - 1); + if oldest.exists() { + fs::remove_file(&oldest).map_err(|e| StorageError::io(&oldest, e))?; + } + // Shift existing backups down by one (these are already-committed copies, so + // moving them is safe). + for i in (1..generations).rev() { + let src = backup_path(path, i - 1); + if src.exists() { + let dst = backup_path(path, i); + fs::rename(&src, &dst).map_err(|e| StorageError::io(&dst, e))?; + } + } + // Copy — not move — the current primary into `.bak`. + let newest = backup_path(path, 0); + fs::copy(path, &newest).map_err(|e| StorageError::io(&newest, e))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn restore_installs_failed_value_when_pending_empty() { + let pending: Arc>> = Arc::new(Mutex::new(None)); + restore_failed_value(&pending, 1); + assert_eq!(*pending_lock(&pending), Some(1)); + } + + #[test] + fn restore_does_not_clobber_newer_pending() { + // Models a put(2) that landed while value 1 was being (unsuccessfully) + // committed: the newer value must survive the failed retry restore. + let pending: Arc>> = Arc::new(Mutex::new(Some(2))); + restore_failed_value(&pending, 1); + assert_eq!(*pending_lock(&pending), Some(2)); + } +} diff --git a/crates/app-storage/tests/process_lock.rs b/crates/app-storage/tests/process_lock.rs new file mode 100644 index 00000000..77f5c5b2 --- /dev/null +++ b/crates/app-storage/tests/process_lock.rs @@ -0,0 +1,145 @@ +//! Process-level single-writer concurrency test. +//! +//! Proves that two *separate processes* contending on one store produce a +//! `WriterConflict` for the loser rather than silently clobbering each other. +//! The test re-execs its own binary in two child roles (holder / contender) +//! selected via the `STORAGE_LOCK_ROLE` env var. The role tests are `#[ignore]`d +//! so they never run as part of a normal `cargo test`; the driver invokes them +//! explicitly with `--ignored --exact`. + +use std::io::{BufRead, BufReader}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use gpui_component_storage::{DebouncedStore, StoreConfig}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +struct Blob { + n: u64, +} + +const ENV_PATH: &str = "STORAGE_LOCK_PATH"; +const ENV_ROLE: &str = "STORAGE_LOCK_ROLE"; + +fn store_path() -> PathBuf { + PathBuf::from(std::env::var(ENV_PATH).expect("STORAGE_LOCK_PATH must be set")) +} + +fn release_path(path: &std::path::Path) -> PathBuf { + let mut s = path.as_os_str().to_os_string(); + s.push(".release"); + PathBuf::from(s) +} + +/// Child role: acquire the store, announce it, hold the lock until released. +#[test] +#[ignore = "child role invoked by the driver via re-exec"] +fn child_holder() { + if std::env::var(ENV_ROLE).as_deref() != Ok("holder") { + return; + } + let path = store_path(); + let store = match DebouncedStore::::open(&path, StoreConfig::new(1)) { + Ok(store) => store, + Err(e) => { + println!("HOLDER_FAILED:{e}"); + return; + } + }; + store.put(Blob { n: 1 }).unwrap(); + store.flush().unwrap(); + + println!("HOLDER_LOCKED"); + use std::io::Write; + std::io::stdout().flush().unwrap(); + + // Hold the lock until the driver signals release (bounded so a stuck driver + // can never leave a zombie behind). + let release = release_path(&path); + let deadline = Instant::now() + Duration::from_secs(30); + while !release.exists() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } + drop(store); +} + +/// Child role: try to acquire the already-held store and report the outcome. +#[test] +#[ignore = "child role invoked by the driver via re-exec"] +fn child_contender() { + if std::env::var(ENV_ROLE).as_deref() != Ok("contender") { + return; + } + let path = store_path(); + match DebouncedStore::::open(&path, StoreConfig::new(1)) { + Ok(_) => println!("CONTENDER_ACQUIRED"), + Err(gpui_component_storage::StorageError::WriterConflict { .. }) => { + println!("CONTENDER_CONFLICT") + } + Err(e) => println!("CONTENDER_ERR:{e}"), + } + use std::io::Write; + std::io::stdout().flush().unwrap(); +} + +fn spawn_role(role: &str, test_name: &str, path: &std::path::Path) -> Command { + let exe = std::env::current_exe().expect("current_exe"); + let mut cmd = Command::new(exe); + cmd.args(["--exact", test_name, "--ignored", "--nocapture"]) + .env(ENV_ROLE, role) + .env(ENV_PATH, path) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + cmd +} + +#[test] +fn process_level_writer_conflict() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("shared.toml"); + + // Start the holder and wait until it confirms it owns the lock. + let mut holder = spawn_role("holder", "child_holder", &path) + .spawn() + .expect("spawn holder"); + let holder_out = holder.stdout.take().unwrap(); + let mut reader = BufReader::new(holder_out); + + let locked = { + let deadline = Instant::now() + Duration::from_secs(20); + let mut got = false; + let mut line = String::new(); + while Instant::now() < deadline { + line.clear(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + break; // holder exited without locking + } + if line.contains("HOLDER_LOCKED") { + got = true; + break; + } + if line.contains("HOLDER_FAILED") { + break; + } + } + got + }; + assert!(locked, "holder failed to acquire the lock"); + + // With the holder owning the lock, a second process must be refused. + let contender = spawn_role("contender", "child_contender", &path) + .output() + .expect("run contender"); + let contender_stdout = String::from_utf8_lossy(&contender.stdout); + + // Signal release and reap the holder regardless of assertion outcome. + std::fs::write(release_path(&path), b"go").unwrap(); + let _ = holder.wait(); + + assert!( + contender_stdout.contains("CONTENDER_CONFLICT"), + "second process should hit WriterConflict, got: {contender_stdout}" + ); +} diff --git a/crates/app-storage/tests/store.rs b/crates/app-storage/tests/store.rs new file mode 100644 index 00000000..0cff3c8c --- /dev/null +++ b/crates/app-storage/tests/store.rs @@ -0,0 +1,258 @@ +//! Integration tests for `DebouncedStore` exercised through its public API. + +use std::time::{Duration, Instant}; + +use gpui_component_storage::{DebouncedStore, LoadOutcome, StoreConfig}; +use serde::{Deserialize, Serialize}; +use tempfile::TempDir; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct Doc { + label: String, + n: u64, +} + +fn doc(label: &str, n: u64) -> Doc { + Doc { + label: label.to_string(), + n, + } +} + +fn cfg() -> StoreConfig { + StoreConfig::new(1).debounce(Duration::from_millis(50)) +} + +fn wait_for(mut cond: impl FnMut() -> bool) { + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if cond() { + return; + } + std::thread::sleep(Duration::from_millis(10)); + } + panic!("condition not met within timeout"); +} + +fn load_value(store: &DebouncedStore) -> Option { + match store.load().unwrap() { + LoadOutcome::Loaded(v) => Some(v), + _ => None, + } +} + +#[test] +fn put_then_flush_persists() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("store.toml"); + let store = DebouncedStore::::open(&path, cfg()).unwrap(); + + store.put(doc("a", 1)).unwrap(); + store.flush().unwrap(); + + assert_eq!(load_value(&store), Some(doc("a", 1))); +} + +#[test] +fn rapid_puts_coalesce_to_latest() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("store.toml"); + let store = DebouncedStore::::open(&path, cfg()).unwrap(); + + // Several puts inside one debounce window: only the last survives. + store.put(doc("first", 1)).unwrap(); + store.put(doc("second", 2)).unwrap(); + store.put(doc("third", 3)).unwrap(); + store.flush().unwrap(); + + assert_eq!(load_value(&store), Some(doc("third", 3))); +} + +#[test] +fn background_debounce_writes_without_explicit_flush() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("store.toml"); + let store = DebouncedStore::::open(&path, cfg()).unwrap(); + + store.put(doc("bg", 42)).unwrap(); + // No flush: the debounce timer must commit on its own. + wait_for(|| path.exists()); + assert_eq!(load_value(&store), Some(doc("bg", 42))); +} + +#[test] +fn shutdown_flushes_pending_synchronously() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("store.toml"); + let store = DebouncedStore::::open(&path, cfg()).unwrap(); + + store.put(doc("quit", 9)).unwrap(); + store.shutdown().unwrap(); + + // Re-open (the previous writer released its lock) and confirm persistence. + let reopened = DebouncedStore::::open(&path, cfg()).unwrap(); + assert_eq!(load_value(&reopened), Some(doc("quit", 9))); +} + +#[test] +fn backup_generations_rotate() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("store.toml"); + let store = DebouncedStore::::open(&path, cfg()).unwrap(); + + for i in 0..4 { + store.put(doc("gen", i)).unwrap(); + store.flush().unwrap(); + } + + let bak = |suffix: &str| { + let mut s = path.as_os_str().to_os_string(); + s.push(suffix); + std::path::PathBuf::from(s) + }; + + // Existence alone passes even if rotation duplicated or misordered + // generations, so assert each file's actual value. The newest generation is + // the primary and each `.bak*` holds the next-older value. Backups are read + // by pointing a fresh store at their path (their lock is independent of the + // still-open primary store). + let read_backup = |p: std::path::PathBuf| { + let store = DebouncedStore::::open(&p, cfg()).unwrap(); + let value = load_value(&store); + store.shutdown().unwrap(); + value + }; + assert_eq!(load_value(&store), Some(doc("gen", 3)), "primary"); + assert_eq!(read_backup(bak(".bak")), Some(doc("gen", 2)), ".bak"); + assert_eq!(read_backup(bak(".bak.1")), Some(doc("gen", 1)), ".bak.1"); + assert_eq!(read_backup(bak(".bak.2")), Some(doc("gen", 0)), ".bak.2"); +} + +#[test] +fn corrupt_primary_recovers_from_backup() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("store.toml"); + let store = DebouncedStore::::open(&path, cfg()).unwrap(); + + // Two committed generations: newest primary + one `.bak`. + store.put(doc("good-old", 1)).unwrap(); + store.flush().unwrap(); + store.put(doc("good-new", 2)).unwrap(); + store.flush().unwrap(); + + // Corrupt the primary in place. + std::fs::write(&path, "@@ not toml @@").unwrap(); + + // load() falls back to the newest valid backup (the previous generation). + match store.load().unwrap() { + LoadOutcome::Loaded(v) => assert_eq!(v, doc("good-old", 1)), + other => panic!("expected recovery, got {other:?}"), + } +} + +#[test] +fn corrupt_primary_surfaces_migratable_backup() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("store.toml"); + // Current schema is 2; a backup left over from before the upgrade is at + // schema 1, i.e. NeedsMigration relative to current. + let store = DebouncedStore::::open(&path, StoreConfig::new(2)).unwrap(); + + // Hand-place an older-schema `.bak` generation. + let bak = { + let mut s = path.as_os_str().to_os_string(); + s.push(".bak"); + std::path::PathBuf::from(s) + }; + std::fs::write(&bak, "schema_version = 1\nlabel = \"legacy\"\nn = 5\n").unwrap(); + + // Corrupt the primary. + std::fs::write(&path, "@@ not toml @@").unwrap(); + + // Recovery must surface the migratable generation, not fall through to + // Corrupt (which would let a caller init defaults over real data). + match store.load().unwrap() { + LoadOutcome::NeedsMigration { found, raw } => { + assert_eq!(found, 1); + assert_eq!(raw.get("label").and_then(|v| v.as_str()), Some("legacy")); + } + other => panic!("expected NeedsMigration from backup, got {other:?}"), + } +} + +#[cfg(unix)] +#[test] +fn worker_write_error_is_surfaced() { + use std::os::unix::fs::PermissionsExt; + + let dir = TempDir::new().unwrap(); + let sub = dir.path().join("locked"); + std::fs::create_dir_all(&sub).unwrap(); + let path = sub.join("store.toml"); + let store = DebouncedStore::::open(&path, cfg()).unwrap(); + + // First commit succeeds. + store.put(doc("ok", 1)).unwrap(); + store.flush().unwrap(); + + // Make the containing directory read-only so the next atomic write fails. + let mut perms = std::fs::metadata(&sub).unwrap().permissions(); + perms.set_mode(0o500); + std::fs::set_permissions(&sub, perms).unwrap(); + + store.put(doc("fails", 2)).unwrap(); + let flush_result = store.flush(); + + // Restore permissions before any assertion so cleanup always works. + let mut restore = std::fs::metadata(&sub).unwrap().permissions(); + restore.set_mode(0o700); + std::fs::set_permissions(&sub, restore).unwrap(); + + assert!( + flush_result.is_err(), + "flush should surface the write error" + ); + assert!( + store.last_error().is_some(), + "last_error should retain the failure" + ); +} + +#[cfg(unix)] +#[test] +fn failed_commit_leaves_primary_intact_and_loadable() { + use std::os::unix::fs::PermissionsExt; + + let dir = TempDir::new().unwrap(); + let sub = dir.path().join("locked"); + std::fs::create_dir_all(&sub).unwrap(); + let path = sub.join("store.toml"); + let store = DebouncedStore::::open(&path, cfg()).unwrap(); + + // Commit a good generation. + store.put(doc("keep", 1)).unwrap(); + store.flush().unwrap(); + + // Make the directory read-only so rotation/write of the next commit fails. + let mut perms = std::fs::metadata(&sub).unwrap().permissions(); + perms.set_mode(0o500); + std::fs::set_permissions(&sub, perms).unwrap(); + + store.put(doc("doomed", 2)).unwrap(); + let flush_result = store.flush(); + + // Assert while the directory is still read-only: this guarantees the worker + // cannot background-commit "doomed" and race the assertions below. Reads are + // still permitted (r-x), so load() works. + assert!(flush_result.is_err(), "the commit must fail"); + assert!( + path.exists(), + "primary must remain in place after a failed commit" + ); + assert_eq!(load_value(&store), Some(doc("keep", 1))); + + // Restore permissions so TempDir cleanup and the Drop-time flush can run. + let mut restore = std::fs::metadata(&sub).unwrap().permissions(); + restore.set_mode(0o700); + std::fs::set_permissions(&sub, restore).unwrap(); +} diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml new file mode 100644 index 00000000..7d4bac09 --- /dev/null +++ b/crates/app/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "gpui-component-app" +description = "Application shell for gpui-component apps: AppShell builder, lifecycle events, identity, settings, windows, theme, menus." +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +anyhow.workspace = true +gpui.workspace = true +gpui_platform.workspace = true +gpui-component.workspace = true +gpui-component-manifest.workspace = true +gpui-component-storage.workspace = true +log.workspace = true +serde.workspace = true +thiserror.workspace = true +toml.workspace = true + +[dev-dependencies] +serde_json.workspace = true +tempfile.workspace = true + +# GPUI must construct its `App` on the main thread, which cargo's default test +# harness (worker threads) cannot provide. This test drives the shell from its +# own `main`, on the process main thread. +[[test]] +name = "headless" +harness = false + +[lints] +workspace = true diff --git a/crates/app/src/capabilities.rs b/crates/app/src/capabilities.rs new file mode 100644 index 00000000..be894cdc --- /dev/null +++ b/crates/app/src/capabilities.rs @@ -0,0 +1,183 @@ +//! Runtime platform-capability reporting (plan D9, task P1.8). +//! +//! The gpui fork has several *silent stubs* — URL-scheme registration is +//! unimplemented on Windows and Linux, and X11 overlay click-through is a no-op +//! (plan §1.5, §7). Rather than wrap those as if they worked, the shell reports +//! honest capabilities and fallible APIs return [`Capability::Unsupported`] with +//! a reason. Capabilities are *runtime* values, not `cfg!` constants, so that +//! session-dependent facts (Wayland vs X11, tray availability) can be refined +//! here later without an API change. + +/// Whether a given platform capability is available in this process. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Capability { + /// The capability is available and expected to work. + Supported, + /// The capability is not available; `reason` explains why (missing fork + /// implementation, wrong session type, disabled feature, …). + Unsupported { + /// Human-readable, stable-ish explanation for logs and diagnostics. + reason: &'static str, + }, +} + +impl Capability { + /// Convenience: an `Unsupported` value with a static reason. + pub const fn unsupported(reason: &'static str) -> Self { + Capability::Unsupported { reason } + } + + /// Whether the capability is [`Capability::Supported`]. + pub const fn is_supported(&self) -> bool { + matches!(self, Capability::Supported) + } +} + +/// A snapshot of platform capabilities relevant to app-shell features. +/// +/// Cloned into [`crate::AppInfo`] so background threads can inspect it before +/// committing to a shape (e.g. a tray-first app checking `tray` before choosing +/// a zero-window launch). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct PlatformCapabilities { + /// Click-through overlay surfaces (`open_overlay_surface`). + pub overlay_surface: Capability, + /// System tray / status-item icon. + pub tray: Capability, + /// Right-click dock menu (macOS) / jump list (Windows). + pub dock_menu: Capability, + /// OS credential store (keychain / libsecret / credential manager). + pub credential_store: Capability, + /// Registering the app as a handler for custom URL schemes. + pub url_schemes: Capability, + /// Restoring a window to an exact previous position. + pub precise_window_positioning: Capability, +} + +impl PlatformCapabilities { + /// Detect capabilities for the current build target. + /// + /// Judgements (each traceable to a plan finding or verified fork stub): + /// + /// - `url_schemes`: registration is implemented only on macOS. Windows + /// (`gpui_windows/src/platform.rs`) and Linux (`gpui_linux/src/platform.rs`) + /// are verified stubs, so both report `Unsupported` (plan §1.5, D9). + /// - `overlay_surface`: macOS only for now. X11 click-through is a silent + /// no-op and Wayland is unverified, so non-macOS reports `Unsupported` + /// until end-to-end verification (plan §7). + /// - `tray`: macOS and Windows report `Supported`; Linux is `Unsupported` + /// because tray reliability depends on the desktop session + /// (Wayland/GNOME) and needs a runtime, not compile-time, check (plan §7). + /// - `dock_menu`: macOS dock menu / Windows jump list only. + /// - `credential_store`: not yet wired through the shell on any platform; the + /// credential-store abstraction is deferred (plan §3, Phase 3). + /// - `precise_window_positioning`: `Unsupported` on Linux because Wayland + /// does not expose absolute window positions. + pub fn detect() -> Self { + Self { + overlay_surface: overlay_surface(), + tray: tray(), + dock_menu: dock_menu(), + credential_store: Capability::unsupported( + "credential storage is not yet wired through the shell (deferred)", + ), + url_schemes: url_schemes(), + precise_window_positioning: precise_window_positioning(), + } + } +} + +const fn overlay_surface() -> Capability { + #[cfg(target_os = "macos")] + { + Capability::Supported + } + #[cfg(not(target_os = "macos"))] + { + Capability::unsupported( + "overlay click-through is unverified off macOS (X11 no-op, Wayland untested)", + ) + } +} + +const fn tray() -> Capability { + #[cfg(any(target_os = "macos", target_os = "windows"))] + { + Capability::Supported + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + Capability::unsupported( + "tray reliability depends on the Linux desktop session; verify at runtime", + ) + } +} + +const fn dock_menu() -> Capability { + #[cfg(target_os = "macos")] + { + Capability::Supported + } + #[cfg(target_os = "windows")] + { + // The OS has jump lists, but the shell's dock projection + // (`set_dock_menu`) is only implemented on macOS today. + Capability::unsupported("jump-list projection is not implemented in the shell yet") + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + Capability::unsupported("no dock menu / jump list equivalent on this platform") + } +} + +const fn url_schemes() -> Capability { + #[cfg(target_os = "macos")] + { + Capability::Supported + } + #[cfg(not(target_os = "macos"))] + { + Capability::unsupported( + "URL-scheme registration is an unimplemented fork stub on Windows and Linux", + ) + } +} + +const fn precise_window_positioning() -> Capability { + #[cfg(target_os = "linux")] + { + Capability::unsupported("absolute window position is unavailable under Wayland") + } + #[cfg(not(target_os = "linux"))] + { + Capability::Supported + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detect_is_internally_consistent() { + let caps = PlatformCapabilities::detect(); + // credential_store is always deferred for now. + assert!(!caps.credential_store.is_supported()); + + // url_schemes support only ever claimed where registration is real. + #[cfg(target_os = "macos")] + assert!(caps.url_schemes.is_supported()); + #[cfg(not(target_os = "macos"))] + assert!(!caps.url_schemes.is_supported()); + } + + #[test] + fn unsupported_carries_reason() { + let cap = Capability::unsupported("nope"); + match cap { + Capability::Unsupported { reason } => assert_eq!(reason, "nope"), + Capability::Supported => panic!("expected unsupported"), + } + } +} diff --git a/crates/app/src/commands/menu.rs b/crates/app/src/commands/menu.rs new file mode 100644 index 00000000..634934a8 --- /dev/null +++ b/crates/app/src/commands/menu.rs @@ -0,0 +1,305 @@ +//! [`MenuPlan`]: the declarative native-menu description, assembled by +//! projecting the [`CommandRegistry`](super::CommandRegistry). +//! +//! Projection is two-stage so the tree structure is unit-testable without a +//! live `App`: +//! +//! 1. [`MenuPlan::outline`] (pure): given the registered commands, compute the +//! ordered [`MenuOutline`]s — which top-level menus, and the sequence of +//! command/separator/section nodes inside each. +//! 2. [`build_menus`] (App-bound): resolve each node into a `gpui::Menu`, +//! evaluating labels/checked/enabled and expanding section providers. + +use gpui::{App, Menu, MenuItem}; + +use super::{APP_MENU, Command, CommandId, CommandRegistry, EDIT_MENU, WINDOW_MENU}; + +/// One node in a projected menu. +#[derive(Debug, PartialEq, Eq)] +pub enum MenuNode { + /// A command, referenced by id (resolved to a menu item at build time). + Command(CommandId), + /// A separator between groups or before a section. + Separator, + /// A reserved section slot, expanded by its registered provider at build + /// time (skipped if no provider is registered). + Section(&'static str), +} + +/// The pure, ordered structure of one top-level menu. +#[derive(Debug, PartialEq, Eq)] +pub struct MenuOutline { + /// The top-level menu key (e.g. [`APP_MENU`]); the App menu renders with the + /// app display name. + pub key: &'static str, + /// The ordered nodes. + pub nodes: Vec, +} + +/// One top-level menu in the plan: a key plus any reserved section slots +/// appended after its commands. +struct TopMenu { + key: &'static str, + sections: Vec<&'static str>, +} + +/// A declarative description of the native menu bar. Menus are projections of +/// the command registry (plan §3). +pub struct MenuPlan { + menus: Vec, +} + +impl MenuPlan { + /// The standard App + Edit + Window menu bar, projected from the registry. + pub fn standard() -> Self { + Self { + menus: vec![ + TopMenu { + key: APP_MENU, + sections: Vec::new(), + }, + TopMenu { + key: EDIT_MENU, + sections: Vec::new(), + }, + TopMenu { + key: WINDOW_MENU, + sections: Vec::new(), + }, + ], + } + } + + /// Reserve an Appearance/Theme section in the App menu, fed by the + /// [`MenuSection`](super::MenuSection) provider registered under + /// [`THEME_SECTION`]. + pub fn with_theme_menu(mut self) -> Self { + self.reserve_section(APP_MENU, THEME_SECTION); + self + } + + /// Reserve a section `slot` at the end of the menu keyed by `menu_key`. The + /// same seam serves a future Move-to-Window section. + pub fn reserve_section(&mut self, menu_key: &'static str, slot: &'static str) { + if let Some(menu) = self.menus.iter_mut().find(|m| m.key == menu_key) { + menu.sections.push(slot); + } + } + + /// Compute the pure menu outline from the registered `commands`. + pub fn outline(&self, commands: &[Command]) -> Vec { + self.menus + .iter() + .map(|menu| MenuOutline { + key: menu.key, + nodes: outline_nodes(menu, commands), + }) + .collect() + } +} + +/// The section slot fed by the theme service's Appearance/Theme submenu. +pub const THEME_SECTION: &str = "appearance"; + +/// Build the ordered nodes for one top-level menu: commands grouped and +/// separated by `(group, order)`, then each reserved section (preceded by a +/// separator when the menu already has items). +fn outline_nodes(menu: &TopMenu, commands: &[Command]) -> Vec { + let mut placed: Vec<(u16, u16, CommandId)> = commands + .iter() + .filter_map(|c| { + c.placement() + .filter(|p| p.menu == menu.key) + .map(|p| (p.group, p.order, c.id())) + }) + .collect(); + placed.sort_by_key(|(group, order, _)| (*group, *order)); + + let mut nodes = Vec::new(); + let mut last_group: Option = None; + for (group, _, id) in placed { + if let Some(prev) = last_group { + if prev != group { + nodes.push(MenuNode::Separator); + } + } + last_group = Some(group); + nodes.push(MenuNode::Command(id)); + } + + for &slot in &menu.sections { + if !nodes.is_empty() { + nodes.push(MenuNode::Separator); + } + nodes.push(MenuNode::Section(slot)); + } + nodes +} + +/// Resolve the plan into native `gpui::Menu`s against the current app state. +/// +/// Returns `None` when no plan is installed. Empty menus (no placed commands and +/// no populated sections) are dropped so the bar stays clean. +pub(super) fn build_menus(cx: &App, registry: &CommandRegistry) -> Option> { + let plan = registry.plan()?; + let app_title = app_menu_title(cx); + + let mut menus = Vec::new(); + for outline in plan.outline(registry.commands()) { + let mut items = Vec::new(); + for node in &outline.nodes { + match node { + MenuNode::Command(id) => { + if let Some(cmd) = registry.get(*id) { + items.push(cmd.to_menu_item(cx)); + } + } + // Never emit a leading or doubled separator: a preceding + // reserved section may have resolved to zero items. + MenuNode::Separator => { + if !items.is_empty() && !matches!(items.last(), Some(MenuItem::Separator)) { + items.push(MenuItem::Separator); + } + } + MenuNode::Section(slot) => { + if let Some(section) = registry.section(slot) { + // Drop a dangling separator if the section is empty. + let section_items = section.items(cx); + if section_items.is_empty() { + if matches!(items.last(), Some(MenuItem::Separator)) { + items.pop(); + } + } else { + items.extend(section_items); + } + } else if matches!(items.last(), Some(MenuItem::Separator)) { + items.pop(); + } + } + } + } + if items.is_empty() { + continue; + } + let name = if outline.key == APP_MENU { + app_title.clone() + } else { + outline.key.into() + }; + menus.push(Menu { + name, + items, + disabled: false, + }); + } + Some(menus) +} + +/// Project the dock-menu commands (keyed [`DOCK_MENU`](super::DOCK_MENU)) into a +/// flat item list for `set_dock_menu`. +#[cfg(target_os = "macos")] +pub(super) fn build_dock_items(cx: &App, registry: &CommandRegistry) -> Vec { + let mut placed: Vec<(u16, u16, &Command)> = registry + .commands() + .iter() + .filter_map(|c| { + c.placement() + .filter(|p| p.menu == super::DOCK_MENU) + .map(|p| (p.group, p.order, c)) + }) + .collect(); + placed.sort_by_key(|(group, order, _)| (*group, *order)); + + let mut items = Vec::new(); + let mut last_group: Option = None; + for (group, _, cmd) in placed { + if let Some(prev) = last_group { + if prev != group { + items.push(MenuItem::Separator); + } + } + last_group = Some(group); + items.push(cmd.to_menu_item(cx)); + } + items +} + +/// The App menu title: the app display name when the shell is installed, else a +/// neutral fallback (keeps pure/early builds working). +fn app_menu_title(cx: &App) -> gpui::SharedString { + use crate::handles::{AppShellExt, ShellState}; + if cx.has_global::() { + cx.app_info().display_name().to_string().into() + } else { + APP_MENU.into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::{CommandScope, MenuPlacement}; + use gpui::actions; + + actions!(menu_test, [Noop]); + + fn cmd(id: &'static str, placement: MenuPlacement) -> Command { + Command::new(CommandId(id), id, CommandScope::App, Noop).with_placement(placement) + } + + #[test] + fn outline_groups_with_separators_and_sorts() { + let commands = vec![ + cmd("quit", MenuPlacement::new(APP_MENU, 9, 0)), + cmd("about", MenuPlacement::new(APP_MENU, 0, 0)), + cmd("copy", MenuPlacement::new(EDIT_MENU, 0, 1)), + cmd("undo", MenuPlacement::new(EDIT_MENU, 0, 0)), + ]; + let plan = MenuPlan::standard(); + let outline = plan.outline(&commands); + + // App menu: about (group 0), separator, quit (group 9). + assert_eq!(outline[0].key, APP_MENU); + assert_eq!( + outline[0].nodes, + vec![ + MenuNode::Command(CommandId("about")), + MenuNode::Separator, + MenuNode::Command(CommandId("quit")), + ] + ); + // Edit menu: undo then copy (order within group), no separator. + assert_eq!( + outline[1].nodes, + vec![ + MenuNode::Command(CommandId("undo")), + MenuNode::Command(CommandId("copy")), + ] + ); + // Window menu: empty. + assert_eq!(outline[2].key, WINDOW_MENU); + assert!(outline[2].nodes.is_empty()); + } + + #[test] + fn theme_menu_reserves_section_after_separator() { + let commands = vec![cmd("about", MenuPlacement::new(APP_MENU, 0, 0))]; + let plan = MenuPlan::standard().with_theme_menu(); + let outline = plan.outline(&commands); + assert_eq!( + outline[0].nodes, + vec![ + MenuNode::Command(CommandId("about")), + MenuNode::Separator, + MenuNode::Section(THEME_SECTION), + ] + ); + } + + #[test] + fn theme_section_without_commands_has_no_leading_separator() { + let plan = MenuPlan::standard().with_theme_menu(); + let outline = plan.outline(&[]); + assert_eq!(outline[0].nodes, vec![MenuNode::Section(THEME_SECTION)]); + } +} diff --git a/crates/app/src/commands/mod.rs b/crates/app/src/commands/mod.rs new file mode 100644 index 00000000..0a6472ee --- /dev/null +++ b/crates/app/src/commands/mod.rs @@ -0,0 +1,483 @@ +//! Command registry and menu projection (plan §3 — "commands before menus"). +//! +//! A [`Command`] carries a stable [`CommandId`], a GPUI action, a scope, a +//! localizable label, optional enabled/checked predicates, an optional default +//! keybinding, and an optional [`MenuPlacement`]. The [`CommandRegistry`] is the +//! canonical vocabulary; the native menu, the dock menu, and (later) tray menus +//! and keymap files are all *projections* of it. +//! +//! Contributors that own their own actions (the theme service's +//! Appearance/Theme submenu, the window manager's Move-to-Window section) feed a +//! reserved menu section through the [`MenuSection`] seam, so this module never +//! imports them. +//! +//! The [`MenusPlugin`] installs the registry, wires the standard command set, +//! registers default keybindings, and re-installs the native menus whenever a +//! contributing section signals change via [`menus_invalidate`]. + +mod menu; +mod plugin; +mod standard; + +use std::collections::HashMap; + +use gpui::{Action, App, Global, MenuItem, OsAction, SharedString}; + +pub use menu::{MenuNode, MenuOutline, MenuPlan, THEME_SECTION}; +pub use plugin::{MenusPlugin, menus_invalidate}; +pub use standard::{About, CloseWindow, Quit}; +#[cfg(target_os = "macos")] +pub use standard::{HideApp, HideOthers, Minimize, ShowAll, Zoom}; + +/// The top-level menu key used for the application menu. Its rendered title is +/// the app display name; placements and outlines match on this key. +pub const APP_MENU: &str = "App"; +/// The top-level menu key for the standard Edit menu. +pub const EDIT_MENU: &str = "Edit"; +/// The top-level menu key for the standard Window menu. +pub const WINDOW_MENU: &str = "Window"; +/// The pseudo-menu key for commands projected into the macOS dock menu. +pub const DOCK_MENU: &str = "Dock"; + +/// A stable, process-unique identifier for a command. +/// +/// The canonical key across every projection (menu, dock, tray, keymap). Ids are +/// `&'static str` so they can be written as literals and compared cheaply; keep +/// them stable across releases (they will anchor user keymap files). +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub struct CommandId(pub &'static str); + +impl CommandId { + /// The underlying string. + pub const fn as_str(&self) -> &'static str { + self.0 + } +} + +/// Where a command's handler is dispatched. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum CommandScope { + /// Dispatched to the application via a global `on_action` handler. + App, + /// Dispatched to the focused window/view (e.g. the Edit block, which the + /// focused input handles). The shell registers no global handler for these. + Window, +} + +/// Placement of a command inside a projected menu. +/// +/// Menus are assembled by grouping every command with a matching [`menu`](Self::menu) +/// key, sorting by `(group, order)`, and inserting a separator between adjacent +/// groups. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct MenuPlacement { + /// The top-level menu key (e.g. [`APP_MENU`], [`EDIT_MENU`]). + pub menu: &'static str, + /// Group index; a separator is inserted between adjacent groups. + pub group: u16, + /// Order within the group. + pub order: u16, +} + +impl MenuPlacement { + /// Place a command in `menu`, `group`, at `order`. + pub const fn new(menu: &'static str, group: u16, order: u16) -> Self { + Self { menu, group, order } + } +} + +/// A single command: the canonical unit projected into menus and keymaps. +pub struct Command { + id: CommandId, + label: SharedString, + scope: CommandScope, + action: Box, + os_action: Option, + enabled: Option bool>, + checked: Option bool>, + default_binding: Option<&'static str>, + placement: Option, +} + +impl Command { + /// Create a command with the required fields. Chain the `with_*` builders to + /// add menu placement, predicates, and a default binding. + pub fn new( + id: CommandId, + label: impl Into, + scope: CommandScope, + action: impl Action, + ) -> Self { + Self { + id, + label: label.into(), + scope, + action: Box::new(action), + os_action: None, + enabled: None, + checked: None, + default_binding: None, + placement: None, + } + } + + /// Associate the OS action so the platform can supply specialized behavior + /// (macOS standard Edit items). + pub fn with_os_action(mut self, os_action: OsAction) -> Self { + self.os_action = Some(os_action); + self + } + + /// Set the enabled predicate. Absent means always enabled. + pub fn with_enabled(mut self, f: fn(&App) -> bool) -> Self { + self.enabled = Some(f); + self + } + + /// Set the checked predicate. Absent means unchecked. + pub fn with_checked(mut self, f: fn(&App) -> bool) -> Self { + self.checked = Some(f); + self + } + + /// Set the default keybinding (component < shell < app precedence; registered + /// in registry order so later registrations win). + pub fn with_binding(mut self, keystroke: &'static str) -> Self { + self.default_binding = Some(keystroke); + self + } + + /// Place the command in a menu. + pub fn with_placement(mut self, placement: MenuPlacement) -> Self { + self.placement = Some(placement); + self + } + + /// Convenience for [`Command::with_placement`]. + pub fn placed(self, menu: &'static str, group: u16, order: u16) -> Self { + self.with_placement(MenuPlacement::new(menu, group, order)) + } + + /// The command's stable id. + pub fn id(&self) -> CommandId { + self.id + } + + /// The command's label. + pub fn label(&self) -> &SharedString { + &self.label + } + + /// The command's dispatch scope. + pub fn scope(&self) -> CommandScope { + self.scope + } + + /// The command's menu placement, if any. + pub fn placement(&self) -> Option { + self.placement + } + + /// The command's default keybinding, if any. + pub fn default_binding(&self) -> Option<&'static str> { + self.default_binding + } + + /// A fresh boxed clone of the command's action (for menu/keymap projection). + pub fn boxed_action(&self) -> Box { + self.action.boxed_clone() + } + + /// Project the command into a menu item, evaluating checked/enabled against + /// `cx`. + fn to_menu_item(&self, cx: &App) -> MenuItem { + let flags = menu_flags(self.checked.map(|f| f(cx)), self.enabled.map(|f| f(cx))); + MenuItem::Action { + name: self.label.clone(), + action: self.action.boxed_clone(), + os_action: self.os_action, + checked: flags.checked, + disabled: flags.disabled, + } + } +} + +/// Resolved menu-item flags. Pure so the checked/enabled → checked/disabled +/// mapping is unit-testable without a live `App`. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +struct MenuFlags { + checked: bool, + disabled: bool, +} + +/// Map optional predicate results to menu-item flags: an absent checked +/// predicate is unchecked; an absent enabled predicate is enabled. +fn menu_flags(checked: Option, enabled: Option) -> MenuFlags { + MenuFlags { + checked: checked.unwrap_or(false), + disabled: !enabled.unwrap_or(true), + } +} + +/// A provider that contributes freshly-built menu items to a reserved section. +/// +/// The seam that lets the theme service (Appearance/Theme) and, later, the +/// window manager (Move-to-Window) feed a menu section without this module +/// importing them. Items are rebuilt on demand so checked/enabled state is +/// always current. A blanket impl accepts a plain `Fn(&App) -> Vec`. +pub trait MenuSection: 'static { + /// Build this section's items against the current app state. + fn items(&self, cx: &App) -> Vec; +} + +impl Vec + 'static> MenuSection for F { + fn items(&self, cx: &App) -> Vec { + self(cx) + } +} + +/// The command registry: a main-thread GPUI [`Global`]. +/// +/// Holds commands (deduped by [`CommandId`], last-registration-wins), the +/// section providers keyed by slot name, and the active [`MenuPlan`] the plugin +/// re-projects on invalidation. +#[derive(Default)] +pub struct CommandRegistry { + commands: Vec, + index: HashMap, + sections: HashMap<&'static str, Box>, + plan: Option, + /// Set once the plugin has run its initial binding + projection pass. While + /// unset, [`register_command`](AppCommandsExt::register_command) only records + /// (the plugin binds/projects the whole registry in one batch); once set, + /// later registrations must bind and re-project themselves immediately. + active: bool, +} + +impl Global for CommandRegistry {} + +impl CommandRegistry { + /// A new, empty registry. + pub fn new() -> Self { + Self::default() + } + + /// Register a command. If an id is already present its entry is replaced + /// (last-registration-wins) so apps can override a standard command; the + /// registration slot (and thus binding precedence order) is preserved. + pub fn register(&mut self, command: Command) { + if let Some(&ix) = self.index.get(&command.id) { + self.commands[ix] = command; + } else { + let ix = self.commands.len(); + self.index.insert(command.id, ix); + self.commands.push(command); + } + } + + /// Look up a command by id. + pub fn get(&self, id: CommandId) -> Option<&Command> { + self.index.get(&id).map(|&ix| &self.commands[ix]) + } + + /// All registered commands in registration order. + pub fn commands(&self) -> &[Command] { + &self.commands + } + + /// Register a menu-section provider under `slot`. Replaces any prior provider + /// for the same slot. + pub fn register_section(&mut self, slot: &'static str, section: impl MenuSection) { + self.sections.insert(slot, Box::new(section)); + } + + /// The section provider for `slot`, if registered. + pub fn section(&self, slot: &str) -> Option<&dyn MenuSection> { + self.sections.get(slot).map(|b| b.as_ref()) + } + + /// Set the active menu plan (installed by the plugin). + pub fn set_plan(&mut self, plan: MenuPlan) { + self.plan = Some(plan); + } + + /// The active menu plan, if one is installed. + pub fn plan(&self) -> Option<&MenuPlan> { + self.plan.as_ref() + } + + /// Mark the registry live: the plugin has bound and projected the initial + /// registry, so subsequent registrations bind/project dynamically. + pub fn activate(&mut self) { + self.active = true; + } + + /// Whether the initial projection pass has completed. + pub fn is_active(&self) -> bool { + self.active + } +} + +/// Registry access on the raw `gpui::App`. The registry global is created lazily +/// on first mutation, so contributors may register before the plugin's `init`. +/// +/// Registration is *dynamic once the plugin is live*: because [`AppPlugin`] is +/// sealed, apps first reach `&mut App` from `on_launch` — after the plugin's +/// initial binding/projection pass. Commands registered then are bound and +/// re-projected immediately (late app-tier bindings still sit above the shell +/// defaults and below any future user overrides). +pub trait AppCommandsExt { + /// Register a command (creates the registry global if absent). If the plugin + /// has already run its initial pass, the command's default binding is bound + /// and the menus are re-projected immediately. + fn register_command(&mut self, command: Command); + /// Register a menu-section provider (creates the registry global if absent). + /// Re-projects the menus immediately if the plugin is already live. + fn register_menu_section(&mut self, slot: &'static str, section: impl MenuSection); + /// The command registry, if it has been created. + fn command_registry(&self) -> Option<&CommandRegistry>; +} + +impl AppCommandsExt for App { + fn register_command(&mut self, command: Command) { + let id = command.id(); + // Decide what to do about keybindings/menus *before* the registry mutates + // (we need to know whether this id already existed). + let effect = self + .command_registry() + .map_or(RegistrationEffect::None, |registry| { + RegistrationEffect::of(registry.is_active(), registry.get(id).is_some()) + }); + ensure_registry(self).register(command); + match effect { + RegistrationEffect::None => {} + RegistrationEffect::Append => plugin::bind_and_reproject(self, id), + RegistrationEffect::Rebuild => plugin::rebuild_bindings_and_reproject(self), + } + } + + fn register_menu_section(&mut self, slot: &'static str, section: impl MenuSection) { + ensure_registry(self).register_section(slot, section); + if self.global::().is_active() { + menus_invalidate(self); + } + } + + fn command_registry(&self) -> Option<&CommandRegistry> { + self.has_global::() + .then(|| self.global::()) + } +} + +/// What a live registration must do about keybindings and menus. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum RegistrationEffect { + /// Registry not yet live: the plugin binds/projects the whole registry in one + /// batch, so recording is enough. + None, + /// A brand-new id after go-live: append its binding (no stale chord to clear). + Append, + /// Replacing an existing id after go-live: rebuild bindings so the replaced + /// command's now-stale chord is removed. + Rebuild, +} + +impl RegistrationEffect { + fn of(active: bool, id_exists: bool) -> Self { + match (active, id_exists) { + (false, _) => Self::None, + (true, false) => Self::Append, + (true, true) => Self::Rebuild, + } + } +} + +/// Create the registry global if absent, then return it mutably. +fn ensure_registry(cx: &mut App) -> &mut CommandRegistry { + if !cx.has_global::() { + cx.set_global(CommandRegistry::new()); + } + cx.global_mut::() +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::actions; + + actions!(commands_test, [Alpha, Beta]); + + fn cmd(id: &'static str, action: impl Action) -> Command { + Command::new(CommandId(id), id, CommandScope::App, action) + } + + #[test] + fn register_dedups_last_wins_and_keeps_slot() { + let mut reg = CommandRegistry::new(); + reg.register(cmd("a", Alpha).with_binding("cmd-a")); + reg.register(cmd("b", Beta)); + // Re-register "a" with a new label/binding: replaces in place. + reg.register( + Command::new(CommandId("a"), "A2", CommandScope::App, Alpha).with_binding("cmd-x"), + ); + + assert_eq!(reg.commands().len(), 2, "dedup keeps a single entry per id"); + assert_eq!(reg.get(CommandId("a")).unwrap().label().as_ref(), "A2"); + assert_eq!( + reg.get(CommandId("a")).unwrap().default_binding(), + Some("cmd-x") + ); + // Slot order preserved: "a" still precedes "b". + assert_eq!(reg.commands()[0].id(), CommandId("a")); + assert_eq!(reg.commands()[1].id(), CommandId("b")); + } + + #[test] + fn registration_effect_routes_by_liveness_and_existing_id() { + // Before go-live: batch pass handles everything. + assert_eq!( + RegistrationEffect::of(false, false), + RegistrationEffect::None + ); + assert_eq!( + RegistrationEffect::of(false, true), + RegistrationEffect::None + ); + // After go-live: new id appends, existing id rebuilds (drops stale chord). + assert_eq!( + RegistrationEffect::of(true, false), + RegistrationEffect::Append + ); + assert_eq!( + RegistrationEffect::of(true, true), + RegistrationEffect::Rebuild + ); + } + + #[test] + fn menu_flags_defaults_and_inversion() { + // Absent predicates: unchecked, enabled. + assert_eq!( + menu_flags(None, None), + MenuFlags { + checked: false, + disabled: false + } + ); + // Checked true, disabled is the inverse of enabled. + assert_eq!( + menu_flags(Some(true), Some(false)), + MenuFlags { + checked: true, + disabled: true + } + ); + assert_eq!( + menu_flags(Some(false), Some(true)), + MenuFlags { + checked: false, + disabled: false + } + ); + } +} diff --git a/crates/app/src/commands/plugin.rs b/crates/app/src/commands/plugin.rs new file mode 100644 index 00000000..ed3fa868 --- /dev/null +++ b/crates/app/src/commands/plugin.rs @@ -0,0 +1,315 @@ +//! [`MenusPlugin`]: installs the standard command set, registers default +//! keybindings, and keeps the native menu bar in sync with the registry. +//! +//! Reactive rebuild: the plugin observes the [`gpui_component::Theme`] global so +//! checked state (appearance mode, active theme) refreshes automatically, and +//! exposes [`menus_invalidate`] for other modules (theme registry watcher, window +//! manager) to call when their contributing sections change. +//! +//! Keybinding precedence (component < shell < app < user): the component tier is +//! bound by `gpui_component::init` before this plugin runs; default bindings are +//! then registered from the registry in registration order, and GPUI resolves +//! later-registered bindings first — so shell/app defaults override component +//! defaults, and a future user keymap (registered last) will override both. + +use gpui::{Action, App, DummyKeyboardMapper, KeyBinding, KeyBindingMetaIndex, Subscription}; + +use super::{CommandId, CommandRegistry, MenuPlan, menu, standard}; +use crate::error::AppShellError; +use crate::plugin::{AppPlugin, ShellSeed, sealed}; + +/// Meta tag stamped on every keybinding this module installs, so a rebuild can +/// tell registry-owned bindings apart from the component tier (bound by +/// `gpui_component::init`) and any future user-keymap tier — neither of which we +/// own — and drop only ours before re-adding the current registry. +const COMMANDS_BINDING_META: KeyBindingMetaIndex = KeyBindingMetaIndex(0xC0FFEE); + +/// Installs and maintains the native menu bar from the command registry. +pub struct MenusPlugin { + plan: Option, + theme_observer: Option, +} + +impl MenusPlugin { + /// A plugin driving the given [`MenuPlan`]. + pub fn new(plan: MenuPlan) -> Self { + Self { + plan: Some(plan), + theme_observer: None, + } + } + + /// A plugin driving the standard App + Edit + Window menus. + pub fn standard() -> Self { + Self::new(MenuPlan::standard()) + } +} + +impl sealed::Sealed for MenusPlugin {} + +impl AppPlugin for MenusPlugin { + fn init(&mut self, cx: &mut App, _shell: &ShellSeed) -> Result<(), AppShellError> { + // Register the standard commands and the shell-owned handlers; this also + // creates the registry global if a contributor has not already. + standard::install(cx); + + // Shell/app-tier default bindings, in registration order (component tier + // was bound by gpui_component::init already). + install_bindings(cx); + + let plan = self.plan.take().unwrap_or_else(MenuPlan::standard); + cx.global_mut::().set_plan(plan); + + // Initial projection, then rebuild whenever the theme changes. + menus_invalidate(cx); + + // Mark the registry live: past this point apps can only register commands + // from `on_launch` (or later), so those registrations must bind and + // re-project themselves — see `AppCommandsExt::register_command`. + cx.global_mut::().activate(); + + self.theme_observer = + Some(cx.observe_global::(|cx| menus_invalidate(cx))); + Ok(()) + } +} + +/// Bind a *newly added* command's default keybinding (if any) and re-project the +/// menus. +/// +/// Append-only fast path for a brand-new command id registered after the initial +/// pass (the common `on_launch` case). The binding lands after the shell defaults +/// and below any future user overrides — preserving precedence. For *replacing* +/// an existing id, use [`rebuild_bindings_and_reproject`], which also removes the +/// replaced command's now-stale chord. +pub(super) fn bind_and_reproject(cx: &mut App, id: CommandId) { + if let Some(binding) = binding_for(cx, id) { + cx.bind_keys([binding]); + } + menus_invalidate(cx); +} + +/// Rebuild the registry's keybindings from scratch and re-project the menus. +/// +/// Used when an already-registered id is replaced after the initial pass: the +/// old command's binding is still live in the keymap, and the fork exposes no +/// per-binding removal — only `clear_key_bindings` (all) plus `bind_keys`. So we +/// snapshot the whole keymap, drop the bindings we own (tagged), and restore the +/// foreign ones (component tier, any user tier) *in place* around a freshly +/// rebuilt registry: those installed before our tier stay before it, those +/// installed after stay after (see [`split_around_registry`]). Net effect: stale +/// registry chords disappear while every foreign tier keeps its bindings, order, +/// and precedence relative to ours. +pub(super) fn rebuild_bindings_and_reproject(cx: &mut App) { + let snapshot: Vec = { + let keymap = cx.key_bindings(); + let keymap = keymap.borrow(); + keymap.bindings().cloned().collect() + }; + let (before, after) = split_around_registry(snapshot); + cx.clear_key_bindings(); + cx.bind_keys(before); + install_bindings(cx); + cx.bind_keys(after); + menus_invalidate(cx); +} + +/// Split a keymap snapshot around the registry tier, dropping the registry-owned +/// (tagged) bindings so they can be rebuilt fresh. +/// +/// Foreign bindings that appeared *before* the first registry-owned binding are +/// returned in `before`; every other foreign binding — including any interleaved +/// with or following the registry tier — is returned in `after`. Rebuilding as +/// `before + registry + after` preserves each foreign binding's position, and +/// thus precedence, relative to our tier. +fn split_around_registry(snapshot: Vec) -> (Vec, Vec) { + let mut before = Vec::new(); + let mut after = Vec::new(); + let mut seen_registry = false; + for binding in snapshot { + if binding.meta() == Some(COMMANDS_BINDING_META) { + seen_registry = true; + continue; + } + if seen_registry { + after.push(binding); + } else { + before.push(binding); + } + } + (before, after) +} + +/// Build the [`KeyBinding`] for a registered command's default binding, if it has +/// one. +fn binding_for(cx: &App, id: CommandId) -> Option { + let registry = cx.global::(); + let command = registry.get(id)?; + let keystroke = command.default_binding()?; + Some(load_binding(keystroke, command.boxed_action())) +} + +/// Construct a global (context-less) keybinding from a keystroke and boxed +/// action, tagged as registry-owned so a rebuild can identify and replace it. +fn load_binding(keystroke: &str, action: Box) -> KeyBinding { + KeyBinding::load(keystroke, action, None, false, None, &DummyKeyboardMapper) + .expect("command default binding must parse") + .with_meta(COMMANDS_BINDING_META) +} + +/// Re-project the registry into the native menu bar (and the macOS dock menu). +/// +/// Call this from any module whose contributing section or command state changed +/// (theme registry updates, window list changes, enabled/checked transitions). +/// A no-op before the registry exists. +pub fn menus_invalidate(cx: &mut App) { + if !cx.has_global::() { + return; + } + let menus = { + let registry = cx.global::(); + menu::build_menus(cx, registry) + }; + if let Some(menus) = menus { + cx.set_menus(menus); + } + + #[cfg(target_os = "macos")] + { + // Set unconditionally: an empty projection must clear a previously + // installed dock menu (e.g. the last dock-placed command was moved off + // the dock), otherwise the stale native item stays live. + let items = { + let registry = cx.global::(); + menu::build_dock_items(cx, registry) + }; + cx.set_dock_menu(items); + } +} + +/// Register default keybindings from the registry, in registration order. GPUI +/// resolves bindings in reverse insertion order, so later registrations win. +fn install_bindings(cx: &mut App) { + let bindings: Vec = { + let registry = cx.global::(); + registry + .commands() + .iter() + .filter_map(|command| { + command + .default_binding() + .map(|keystroke| load_binding(keystroke, command.boxed_action())) + }) + .collect() + }; + cx.bind_keys(bindings); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::{ + APP_MENU, AppCommandsExt, Command, CommandId, CommandRegistry, CommandScope, EDIT_MENU, + }; + use gpui::actions; + + actions!(plugin_test, [A, B, C]); + + /// The pure ordered list of (id, keystroke) that `install_bindings` feeds to + /// `bind_keys`, mirroring registration order (later = higher precedence). + fn binding_order(registry: &CommandRegistry) -> Vec<(CommandId, &'static str)> { + registry + .commands() + .iter() + .filter_map(|c| c.default_binding().map(|k| (c.id(), k))) + .collect() + } + + #[test] + fn split_around_registry_preserves_foreign_positions() { + // component tier (before) | two registry (tagged) | app raw bind (after). + let snapshot = vec![ + KeyBinding::new("ctrl-a", A, None), + load_binding("cmd-1", Box::new(B)), + load_binding("cmd-2", Box::new(B)), + KeyBinding::new("ctrl-b", C, None), + ]; + let (before, after) = split_around_registry(snapshot); + let names = |v: &[KeyBinding]| v.iter().map(|b| b.action().name()).collect::>(); + // Registry-owned bindings dropped; foreign bindings keep their side. + assert_eq!(names(&before), vec![A.name()]); + assert_eq!(names(&after), vec![C.name()]); + } + + #[test] + fn bindings_follow_registration_order() { + let mut registry = CommandRegistry::new(); + registry.register( + Command::new(CommandId("a"), "A", CommandScope::App, A) + .with_binding("cmd-k") + .with_placement(super::super::MenuPlacement::new(APP_MENU, 0, 0)), + ); + // No binding -> excluded. + registry.register(Command::new(CommandId("b"), "B", CommandScope::App, B)); + registry.register( + Command::new(CommandId("c"), "C", CommandScope::App, C) + .with_binding("cmd-k") + .with_placement(super::super::MenuPlacement::new(EDIT_MENU, 0, 0)), + ); + + // "c" comes after "a": it is registered later, so it wins the shared + // "cmd-k" chord under GPUI's reverse-order resolution. + assert_eq!( + binding_order(®istry), + vec![(CommandId("a"), "cmd-k"), (CommandId("c"), "cmd-k")] + ); + } + + #[test] + fn post_activation_registration_exposes_binding_and_placement() { + use crate::commands::{MenuNode, MenuPlacement}; + + let mut registry = CommandRegistry::new(); + // Initial batch, bound by `install_bindings`. + registry.register( + Command::new(CommandId("std"), "Std", CommandScope::App, A).with_binding("cmd-1"), + ); + assert!(!registry.is_active()); + + // The plugin has finished its initial pass. + registry.activate(); + assert!(registry.is_active()); + + // An app registers a new command from `on_launch` (post-activation). The + // ext path would call `bind_and_reproject`; the registry state it reads is + // what we assert here. + registry.register( + Command::new(CommandId("late"), "Late", CommandScope::App, B) + .with_binding("cmd-2") + .with_placement(MenuPlacement::new(APP_MENU, 0, 0)), + ); + + // The late binding is present, ordered after the initial one (so it wins + // a shared chord and sits in the app tier). + assert_eq!( + binding_order(®istry), + vec![(CommandId("std"), "cmd-1"), (CommandId("late"), "cmd-2")] + ); + // And the late command's placement now appears in the projected outline. + let outline = MenuPlan::standard().outline(registry.commands()); + assert!( + outline[0] + .nodes + .contains(&MenuNode::Command(CommandId("late"))) + ); + } + + #[test] + fn app_command_ext_creates_registry_lazily() { + // The ext trait's lazy-create path is covered structurally: with no + // global, command_registry is None; the mutation path is exercised by the + // headless integration test. + fn assert_impls() {} + assert_impls::(); + } +} diff --git a/crates/app/src/commands/standard.rs b/crates/app/src/commands/standard.rs new file mode 100644 index 00000000..7beadbbe --- /dev/null +++ b/crates/app/src/commands/standard.rs @@ -0,0 +1,214 @@ +//! The standard command set: App, Edit, and Window blocks, projected from the +//! registry into the native menu bar. +//! +//! App-scoped commands whose behavior the shell owns (Quit → the single +//! [`request_quit`](crate::AppShellExt::request_quit) path; macOS Hide/Show) +//! get a global `on_action` handler here. Commands whose behavior belongs to the +//! app or a window are registered as vocabulary only: +//! +//! - [`About`] — the app supplies its own handler (about window/panel). +//! - [`CloseWindow`], [`Minimize`], [`Zoom`] — window-scoped; the window manager +//! wires these (flagged as an integration need). +//! - The Edit block — [`gpui_component::input`] actions the focused input already +//! handles; no shell handler and no rebinding (the input component owns the +//! `Input`-context keybindings). + +use gpui::{App, OsAction, actions}; +use gpui_component::input; + +use super::{APP_MENU, AppCommandsExt, Command, CommandId, CommandScope, EDIT_MENU, WINDOW_MENU}; +use crate::handles::AppShellExt; + +actions!( + app, + [ + /// Quit the application through the shell's single shutdown path. + Quit, + /// Show the application's about window (app-provided handler). + About, + /// Close the focused window (window-scoped). + CloseWindow, + ] +); + +#[cfg(target_os = "macos")] +actions!( + app, + [ + /// Hide the application (macOS). + HideApp, + /// Hide other applications (macOS). + HideOthers, + /// Show all applications (macOS). + ShowAll, + /// Minimize the focused window (macOS; window-scoped). + Minimize, + /// Zoom the focused window (macOS; window-scoped). + Zoom, + ] +); + +/// Register the standard commands and the app-scoped handlers the shell owns. +pub(super) fn install(cx: &mut App) { + register_app_block(cx); + register_edit_block(cx); + register_window_block(cx); + register_handlers(cx); +} + +/// App menu: About, (macOS Hide group), Quit. +fn register_app_block(cx: &mut App) { + cx.register_command( + Command::new(CommandId("app.about"), "About", CommandScope::App, About) + .placed(APP_MENU, 0, 0), + ); + + #[cfg(target_os = "macos")] + { + cx.register_command( + Command::new(CommandId("app.hide"), "Hide", CommandScope::App, HideApp) + .with_binding("cmd-h") + .placed(APP_MENU, 1, 0), + ); + cx.register_command( + Command::new( + CommandId("app.hide_others"), + "Hide Others", + CommandScope::App, + HideOthers, + ) + .placed(APP_MENU, 1, 1), + ); + cx.register_command( + Command::new( + CommandId("app.show_all"), + "Show All", + CommandScope::App, + ShowAll, + ) + .placed(APP_MENU, 1, 2), + ); + } + + // Quit sits in a high group so it is always last, separated from the rest. + let quit = + Command::new(CommandId("app.quit"), "Quit", CommandScope::App, Quit).placed(APP_MENU, 9, 0); + #[cfg(target_os = "macos")] + let quit = quit.with_binding("cmd-q"); + cx.register_command(quit); +} + +/// Edit menu: Undo/Redo, Cut/Copy/Paste, Select All (dispatched to the focused +/// input; no shell handler, no rebinding). +fn register_edit_block(cx: &mut App) { + cx.register_command(edit( + CommandId("edit.undo"), + "Undo", + input::Undo, + OsAction::Undo, + 0, + 0, + )); + cx.register_command(edit( + CommandId("edit.redo"), + "Redo", + input::Redo, + OsAction::Redo, + 0, + 1, + )); + cx.register_command(edit( + CommandId("edit.cut"), + "Cut", + input::Cut, + OsAction::Cut, + 1, + 0, + )); + cx.register_command(edit( + CommandId("edit.copy"), + "Copy", + input::Copy, + OsAction::Copy, + 1, + 1, + )); + cx.register_command(edit( + CommandId("edit.paste"), + "Paste", + input::Paste, + OsAction::Paste, + 1, + 2, + )); + cx.register_command(edit( + CommandId("edit.select_all"), + "Select All", + input::SelectAll, + OsAction::SelectAll, + 2, + 0, + )); +} + +/// Build one Edit command (window-scoped, with its OS action and placement). +fn edit( + id: CommandId, + label: &'static str, + action: impl gpui::Action, + os_action: OsAction, + group: u16, + order: u16, +) -> Command { + Command::new(id, label, CommandScope::Window, action) + .with_os_action(os_action) + .placed(EDIT_MENU, group, order) +} + +/// Window menu: (macOS Minimize/Zoom), Close Window. +fn register_window_block(cx: &mut App) { + #[cfg(target_os = "macos")] + { + cx.register_command( + Command::new( + CommandId("window.minimize"), + "Minimize", + CommandScope::Window, + Minimize, + ) + .with_binding("cmd-m") + .placed(WINDOW_MENU, 0, 0), + ); + cx.register_command( + Command::new(CommandId("window.zoom"), "Zoom", CommandScope::Window, Zoom).placed( + WINDOW_MENU, + 0, + 1, + ), + ); + } + + let close = Command::new( + CommandId("window.close"), + "Close Window", + CommandScope::Window, + CloseWindow, + ) + .placed(WINDOW_MENU, 1, 0); + #[cfg(target_os = "macos")] + let close = close.with_binding("cmd-w"); + cx.register_command(close); +} + +/// Register the global handlers the shell owns. Quit routes through the single +/// shutdown path; the macOS Hide/Show actions call the platform directly. +fn register_handlers(cx: &mut App) { + cx.on_action(|_: &Quit, cx: &mut App| cx.request_quit()); + + #[cfg(target_os = "macos")] + { + cx.on_action(|_: &HideApp, cx: &mut App| cx.hide()); + cx.on_action(|_: &HideOthers, cx: &mut App| cx.hide_other_apps()); + cx.on_action(|_: &ShowAll, cx: &mut App| cx.unhide_other_apps()); + } +} diff --git a/crates/app/src/defaults.rs b/crates/app/src/defaults.rs new file mode 100644 index 00000000..47755b8d --- /dev/null +++ b/crates/app/src/defaults.rs @@ -0,0 +1,102 @@ +//! Builder sugar that wires the service plugins together. +//! +//! The service modules (settings, theme, commands, windows) are deliberately +//! decoupled: each exposes a narrow seam instead of importing its peers. This +//! module is the one place those seams are tied — theme persistence to the +//! platform-owned [`ShellPreferences`] store, and the theme menu section into +//! the command registry. + +use gpui::{App, MenuItem}; +use gpui_component::ThemeModePreference; + +use crate::commands::{AppCommandsExt, MenuPlan, MenusPlugin, THEME_SECTION, menus_invalidate}; +use crate::error::AppShellError; +use crate::plugin::{AppPlugin, ShellSeed}; +use crate::settings::{ + AppSettings, SettingsPlugin, StoreKey, ThemeMode, shell_preferences, update_shell_preferences, +}; +use crate::shell::AppShellBuilder; +use crate::theme::{ThemeMenuGroup, ThemePlugin, ThemeSelection, ThemeSource, theme_menu_items}; + +impl AppShellBuilder { + /// Install a typed, persisted settings store (see [`crate::settings`]). + pub fn settings(self, key: StoreKey) -> Self { + self.plugin(SettingsPlugin::::new(key)) + } + + /// Install the theme service with selection persisted in the platform-owned + /// [`ShellPreferences`](crate::settings::ShellPreferences) store, and its + /// Appearance/Theme items projected into the reserved menu section. + pub fn theme(self, source: ThemeSource) -> Self { + self.plugin( + ThemePlugin::new(source) + .with_preferences(read_theme_preference, write_theme_preference), + ) + .plugin(ThemeMenuBridge) + } + + /// Install native menus built from the command registry. + pub fn menus(self, plan: MenuPlan) -> Self { + self.plugin(MenusPlugin::new(plan)) + } +} + +fn read_theme_preference(cx: &App) -> Option { + let prefs = shell_preferences(cx); + Some(ThemeSelection { + mode: match prefs.theme_mode { + ThemeMode::System => ThemeModePreference::System, + ThemeMode::Light => ThemeModePreference::Light, + ThemeMode::Dark => ThemeModePreference::Dark, + }, + name: prefs.theme_name, + }) +} + +fn write_theme_preference(cx: &mut App, selection: &ThemeSelection) { + let mode = match selection.mode { + ThemeModePreference::System => ThemeMode::System, + ThemeModePreference::Light => ThemeMode::Light, + ThemeModePreference::Dark => ThemeMode::Dark, + }; + let name = selection.name.clone(); + if let Err(e) = update_shell_preferences(cx, |prefs| { + prefs.theme_mode = mode; + prefs.theme_name = name; + }) { + log::error!("failed to persist theme preference: {e}"); + } +} + +/// Glue plugin: projects [`theme_menu_items`] into the command registry's +/// reserved theme section and invalidates menus on registry hot-reloads. +struct ThemeMenuBridge; + +impl crate::plugin::sealed::Sealed for ThemeMenuBridge {} + +impl AppPlugin for ThemeMenuBridge { + fn init(&mut self, cx: &mut App, _shell: &ShellSeed) -> Result<(), AppShellError> { + cx.register_menu_section(THEME_SECTION, theme_section_items); + crate::theme::on_theme_registry_changed(cx, |cx| menus_invalidate(cx)).detach(); + Ok(()) + } +} + +fn theme_section_items(cx: &App) -> Vec { + let mut items = Vec::new(); + let mut last_group: Option = None; + for item in theme_menu_items(cx) { + if last_group.is_some_and(|g| g != item.group) { + items.push(MenuItem::Separator); + } + last_group = Some(item.group); + items.push(MenuItem::Action { + name: item.label.into(), + action: item.action, + os_action: None, + checked: item.checked, + disabled: false, + }); + } + items +} diff --git a/crates/app/src/error.rs b/crates/app/src/error.rs new file mode 100644 index 00000000..6799e335 --- /dev/null +++ b/crates/app/src/error.rs @@ -0,0 +1,63 @@ +//! Stable public error type for the application shell. +//! +//! Library callbacks and shell APIs return [`AppShellError`] rather than +//! `anyhow::Error` so downstream apps can match on failure modes across +//! releases (plan §3, "public API errors are a stable `AppShellError`"). +//! `anyhow` is still accepted *into* the shell (via `From`) so application +//! callbacks may use `?` freely. + +use thiserror::Error; + +/// Errors surfaced by the application shell. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum AppShellError { + /// The compiled-in [`crate::AppIdentity`] failed validation. + #[error("invalid app identity: {0}")] + Identity(String), + + /// Per-app directories could not be resolved from the identity namespace. + #[error("failed to resolve application paths")] + Paths(#[source] gpui_component_storage::StorageError), + + /// A required startup service failed and startup cannot continue. + /// + /// Degradable services (theme watcher, file logging) must not use this; + /// they log and continue. Only services declared *required* abort startup. + #[error("required startup service `{service}` failed")] + Service { + /// Stable identifier of the failing service. + service: &'static str, + /// Underlying cause. + #[source] + source: anyhow::Error, + }, + + /// A cross-thread dispatch was attempted after shutdown began. + #[error(transparent)] + Closed(#[from] AppClosed), + + /// An application-supplied callback returned an error. + /// + /// This is the `?`-ergonomic bridge: `anyhow::Error` from a user closure + /// converts here automatically. + #[error("application callback failed")] + Callback(#[source] anyhow::Error), +} + +impl From for AppShellError { + fn from(source: anyhow::Error) -> Self { + AppShellError::Callback(source) + } +} + +// Deliberately no blanket `From`: only path *resolution* failures +// are `AppShellError::Paths`. A blanket conversion would misclassify write, lock, +// and corruption `StorageError`s reached via `?` as path errors, so callers map +// `AppPaths::new` explicitly at the one resolution site (`shell.rs`). + +/// Returned by [`crate::AppProxy::dispatch`] once the shell has begun shutting +/// down and can no longer accept main-thread work. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[error("the application has shut down and can no longer accept dispatched work")] +pub struct AppClosed; diff --git a/crates/app/src/handles.rs b/crates/app/src/handles.rs new file mode 100644 index 00000000..fdea5bf4 --- /dev/null +++ b/crates/app/src/handles.rs @@ -0,0 +1,610 @@ +//! Thread-affinity-explicit handles and the main-thread shell global (plan D3). +//! +//! Two handle kinds with *distinct, compile-tested* auto-trait contracts: +//! +//! - [`AppInfo`] and [`AppProxy`] are `Clone + Send + Sync` — they cross threads +//! (tray, watchers, audio callbacks). +//! - [`ShellState`] is a main-thread-only GPUI [`gpui::Global`], reached through +//! the [`AppShellExt`] extension trait. It is intentionally **not** `Send`. +//! +//! Callbacks always receive a raw `&mut gpui::App`; there is no context wrapper. + +use std::any::{Any, TypeId}; +use std::collections::HashMap; +use std::collections::VecDeque; +use std::sync::mpsc::{Receiver, Sender, channel}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; + +use gpui::App; +use gpui_component_manifest::schema::IdentityRef; +use gpui_component_storage::AppPaths; + +use crate::capabilities::PlatformCapabilities; +use crate::error::AppClosed; +use crate::lifecycle::{AppEvent, OpenRequest, ShutdownReason}; +use crate::liveness::{Liveness, ShellHold}; +use crate::phases::PhaseTracker; +use crate::plugin::{AppPlugin, EventHandler}; + +/// How long the cross-thread drain loop sleeps between polls. +/// +/// WART (flagged): the gpui fork exposes no dependency-free way to wake the main +/// thread from a `Send` context (see the report). The proxy therefore *polls* +/// its command channel on the foreground executor. A proper fix is a gpui +/// `spawn_on_main(impl FnOnce(&mut App) + Send)` primitive or an async channel. +const PROXY_POLL_INTERVAL: Duration = Duration::from_millis(16); + +/// Immutable application identity, resolved paths, and capability snapshot. +/// +/// `Clone + Send + Sync` (compile-asserted below) so background threads can read +/// identity/paths/capabilities without touching the main-thread global. +#[derive(Clone)] +pub struct AppInfo { + inner: Arc, +} + +struct AppInfoInner { + identity: IdentityRef, + paths: AppPaths, + capabilities: PlatformCapabilities, +} + +impl AppInfo { + pub(crate) fn new( + identity: IdentityRef, + paths: AppPaths, + capabilities: PlatformCapabilities, + ) -> Self { + Self { + inner: Arc::new(AppInfoInner { + identity, + paths, + capabilities, + }), + } + } + + /// The stable application id (e.g. `com.example.app`). + pub fn app_id(&self) -> &str { + self.inner.identity.app_id + } + + /// The user-facing display name. + pub fn display_name(&self) -> &str { + self.inner.identity.display_name + } + + /// The canonical SemVer version (`CARGO_PKG_VERSION` of the app). + pub fn version(&self) -> &str { + self.inner.identity.version + } + + /// The compiled-in borrowed identity. + pub fn identity(&self) -> IdentityRef { + self.inner.identity + } + + /// Resolved per-app directories. + pub fn paths(&self) -> &AppPaths { + &self.inner.paths + } + + /// Snapshot of platform capabilities. + pub fn capabilities(&self) -> &PlatformCapabilities { + &self.inner.capabilities + } +} + +type ProxyCommand = Box; + +/// Cross-thread dispatch handle: schedules work onto the main thread. +/// +/// `Clone + Send + Sync`. Serves tray/hotkey/watcher/audio callbacks alike. +/// After shutdown begins, [`AppProxy::dispatch`] returns [`AppClosed`]. +#[derive(Clone)] +pub struct AppProxy { + inner: Arc, +} + +struct ProxyInner { + /// `None` once closed. The closed state lives *inside* the mutex so the + /// closed-check and the send are one atomic operation — nothing can be + /// accepted after `close()` wins the lock. + sender: Mutex>>, +} + +impl AppProxy { + /// Create a proxy and its receiver. The receiver is consumed by the + /// main-thread drain loop; the proxy is cloned to background consumers. + fn new() -> (Self, Receiver) { + let (tx, rx) = channel(); + let inner = Arc::new(ProxyInner { + sender: Mutex::new(Some(tx)), + }); + (Self { inner }, rx) + } + + /// Schedule `f` to run on the main thread with `&mut App`. + /// + /// Returns [`AppClosed`] once shutdown has begun (the proxy is closed at the + /// `ShutdownRequested` boundary). The closed-check and send are atomic under + /// one lock, so no callback is enqueued past that boundary. + pub fn dispatch(&self, f: impl FnOnce(&mut App) + Send + 'static) -> Result<(), AppClosed> { + let guard = self.inner.sender.lock().expect("proxy sender poisoned"); + match guard.as_ref() { + Some(tx) => tx.send(Box::new(f)).map_err(|_| AppClosed), + None => Err(AppClosed), + } + } + + /// Whether the proxy has been closed. + pub fn is_closed(&self) -> bool { + self.inner + .sender + .lock() + .expect("proxy sender poisoned") + .is_none() + } + + /// Close the proxy: reject all future dispatches and let the drain loop + /// observe disconnection and exit. Idempotent. + fn close(&self) { + *self.inner.sender.lock().expect("proxy sender poisoned") = None; + } +} + +/// Raw platform events captured before services exist. Shared between the early +/// listeners (registered pre-`run`) and the main-thread drain. +#[derive(Default)] +pub(crate) struct PendingEvents { + pub(crate) queue: Mutex>, + /// Set only once the shell is ready; its presence tells post-ready listeners + /// they may dispatch a drain instead of relying on the startup drain. + pub(crate) proxy: OnceLock, +} + +impl PendingEvents { + pub(crate) fn push(&self, event: AppEvent) { + self.queue + .lock() + .expect("pending queue poisoned") + .push_back(event); + if let Some(proxy) = self.proxy.get() { + let _ = proxy.dispatch(drain_pending); + } + } +} + +/// Main-thread shell state. A GPUI [`gpui::Global`]; intentionally not `Send`. +pub struct ShellState { + app_info: AppInfo, + proxy: AppProxy, + liveness: Liveness, + plugins: Vec>, + handlers: Vec, + phases: PhaseTracker, + pending: Arc, + state: HashMap>, + subscriptions: Vec, + /// Re-entrancy guard for `deliver_event`. + delivery: crate::lifecycle::ReentrantQueue, + /// The reason to attribute if the next idle evaluation triggers an exit. + /// Set by the window-close observer (which fires before a window-manager + /// plugin drops the window's hold), consumed by `evaluate_exit`. + pending_exit_reason: Option, + shutdown_requested: bool, + will_exit_done: bool, + _drain_task: gpui::Task<()>, +} + +impl gpui::Global for ShellState {} + +impl ShellState { + /// Number of live plugins (for diagnostics/tests). + pub fn plugin_count(&self) -> usize { + self.plugins.len() + } + + /// The recorded phase progress. + pub fn phases(&self) -> &PhaseTracker { + &self.phases + } + + pub(crate) fn record_phase(&mut self, phase: crate::phases::Phase) { + self.phases.complete(phase); + } + + /// Move plugins out for a `&mut App` re-entrant call (init). + pub(crate) fn take_plugins(&mut self) -> Vec> { + std::mem::take(&mut self.plugins) + } + + /// Restore plugins taken by [`ShellState::take_plugins`]. + pub(crate) fn restore_plugins(&mut self, plugins: Vec>) { + self.plugins = plugins; + } +} + +/// A lightweight, cloneable handle for taking liveness leases and driving quit. +#[derive(Clone)] +pub struct ShellHandle { + proxy: AppProxy, + holds: Arc, +} + +impl ShellHandle { + /// Acquire a liveness lease. The shell stays alive until it is dropped (and + /// no windows remain, under [`crate::ExitPolicy::WhenIdle`]). + pub fn hold(&self, reason: &'static str) -> ShellHold { + crate::liveness::acquire_hold(Arc::clone(&self.holds), self.proxy.clone(), reason) + } + + /// The cross-thread proxy. + pub fn proxy(&self) -> AppProxy { + self.proxy.clone() + } +} + +/// Extension trait exposing shell services on the raw `gpui::App`. +pub trait AppShellExt { + /// Immutable app identity/paths/capabilities. + fn app_info(&self) -> &AppInfo; + /// A cross-thread dispatch proxy. + fn app_proxy(&self) -> AppProxy; + /// A handle for liveness leases and quit. + fn shell(&self) -> ShellHandle; + /// Typed pre-platform state registered via `AppShellBuilder::state`. + fn app_state(&self) -> Option<&T>; + /// Route a quit through the single shutdown path. + fn request_quit(&mut self); +} + +impl AppShellExt for App { + fn app_info(&self) -> &AppInfo { + &self.global::().app_info + } + + fn app_proxy(&self) -> AppProxy { + self.global::().proxy.clone() + } + + fn shell(&self) -> ShellHandle { + let st = self.global::(); + ShellHandle { + proxy: st.proxy.clone(), + holds: st.liveness.holds_arc(), + } + } + + fn app_state(&self) -> Option<&T> { + self.global::() + .state + .get(&TypeId::of::()) + .and_then(|b| b.downcast_ref::()) + } + + fn request_quit(&mut self) { + request_quit(self); + } +} + +/// Install the shell global and start the cross-thread drain loop. +/// +/// Called during the `CoreServices` phase with the constructed `AppInfo` and the +/// plugins/handlers/state accumulated by the builder. +#[allow(clippy::too_many_arguments)] +pub(crate) fn install( + cx: &mut App, + app_info: AppInfo, + liveness: Liveness, + plugins: Vec>, + handlers: Vec, + pending: Arc, + state: HashMap>, + phases: PhaseTracker, +) -> AppProxy { + let (proxy, rx) = AppProxy::new(); + let drain_task = spawn_drain_loop(cx, proxy.clone(), rx); + + cx.set_global(ShellState { + app_info, + proxy: proxy.clone(), + liveness, + plugins, + handlers, + phases, + pending, + state, + subscriptions: Vec::new(), + delivery: crate::lifecycle::ReentrantQueue::new(), + pending_exit_reason: None, + shutdown_requested: false, + will_exit_done: false, + _drain_task: drain_task, + }); + proxy +} + +fn spawn_drain_loop(cx: &App, proxy: AppProxy, rx: Receiver) -> gpui::Task<()> { + cx.spawn(async move |cx| { + loop { + // Drain queued commands, applying each on the main thread. Re-check + // closed state BETWEEN callbacks: if one triggers shutdown (closing + // the proxy), discard everything still queued behind it rather than + // running it mid-teardown. + loop { + match rx.try_recv() { + Ok(cmd) => { + cx.update(|app| cmd(app)); + if proxy.is_closed() { + return; + } + } + Err(std::sync::mpsc::TryRecvError::Empty) => break, + Err(std::sync::mpsc::TryRecvError::Disconnected) => return, + } + } + if proxy.is_closed() { + return; + } + cx.background_executor().timer(PROXY_POLL_INTERVAL).await; + } + }) +} + +/// Register lifecycle observers (window-closed, app-quit) after readiness. +pub(crate) fn register_observers(cx: &mut App) { + let window_closed = cx.on_window_closed(|app| { + if app.windows().is_empty() { + // Record the reason now. The actual exit may only happen on a later + // tick, after a window-manager plugin's reconcile drops the closed + // window's liveness hold — this observer runs before that. Recording + // it lets the eventual `evaluate_exit` attribute the exit to + // LastWindowClosed instead of the generic Requested, and avoids a + // premature exit here while the hold is still counted. + app.global_mut::().pending_exit_reason = + Some(ShutdownReason::LastWindowClosed); + deliver_event(app, &AppEvent::LastWindowClosed); + } + evaluate_exit(app); + }); + let app_quit = cx.on_app_quit(|app| { + run_will_exit(app); + std::future::ready(()) + }); + let st = cx.global_mut::(); + st.subscriptions.push(window_closed); + st.subscriptions.push(app_quit); +} + +/// Deliver `event` to every plugin then every app event handler. +/// +/// Re-entrancy-safe: a delivery moves plugins/handlers out of the global for the +/// pass, so a callback that itself delivers an event (e.g. `request_quit()` from +/// a `Started` handler emitting `ShutdownRequested`) would otherwise hit empty +/// subscriber lists. Such nested events are buffered and drained after the +/// current pass, in order (see [`crate::lifecycle::ReentrantQueue`]). +pub(crate) fn deliver_event(cx: &mut App, event: &AppEvent) { + if !cx.has_global::() { + return; + } + if !cx.global_mut::().delivery.try_enter(event) { + // A delivery is already in progress; this event is now buffered and will + // be drained by the active pass below. + return; + } + let mut current = event.clone(); + loop { + deliver_one(cx, ¤t); + match cx.global_mut::().delivery.take_next() { + Some(next) => current = next, + None => break, + } + } +} + +/// Deliver a single event to plugins then handlers, moving them out of the +/// global for the call (so they can receive `&mut App`) and restoring them. +/// Handler errors are logged, not fatal. +fn deliver_one(cx: &mut App, event: &AppEvent) { + let (mut plugins, mut handlers) = { + let st = cx.global_mut::(); + ( + std::mem::take(&mut st.plugins), + std::mem::take(&mut st.handlers), + ) + }; + + for plugin in &mut plugins { + if let Err(err) = plugin.on_event(event, cx) { + log::error!("plugin failed handling {event:?}: {err}"); + } + } + for handler in &mut handlers { + if let Err(err) = handler(event, cx) { + log::error!("event handler failed handling {event:?}: {err}"); + } + } + + let st = cx.global_mut::(); + st.plugins = plugins; + st.handlers = handlers; +} + +/// Drain events buffered by early platform listeners and deliver them. +pub(crate) fn drain_pending(cx: &mut App) { + if !cx.has_global::() { + return; + } + let pending = cx.global::().pending.clone(); + let events: Vec = pending + .queue + .lock() + .expect("pending queue poisoned") + .drain(..) + .collect(); + for event in &events { + deliver_event(cx, event); + } +} + +/// Re-evaluate the exit policy; quit through the single path if idle. +/// +/// If an exit is triggered, it is attributed to any `pending_exit_reason` +/// recorded by the window-close observer (consumed here), else +/// [`ShutdownReason::Requested`]. This makes attribution robust to the ordering +/// between this observer and a window-manager plugin's hold-drop: the reason is +/// recorded on close and consumed by whichever evaluation actually exits. +pub(crate) fn evaluate_exit(cx: &mut App) { + if !cx.has_global::() { + return; + } + let (policy, holds) = { + let st = cx.global::(); + if st.shutdown_requested { + return; + } + (st.liveness.exit_policy(), st.liveness.hold_count()) + }; + let windows = cx.windows().len(); + if crate::liveness::should_exit(policy, holds, windows) { + let reason = cx + .global_mut::() + .pending_exit_reason + .take() + .unwrap_or(ShutdownReason::Requested); + request_quit_with(cx, reason); + } else if windows > 0 { + // The app is alive because a window exists; clear any stale window-close + // reason so a later idle exit (e.g. via hold-drop) is not misattributed. + cx.global_mut::().pending_exit_reason = None; + } +} + +/// The single public quit path. Idempotent. Attributes +/// [`ShutdownReason::Requested`]. +pub(crate) fn request_quit(cx: &mut App) { + request_quit_with(cx, ShutdownReason::Requested); +} + +/// The single quit path with an explicit reason. Idempotent. +pub(crate) fn request_quit_with(cx: &mut App, reason: ShutdownReason) { + if !cx.has_global::() { + cx.quit(); + return; + } + { + let st = cx.global_mut::(); + if st.shutdown_requested { + return; + } + st.shutdown_requested = true; + } + // Stop accepting cross-thread work at the shutdown boundary, before any + // teardown runs — background producers must not enqueue callbacks that would + // land mid-shutdown. + cx.global::().proxy.close(); + deliver_event(cx, &AppEvent::ShutdownRequested(reason)); + // Platform quit fires `on_app_quit`, which runs `run_will_exit`. + cx.quit(); +} + +/// Final teardown, delivered for every quit cause via `on_app_quit`. +fn run_will_exit(cx: &mut App) { + if !cx.has_global::() { + return; + } + { + let st = cx.global_mut::(); + if st.will_exit_done { + return; + } + st.will_exit_done = true; + } + // Close the proxy before any teardown so nothing is accepted past the + // boundary. Idempotent: on the `request_quit` path it is already closed; on a + // platform-initiated quit (Cmd+Q / OS logout) this is the earliest hook. + cx.global::().proxy.close(); + // A platform-initiated quit never went through `request_quit`; surface a + // uniform `ShutdownRequested` first. + let already_requested = cx.global::().shutdown_requested; + if !already_requested { + cx.global_mut::().shutdown_requested = true; + deliver_event( + cx, + &AppEvent::ShutdownRequested(ShutdownReason::PlatformQuit), + ); + } + deliver_event(cx, &AppEvent::WillExit); + shutdown_plugins(cx); +} + +/// Shut plugins down in reverse init order. +fn shutdown_plugins(cx: &mut App) { + let mut plugins = std::mem::take(&mut cx.global_mut::().plugins); + for plugin in plugins.iter_mut().rev() { + plugin.shutdown(cx); + } + // Plugins are done; they are not restored. +} + +/// Convenience for early listeners that need to synthesize an `OpenRequest`. +pub(crate) fn open_event(urls: Vec) -> AppEvent { + AppEvent::OpenRequested(OpenRequest { urls }) +} + +// --------------------------------------------------------------------------- +// Auto-trait contract assertions (plan §3, gate 3). Hand-rolled, no new deps. +// --------------------------------------------------------------------------- + +const _: fn() = || { + fn assert_send_sync() {} + fn assert_clone() {} + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); + assert_clone::(); + assert_clone::(); + assert_clone::(); + assert_send_sync::(); +}; + +// Assert `ShellState` is NOT `Send` (it is a main-thread global). The two +// blanket impls below are ambiguous for any `Send` type, so the inference-driven +// reference resolves only when `ShellState: !Send`. +#[allow(dead_code)] +trait AmbiguousIfSend { + fn deconflict() {} +} +impl AmbiguousIfSend<()> for T {} +impl AmbiguousIfSend for T {} + +const _: fn() = || { + let _ = >::deconflict; +}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dispatch_enqueues_until_closed() { + let (proxy, rx) = AppProxy::new(); + assert!(!proxy.is_closed()); + proxy.dispatch(|_app| {}).expect("dispatch before close"); + assert!(rx.try_recv().is_ok(), "command should be queued"); + + proxy.close(); + assert!(proxy.is_closed()); + assert_eq!(proxy.dispatch(|_app| {}), Err(AppClosed)); + } + + #[test] + fn dispatch_fails_when_receiver_dropped() { + let (proxy, rx) = AppProxy::new(); + drop(rx); + // Sender still present but disconnected → AppClosed. + assert_eq!(proxy.dispatch(|_app| {}), Err(AppClosed)); + } +} diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs new file mode 100644 index 00000000..60a65390 --- /dev/null +++ b/crates/app/src/lib.rs @@ -0,0 +1,77 @@ +//! Application shell for gpui-component apps. +//! +//! `AppShell` is a thin builder over a sealed plugin/phase mechanism. Builder +//! methods record intent; a central sequencer ([`phases`]) fixes the startup and +//! reverse-shutdown order. Lifecycle is an event stream ([`lifecycle::AppEvent`]) +//! with early platform-listener registration and queue-until-ready delivery; +//! liveness is hold/release leases ([`liveness`]); thread affinity is explicit +//! ([`AppInfo`]/[`AppProxy`] are `Send + Sync`, main-thread state is a GPUI +//! global reached via [`AppShellExt`]). +//! +//! See `docs/learned/app-platform-plan.md` §3 for the reviewed contracts. + +pub mod capabilities; +pub mod commands; +mod defaults; +pub mod error; +pub mod handles; +pub mod lifecycle; +pub mod liveness; +pub mod phases; +pub mod plugin; +pub mod settings; +pub mod shell; +pub mod theme; +pub mod windows; + +// Framework re-exports so app crates depend on one thing. +pub use gpui; +pub use gpui_component as ui; +pub use gpui_platform; + +// Identity: the `include_identity!()` macro and its schema types. +pub use gpui_component_manifest::include_identity; +pub use gpui_component_manifest::schema::{AppIdentity, IdentityRef}; + +// Storage types used directly in the shell API. +pub use gpui_component_storage::{AppPaths, PathLayout}; + +// Core surface. +pub use capabilities::{Capability, PlatformCapabilities}; +pub use error::{AppClosed, AppShellError}; +pub use handles::{AppInfo, AppProxy, AppShellExt, ShellHandle, ShellState}; +pub use lifecycle::{AppEvent, LaunchRequest, OpenRequest, ShutdownReason}; +pub use liveness::{ExitPolicy, InitialActivation, ShellHold}; +pub use phases::Phase; +pub use plugin::{AppPlugin, BuildContext, ShellSeed}; +pub use shell::{AppShell, AppShellBuilder, EnvironmentPolicy, LoggingPolicy, PlatformRunner}; + +// Service surface. +pub use commands::{ + AppCommandsExt, Command, CommandId, CommandRegistry, CommandScope, MenuPlacement, MenuPlan, + MenuSection, MenusPlugin, THEME_SECTION, menus_invalidate, +}; +pub use settings::{ + AppSettings, FutureVersionPolicy, SettingsExt, SettingsPlugin, ShellPreferences, + ShellPreferencesPlugin, StoreKey, ThemeMode, shell_preferences, update_shell_preferences, +}; +pub use theme::{ThemePlugin, ThemeSelection, ThemeSource}; +pub use windows::{ + AppWindowsExt, OpenedWindow, OverlaySpec, RootPolicy, WindowKey, WindowManager, WindowSpec, + WindowsPlugin, +}; + +/// Common imports for application entry points: `use gpui_component_app::prelude::*;`. +pub mod prelude { + pub use crate::commands::{AppCommandsExt, MenuPlan}; + pub use crate::error::{AppClosed, AppShellError}; + pub use crate::handles::{AppInfo, AppProxy, AppShellExt, ShellHandle}; + pub use crate::lifecycle::{AppEvent, LaunchRequest, OpenRequest, ShutdownReason}; + pub use crate::liveness::{ExitPolicy, InitialActivation, ShellHold}; + pub use crate::settings::{AppSettings, SettingsExt, StoreKey}; + pub use crate::shell::{AppShell, EnvironmentPolicy, LoggingPolicy, PlatformRunner}; + pub use crate::theme::ThemeSource; + pub use crate::windows::{AppWindowsExt, RootPolicy, WindowKey, WindowManager, WindowSpec}; + pub use crate::{IdentityRef, PathLayout}; + pub use gpui::App; +} diff --git a/crates/app/src/lifecycle.rs b/crates/app/src/lifecycle.rs new file mode 100644 index 00000000..ed0dcb91 --- /dev/null +++ b/crates/app/src/lifecycle.rs @@ -0,0 +1,269 @@ +//! Application lifecycle as an event stream (plan §3). +//! +//! Lifecycle is modelled as a stream of [`AppEvent`]s, not a single launch +//! callback. Raw platform listeners are registered *immediately* after platform +//! construction (before services exist); events that arrive before the shell is +//! ready are queued by [`EventQueue`] and drained after plugin init, in FIFO +//! order (the Electron/GApplication/Zed pattern). This reserves single-instance +//! and deep-link delivery without a future breaking change. +//! +//! The queue logic here is pure (no gpui) and unit-tested directly. + +use std::collections::VecDeque; +use std::path::PathBuf; + +/// A lifecycle event delivered to plugins and app event handlers. +/// +/// `#[non_exhaustive]`: new seams (e.g. `SecondInstance`) may be added without +/// a breaking change. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum AppEvent { + /// The application finished launching. Carries the initial launch request. + Started(LaunchRequest), + /// The application was activated (brought to foreground). + Activated, + /// The application was reopened (e.g. dock icon clicked with no windows). + Reopened, + /// The platform asked the app to open URLs or files. May arrive *before* + /// [`AppEvent::Started`]; such events are queued and delivered after it. + OpenRequested(OpenRequest), + /// The last application window closed. Liveness policy decides whether this + /// leads to exit. + LastWindowClosed, + /// The system is about to suspend. Reserved seam; may be unsupported. + Suspend, + /// The system resumed from suspend. Reserved seam; may be unsupported. + Resume, + /// A quit was requested through any path (menu, programmatic, tray, last + /// window). Delivered once, before shutdown proceeds. + ShutdownRequested(ShutdownReason), + /// The process is about to exit; last chance for a bounded flush. + WillExit, +} + +/// The context in which the application was launched. +#[derive(Debug, Clone, Default)] +pub struct LaunchRequest { + /// Process arguments (excluding argv\[0\]). + pub args: Vec, + /// Working directory at launch, if resolvable. + pub cwd: Option, + /// URLs/files supplied at launch (e.g. `open`-with, deep link). + pub urls: Vec, +} + +impl LaunchRequest { + /// Build a launch request from the current process environment. + pub fn from_env() -> Self { + Self { + args: std::env::args().skip(1).collect(), + cwd: std::env::current_dir().ok(), + urls: Vec::new(), + } + } +} + +/// A request to open one or more URLs or files. +#[derive(Debug, Clone, Default)] +pub struct OpenRequest { + /// The URLs or file paths to open. + pub urls: Vec, +} + +/// Why the application is shutting down. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ShutdownReason { + /// The last window closed and liveness policy chose to exit. + LastWindowClosed, + /// Shutdown was requested programmatically via `request_quit`. + Requested, + /// The platform initiated quit (OS logout, `Cmd+Q`, tray "Quit"). + PlatformQuit, +} + +/// Buffers lifecycle events until the shell is ready, then delivers FIFO. +/// +/// Before [`EventQueue::mark_ready`], [`EventQueue::push`] stores the event and +/// returns `false` (queued). After readiness, `push` returns `true` (the caller +/// should deliver immediately). [`EventQueue::drain`] empties the buffer in +/// arrival order. +#[derive(Debug, Default)] +pub struct EventQueue { + ready: bool, + pending: VecDeque, +} + +impl EventQueue { + /// A new, not-yet-ready queue. + pub fn new() -> Self { + Self::default() + } + + /// Whether the shell has been marked ready. + pub fn is_ready(&self) -> bool { + self.ready + } + + /// Record an event. + /// + /// Returns `Some(event)` if the shell is already ready and the caller should + /// deliver it now; returns `None` if it was buffered for later drain. The + /// event is handed back (rather than signalled with a bool) so a ready caller + /// still owns it to perform the immediate delivery. + #[must_use] + pub fn push(&mut self, event: AppEvent) -> Option { + if self.ready { + Some(event) + } else { + self.pending.push_back(event); + None + } + } + + /// Mark the shell ready. Does not itself drain — call [`EventQueue::drain`]. + pub fn mark_ready(&mut self) { + self.ready = true; + } + + /// Remove and return all buffered events in arrival order. + pub fn drain(&mut self) -> Vec { + self.pending.drain(..).collect() + } + + /// Number of buffered events. + pub fn len(&self) -> usize { + self.pending.len() + } + + /// Whether the buffer is empty. + pub fn is_empty(&self) -> bool { + self.pending.is_empty() + } +} + +/// Guards event delivery against re-entrancy. +/// +/// `deliver_event` moves plugins/handlers out of the shell global for the whole +/// pass, so a callback that itself triggers a delivery (e.g. `request_quit()` +/// from a `Started` handler) would otherwise deliver to empty subscriber lists. +/// While a pass is active, a nested event is buffered here and drained after the +/// current pass, in arrival order. Pure logic, unit-tested directly. +#[derive(Debug, Default)] +pub(crate) struct ReentrantQueue { + delivering: bool, + deferred: VecDeque, +} + +impl ReentrantQueue { + /// A new, idle queue. + pub(crate) fn new() -> Self { + Self::default() + } + + /// Begin a delivery pass for `event`. + /// + /// Returns `true` if the caller should deliver `event` now (no pass was + /// active). Returns `false` if a pass is already active — `event` is + /// buffered and the caller must return without delivering. + #[must_use] + pub(crate) fn try_enter(&mut self, event: &AppEvent) -> bool { + if self.delivering { + self.deferred.push_back(event.clone()); + false + } else { + self.delivering = true; + true + } + } + + /// After delivering one event, return the next buffered event, or `None` to + /// end the pass (clearing the in-progress flag). + pub(crate) fn take_next(&mut self) -> Option { + match self.deferred.pop_front() { + Some(event) => Some(event), + None => { + self.delivering = false; + None + } + } + } + + #[cfg(test)] + pub(crate) fn is_delivering(&self) -> bool { + self.delivering + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn open(url: &str) -> AppEvent { + AppEvent::OpenRequested(OpenRequest { + urls: vec![url.to_string()], + }) + } + + #[test] + fn buffers_until_ready_then_drains_fifo() { + let mut q = EventQueue::new(); + assert!(q.push(open("a")).is_none()); + assert!(q.push(open("b")).is_none()); + assert_eq!(q.len(), 2); + assert!(!q.is_ready()); + + q.mark_ready(); + let drained = q.drain(); + assert_eq!(drained.len(), 2); + match (&drained[0], &drained[1]) { + (AppEvent::OpenRequested(a), AppEvent::OpenRequested(b)) => { + assert_eq!(a.urls, vec!["a".to_string()]); + assert_eq!(b.urls, vec!["b".to_string()]); + } + _ => panic!("unexpected drained events"), + } + assert!(q.is_empty()); + } + + #[test] + fn push_after_ready_signals_immediate_delivery() { + let mut q = EventQueue::new(); + q.mark_ready(); + assert!( + matches!(q.push(open("late")), Some(AppEvent::OpenRequested(_))), + "post-ready push must hand the event back for delivery" + ); + assert!(q.is_empty(), "post-ready push must not buffer"); + } + + #[test] + fn reentrant_queue_defers_nested_events_and_preserves_order() { + let mut q = ReentrantQueue::new(); + // Outer pass begins and delivers now. + assert!(q.try_enter(&AppEvent::Started(LaunchRequest::default()))); + assert!(q.is_delivering()); + // Events raised during the pass are buffered, not delivered. + assert!(!q.try_enter(&AppEvent::Reopened)); + assert!(!q.try_enter(&AppEvent::Activated)); + // Drained in arrival order after the pass. + assert!(matches!(q.take_next(), Some(AppEvent::Reopened))); + assert!(matches!(q.take_next(), Some(AppEvent::Activated))); + assert!(q.take_next().is_none()); + assert!(!q.is_delivering()); + // A fresh pass can start again. + assert!(q.try_enter(&AppEvent::Activated)); + } + + #[test] + fn early_reopen_is_buffered_until_ready() { + // A reopen that arrives during startup (before the shell global exists) + // must be buffered and delivered after `Started`, not dropped. + let mut q = EventQueue::new(); + assert!(q.push(AppEvent::Reopened).is_none()); + q.mark_ready(); + let drained = q.drain(); + assert!(matches!(drained.as_slice(), [AppEvent::Reopened])); + } +} diff --git a/crates/app/src/liveness.rs b/crates/app/src/liveness.rs new file mode 100644 index 00000000..c657f1fd --- /dev/null +++ b/crates/app/src/liveness.rs @@ -0,0 +1,186 @@ +//! Liveness: hold/release leases and exit policy (plan §3). +//! +//! Application lifetime is governed by *leases*, not a static quit mode. Windows, +//! trays, and background services each take a [`ShellHold`]; when the last hold +//! is released and no windows remain, an app with [`ExitPolicy::WhenIdle`] may +//! exit (the GApplication model). There is no unconditional `activate(true)`: +//! initial activation follows [`InitialActivation`], so a tray-first app can +//! launch passively with zero windows. +//! +//! The exit *decision* is a pure function ([`should_exit`]) and is unit-tested +//! without any platform. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use crate::handles::AppProxy; + +/// How the app presents itself at launch. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum InitialActivation { + /// Normal foreground activation (default). + #[default] + Regular, + /// Do not steal focus / show in the foreground. For tray-first apps that + /// launch with no window (plan §3 — "no unconditional `activate(true)`"). + Passive, +} + +/// When the shell is allowed to exit on its own. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ExitPolicy { + /// Exit once there are no windows and no holds (last-window-close model). + #[default] + WhenIdle, + /// Never exit automatically; quit only via an explicit `request_quit` + /// (tray-first apps that intentionally outlive their windows). + Explicit, +} + +/// Pure exit decision. +/// +/// Returns `true` when the shell should exit given the policy, the number of +/// outstanding holds, and the number of open windows. +pub fn should_exit(policy: ExitPolicy, holds: usize, windows: usize) -> bool { + match policy { + ExitPolicy::WhenIdle => holds == 0 && windows == 0, + ExitPolicy::Explicit => false, + } +} + +/// Main-thread liveness state, stored in the shell global. +#[derive(Debug)] +pub struct Liveness { + holds: Arc, + exit_policy: ExitPolicy, + initial_activation: InitialActivation, + activated: bool, +} + +impl Liveness { + /// New liveness state with the given policies and zero holds. + pub fn new(exit_policy: ExitPolicy, initial_activation: InitialActivation) -> Self { + Self { + holds: Arc::new(AtomicUsize::new(0)), + exit_policy, + initial_activation, + activated: false, + } + } + + /// The configured exit policy. + pub fn exit_policy(&self) -> ExitPolicy { + self.exit_policy + } + + /// The configured initial-activation policy. + pub fn initial_activation(&self) -> InitialActivation { + self.initial_activation + } + + /// Whether initial activation has been applied. + pub fn activated(&self) -> bool { + self.activated + } + + /// Record that initial activation has been applied. + pub fn mark_activated(&mut self) { + self.activated = true; + } + + /// Current number of outstanding holds. + pub fn hold_count(&self) -> usize { + self.holds.load(Ordering::SeqCst) + } + + /// The shared hold counter, cloned into [`crate::ShellHandle`] so leases can + /// be taken without borrowing the main-thread global. + pub fn holds_arc(&self) -> Arc { + Arc::clone(&self.holds) + } +} + +/// Acquire a lease against a shared hold counter. +/// +/// Used by [`crate::ShellHandle::hold`]. The returned [`ShellHold`] releases the +/// lease on drop and asks the shell to re-evaluate exit; `proxy` schedules that +/// re-evaluation on the main thread (holds may be dropped off-thread). +pub(crate) fn acquire_hold( + holds: Arc, + proxy: AppProxy, + reason: &'static str, +) -> ShellHold { + holds.fetch_add(1, Ordering::SeqCst); + ShellHold { + holds, + proxy, + reason, + } +} + +/// An RAII liveness lease. Dropping it releases the lease and triggers an exit +/// re-evaluation on the main thread. +pub struct ShellHold { + holds: Arc, + proxy: AppProxy, + reason: &'static str, +} + +impl ShellHold { + /// The reason this hold was taken (for diagnostics). + pub fn reason(&self) -> &'static str { + self.reason + } +} + +impl std::fmt::Debug for ShellHold { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ShellHold") + .field("reason", &self.reason) + .field("remaining", &self.holds.load(Ordering::SeqCst)) + .finish() + } +} + +impl Drop for ShellHold { + fn drop(&mut self) { + self.holds.fetch_sub(1, Ordering::SeqCst); + // Ask the main thread to reconsider exit. If the app has already shut + // down, the dispatch is a no-op — that's the correct outcome. The exit + // reason is taken from any pending window-close record, else `Requested`. + let _ = self.proxy.dispatch(crate::handles::evaluate_exit); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn when_idle_exits_only_with_no_windows_and_no_holds() { + assert!(should_exit(ExitPolicy::WhenIdle, 0, 0)); + assert!(!should_exit(ExitPolicy::WhenIdle, 1, 0)); + assert!(!should_exit(ExitPolicy::WhenIdle, 0, 1)); + assert!(!should_exit(ExitPolicy::WhenIdle, 2, 3)); + } + + #[test] + fn explicit_never_auto_exits() { + assert!(!should_exit(ExitPolicy::Explicit, 0, 0)); + assert!(!should_exit(ExitPolicy::Explicit, 5, 5)); + } + + #[test] + fn startup_idle_whenidle_app_is_eligible_to_exit() { + // The stable-state evaluation the shell runs after activation: a + // WhenIdle app that launched with no window and no hold must be eligible + // to exit (otherwise it lives forever under QuitMode::Explicit)... + assert!(should_exit(ExitPolicy::WhenIdle, 0, 0)); + // ...an app that opened a window during `Started` stays alive... + assert!(!should_exit(ExitPolicy::WhenIdle, 0, 1)); + // ...a hold (e.g. tray) keeps it alive... + assert!(!should_exit(ExitPolicy::WhenIdle, 1, 0)); + // ...and a tray-first Explicit app never auto-exits at startup. + assert!(!should_exit(ExitPolicy::Explicit, 0, 0)); + } +} diff --git a/crates/app/src/phases.rs b/crates/app/src/phases.rs new file mode 100644 index 00000000..e699291b --- /dev/null +++ b/crates/app/src/phases.rs @@ -0,0 +1,135 @@ +//! Central, explicit startup/shutdown phase ordering (plan §3, D3). +//! +//! Ordering is defined here, *not* by the order in which builder methods were +//! called — a builder method only records intent; this module fixes the +//! sequence. Shutdown runs the completed phases in reverse. The logic is pure +//! (no gpui) so ordering and reverse-shutdown are unit-testable without any +//! platform. + +/// Ordered startup phases. Discriminants are the canonical order. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Phase { + /// Validate identity, resolve paths, apply process-global policies, run + /// `before_platform` hooks and prepare typed state. No GPUI yet. + Preflight, + /// Construct the GPUI [`gpui::Application`] and apply `configure_application`. + ConfigureApp, + /// Register raw platform listeners (`on_open_urls`, `on_reopen`) so early + /// events are captured before services exist. + EarlyListeners, + /// `gpui_component::init` — component library globals. + ComponentInit, + /// Build core services: [`crate::AppInfo`], [`crate::AppProxy`], shell global. + CoreServices, + /// Initialize plugins in registration order. + PluginInit, + /// Deliver `Started` and drain any events queued before readiness. + DrainQueue, + /// Apply the initial-activation policy. + Activation, +} + +/// The canonical startup order. +pub const STARTUP_PHASES: [Phase; 8] = [ + Phase::Preflight, + Phase::ConfigureApp, + Phase::EarlyListeners, + Phase::ComponentInit, + Phase::CoreServices, + Phase::PluginInit, + Phase::DrainQueue, + Phase::Activation, +]; + +/// Records which phases completed so shutdown can unwind them in reverse. +/// +/// Only phases that actually completed are unwound — if startup aborts midway, +/// shutdown touches exactly the subsystems that were brought up. +#[derive(Debug, Default, Clone)] +pub struct PhaseTracker { + completed: Vec, +} + +impl PhaseTracker { + /// A tracker with no phases completed. + pub fn new() -> Self { + Self::default() + } + + /// Mark `phase` completed. Phases must be completed in [`STARTUP_PHASES`] + /// order; out-of-order completion is a shell bug and panics in debug. + pub fn complete(&mut self, phase: Phase) { + if let Some(&last) = self.completed.last() { + debug_assert!( + phase > last, + "phases must complete in order: {phase:?} after {last:?}" + ); + } + self.completed.push(phase); + } + + /// Whether `phase` has completed. + pub fn is_complete(&self, phase: Phase) -> bool { + self.completed.contains(&phase) + } + + /// The furthest phase reached, if any. + pub fn last(&self) -> Option { + self.completed.last().copied() + } + + /// Completed phases in reverse order — the shutdown sequence. + pub fn shutdown_order(&self) -> Vec { + self.completed.iter().rev().copied().collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn startup_phases_are_strictly_increasing() { + for pair in STARTUP_PHASES.windows(2) { + assert!(pair[0] < pair[1], "not ordered: {:?}", pair); + } + } + + #[test] + fn shutdown_is_reverse_of_completed() { + let mut t = PhaseTracker::new(); + t.complete(Phase::Preflight); + t.complete(Phase::ConfigureApp); + t.complete(Phase::EarlyListeners); + assert_eq!( + t.shutdown_order(), + vec![Phase::EarlyListeners, Phase::ConfigureApp, Phase::Preflight] + ); + } + + #[test] + fn partial_startup_only_unwinds_reached_phases() { + let mut t = PhaseTracker::new(); + t.complete(Phase::Preflight); + t.complete(Phase::ConfigureApp); + // Startup aborted before services came up. + assert!(!t.is_complete(Phase::CoreServices)); + assert_eq!( + t.shutdown_order(), + vec![Phase::ConfigureApp, Phase::Preflight] + ); + assert_eq!(t.last(), Some(Phase::ConfigureApp)); + } + + #[test] + fn full_startup_unwinds_everything() { + let mut t = PhaseTracker::new(); + for phase in STARTUP_PHASES { + t.complete(phase); + } + let order = t.shutdown_order(); + assert_eq!(order.len(), STARTUP_PHASES.len()); + assert_eq!(order.first().copied(), Some(Phase::Activation)); + assert_eq!(order.last().copied(), Some(Phase::Preflight)); + } +} diff --git a/crates/app/src/plugin.rs b/crates/app/src/plugin.rs new file mode 100644 index 00000000..04d607aa --- /dev/null +++ b/crates/app/src/plugin.rs @@ -0,0 +1,61 @@ +//! The sealed internal plugin mechanism (plan §3, §7 guardrail). +//! +//! Builder methods install internal plugins; the shell drives them through the +//! phase sequencer. The trait is **sealed** until all three real apps have +//! exercised it (Phase 2) — external crates cannot implement it yet, which keeps +//! the surface honest while the contract settles. In-crate services (window +//! manager, settings, theme) implement it via [`sealed::Sealed`]. + +use gpui::App; + +use crate::error::AppShellError; +use crate::handles::{AppInfo, AppProxy}; +use crate::lifecycle::AppEvent; + +/// Boxed application event handler (the `on_event` sugar and `on_launch`). +pub(crate) type EventHandler = Box Result<(), AppShellError>>; + +pub(crate) mod sealed { + /// Seals [`super::AppPlugin`]. Implemented only for in-crate plugin types. + pub trait Sealed {} +} + +/// Build-time context handed to a plugin's [`AppPlugin::configure`], before the +/// GPUI app exists. Lets a plugin read identity/capabilities and contribute to +/// startup without touching `&mut App`. +#[non_exhaustive] +pub struct BuildContext<'a> { + /// The resolved application info (identity, paths, capabilities). + pub info: &'a AppInfo, +} + +/// The minimal shell seed handed to [`AppPlugin::init`]: the parts of the shell +/// that exist before the global is fully installed. +#[non_exhaustive] +pub struct ShellSeed { + /// Immutable app info. + pub info: AppInfo, + /// Cross-thread dispatch proxy. + pub proxy: AppProxy, +} + +/// An internal shell plugin. Sealed; see the module docs. +/// +/// Lifecycle: [`configure`](AppPlugin::configure) (pre-GPUI) → +/// [`init`](AppPlugin::init) (services up) → [`on_event`](AppPlugin::on_event) +/// (per lifecycle event) → [`shutdown`](AppPlugin::shutdown) (reverse order). +pub trait AppPlugin: sealed::Sealed + 'static { + /// Pre-platform configuration. Default: no-op. + fn configure(&mut self, _cx: &mut BuildContext<'_>) {} + + /// Initialize the plugin once core services are up. Required. + fn init(&mut self, cx: &mut App, shell: &ShellSeed) -> Result<(), AppShellError>; + + /// Handle a lifecycle event. Default: ignore. + fn on_event(&mut self, _event: &AppEvent, _cx: &mut App) -> Result<(), AppShellError> { + Ok(()) + } + + /// Tear down. Default: no-op. Called in reverse init order. + fn shutdown(&mut self, _cx: &mut App) {} +} diff --git a/crates/app/src/settings.rs b/crates/app/src/settings.rs new file mode 100644 index 00000000..3fa0cb0b --- /dev/null +++ b/crates/app/src/settings.rs @@ -0,0 +1,462 @@ +//! Typed, named application settings backed by schema-versioned TOML stores. +//! +//! Settings files and their backups are **never for secrets**. Use an OS +//! credential store or another purpose-built secrets facility instead. + +use std::borrow::Cow; +use std::marker::PhantomData; +use std::sync::Arc; +use std::time::Duration; + +use gpui::App; +use gpui_component_storage::{ + AppPaths, DebouncedStore, Envelope, LoadOutcome, StorageError, StoreConfig, +}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::error::AppShellError; +use crate::lifecycle::AppEvent; +use crate::plugin::{AppPlugin, ShellSeed}; + +mod runtime; + +pub use runtime::SettingsExt; +use runtime::{ErasedSettingsEntry, SettingsEntry, SettingsRegistry}; + +const EXIT_FLUSH_TIMEOUT: Duration = Duration::from_secs(5); + +/// A serializable application settings schema. +pub trait AppSettings: Serialize + DeserializeOwned + Default + Send + 'static { + /// Current on-disk schema version. + const SCHEMA_VERSION: u32; + + /// Validate a value before it becomes current or is queued for persistence. + fn validate(&self) -> Result<(), String> { + Ok(()) + } +} + +/// Name of one physical settings store. +/// +/// Store identity is the resulting filename, not `(TypeId, StoreKey)`. Thus one +/// filename has exactly one owner and schema, while the same Rust type may be +/// used safely by multiple distinct keys/files. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct StoreKey(Cow<'static, str>); + +impl StoreKey { + /// Conventional primary application settings store (`settings.toml`). + pub const PRIMARY: Self = Self(Cow::Borrowed("settings")); + + /// Create a named store key. + /// + /// Keys may contain ASCII letters, digits, `-`, and `_` only. + pub fn new(key: impl Into>) -> Result { + let key = key.into(); + if key.is_empty() + || !key + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(SettingsError::InvalidStoreKey(key.into_owned())); + } + Ok(Self(key)) + } + + /// Bare key without the `.toml` suffix. + pub fn as_str(&self) -> &str { + &self.0 + } + + fn filename(&self) -> String { + format!("{}.toml", self.0) + } +} + +/// Behavior when disk contains a schema newer than this build understands. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[non_exhaustive] +pub enum FutureVersionPolicy { + /// Preserve the file byte-for-byte and reject every update (default). + #[default] + RefuseToWrite, + /// Load defaults and allow a later explicit update to replace the file. + Overwrite, +} + +/// Settings registration, migration, validation, and persistence errors. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum SettingsError { + /// A key could escape or alias the config directory. + #[error("invalid settings store key {0:?}")] + InvalidStoreKey(String), + /// No registered store owns this filename. + #[error("settings store {0:?} is not registered")] + NotRegistered(String), + /// Another settings plugin already owns this filename. + #[error("settings store filename {0:?} is already registered")] + DuplicateStore(String), + /// The filename is registered for a different Rust settings type. + #[error("settings store {key:?} has type {actual}, not {expected}")] + TypeMismatch { + /// Requested store key. + key: String, + /// Requested Rust type. + expected: &'static str, + /// Registered Rust type. + actual: &'static str, + }, + /// A migration edge required to reach the current schema is absent. + #[error("no migration registered from schema version {found} toward {current}")] + MissingMigration { + /// Version reached by the chain. + found: u32, + /// Version required by this build. + current: u32, + }, + /// A migration edge is invalid or its function failed. + #[error("migration {from}->{to} failed: {message}")] + Migration { + /// Source schema version. + from: u32, + /// Destination schema version. + to: u32, + /// Migration failure detail. + message: String, + }, + /// A loaded or updated value failed validation. + #[error("settings validation failed: {0}")] + Validation(String), + /// A downgrade attempted to modify a newer on-disk schema. + #[error( + "refusing to write settings: on-disk schema version {found} is newer than supported {supported}" + )] + UnsupportedFutureVersion { + /// Version found on disk. + found: u32, + /// Highest version supported by this store. + supported: u32, + }, + /// A bounded exit flush did not finish before its deadline. + #[error("settings flush for {key:?} timed out after {timeout:?}")] + FlushTimedOut { + /// Store key. + key: String, + /// Applied timeout. + timeout: Duration, + }, + /// The helper used to bound an exit flush could not be started. + #[error("failed to spawn settings flush worker: {0}")] + FlushWorker(String), + /// The linked storage crate returned a load outcome this build does not understand. + #[error("storage returned an unsupported settings load outcome")] + UnsupportedLoadOutcome, + /// Foundation storage failure. + #[error(transparent)] + Storage(#[from] StorageError), +} + +/// Function called for one schema migration edge. +pub type MigrationFn = fn(toml::Value) -> Result; +/// Additional validator installed by [`SettingsPlugin::validate`]. +pub type SettingsValidator = fn(&T) -> Result<(), String>; + +struct MigrationStep { + from: u32, + to: u32, + run: MigrationFn, +} + +/// Plugin registering one typed, named settings store. +pub struct SettingsPlugin { + key: StoreKey, + current_version: u32, + migrations: Vec, + validator: Option>, + future_version_policy: FutureVersionPolicy, + _marker: PhantomData T>, +} + +impl SettingsPlugin { + /// Register `T` under `key` using [`AppSettings::SCHEMA_VERSION`]. + pub fn new(key: StoreKey) -> Self { + Self { + key, + current_version: T::SCHEMA_VERSION, + migrations: Vec::new(), + validator: None, + future_version_policy: FutureVersionPolicy::default(), + _marker: PhantomData, + } + } + + /// Override the current version stamped on disk. + /// + /// Normally the [`AppSettings::SCHEMA_VERSION`] default is sufficient. This + /// builder exists for the explicit §4a registration form. + #[must_use] + pub fn current_version(mut self, version: u32) -> Self { + self.current_version = version; + self + } + + /// Add one directed migration edge. Edges run stepwise from the version on disk. + #[must_use] + pub fn migrate(mut self, from: u32, to: u32, run: MigrationFn) -> Self { + self.migrations.push(MigrationStep { from, to, run }); + self + } + + /// Add plugin-specific validation after [`AppSettings::validate`]. + #[must_use] + pub fn validate(mut self, validator: SettingsValidator) -> Self { + self.validator = Some(validator); + self + } + + /// Set downgrade behavior for a newer on-disk schema. + #[must_use] + pub fn future_version_policy(mut self, policy: FutureVersionPolicy) -> Self { + self.future_version_policy = policy; + self + } + + fn open_entry(&self, paths: &AppPaths) -> Result, SettingsError> { + let path = paths.config_dir().join(self.key.filename()); + let store = Arc::new(DebouncedStore::open( + path, + StoreConfig::new(self.current_version), + )?); + let mut migrated = false; + let (value, future_version) = match store.load()? { + LoadOutcome::Loaded(raw) => (decode_value(raw)?, None), + LoadOutcome::NeedsMigration { found, raw } => { + migrated = true; + (self.apply_migrations(found, raw)?, None) + } + LoadOutcome::FutureVersion { found } => (T::default(), Some(found)), + LoadOutcome::Corrupt { .. } | LoadOutcome::Missing => (T::default(), None), + _ => return Err(SettingsError::UnsupportedLoadOutcome), + }; + + validate_value(&value, self.validator)?; + let entry = SettingsEntry { + key: self.key.as_str().to_string(), + value, + store, + current_version: self.current_version, + future_version, + future_version_policy: self.future_version_policy, + validator: self.validator, + version: 0, + lifecycle_error: None, + exit_flushed: false, + }; + if migrated { + entry.queue_current()?; + } + Ok(entry) + } + + fn apply_migrations(&self, mut version: u32, mut raw: toml::Value) -> Result { + while version < self.current_version { + let migration = self + .migrations + .iter() + .find(|migration| migration.from == version) + .ok_or(SettingsError::MissingMigration { + found: version, + current: self.current_version, + })?; + if migration.to <= migration.from || migration.to > self.current_version { + return Err(SettingsError::Migration { + from: migration.from, + to: migration.to, + message: "invalid migration edge".to_string(), + }); + } + raw = (migration.run)(raw).map_err(|message| SettingsError::Migration { + from: migration.from, + to: migration.to, + message, + })?; + let table = raw.as_table_mut().ok_or_else(|| SettingsError::Migration { + from: migration.from, + to: migration.to, + message: "migration returned a non-table TOML value".to_string(), + })?; + table.insert( + "schema_version".to_string(), + toml::Value::Integer(i64::from(migration.to)), + ); + version = migration.to; + } + + let envelope: Envelope = raw + .try_into() + .map_err(StorageError::from) + .map_err(SettingsError::from)?; + Ok(envelope.inner) + } + + fn flush_for_exit(&self, cx: &mut App) { + let result = cx + .global_mut::() + .entry_mut::(&self.key) + .and_then(ErasedSettingsEntry::flush_for_exit); + if let Err(error) = result { + log::error!( + "failed to flush settings store {:?}: {error}", + self.key.as_str() + ); + } + } +} + +impl crate::plugin::sealed::Sealed for SettingsPlugin {} + +impl AppPlugin for SettingsPlugin { + fn init(&mut self, cx: &mut App, shell: &ShellSeed) -> Result<(), AppShellError> { + if !cx.has_global::() { + cx.set_global(SettingsRegistry::default()); + } + let filename = self.key.filename(); + if cx + .global::() + .entries + .contains_key(&filename) + { + return Err(service_error(SettingsError::DuplicateStore(filename))); + } + let entry = self.open_entry(shell.info.paths()).map_err(service_error)?; + cx.global_mut::() + .entries + .insert(filename, Box::new(entry)); + Ok(()) + } + + fn on_event(&mut self, event: &AppEvent, cx: &mut App) -> Result<(), AppShellError> { + if matches!(event, AppEvent::WillExit) { + self.flush_for_exit(cx); + } + Ok(()) + } + + fn shutdown(&mut self, cx: &mut App) { + self.flush_for_exit(cx); + } +} + +fn service_error(error: SettingsError) -> AppShellError { + AppShellError::Service { + service: "settings", + source: anyhow::Error::new(error), + } +} + +fn decode_value(raw: toml::Value) -> Result { + raw.try_into() + .map_err(StorageError::from) + .map_err(SettingsError::from) +} + +fn validate_value( + value: &T, + validator: Option>, +) -> Result<(), SettingsError> { + value.validate().map_err(SettingsError::Validation)?; + if let Some(validator) = validator { + validator(value).map_err(SettingsError::Validation)?; + } + Ok(()) +} + +/// Persisted shell theme preference. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ThemeMode { + /// Follow the operating-system appearance. + #[default] + System, + /// Force light appearance. + Light, + /// Force dark appearance. + Dark, +} + +/// Platform-owned theme and locale preferences. +/// +/// This settings file and its backups are **never for secrets**. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ShellPreferences { + /// System/light/dark appearance mode. + pub theme_mode: ThemeMode, + /// Selected named theme, if any. + pub theme_name: Option, + /// Selected locale, if any. + pub locale: Option, +} + +impl AppSettings for ShellPreferences { + const SCHEMA_VERSION: u32 = 1; +} + +fn shell_preferences_key() -> StoreKey { + StoreKey(Cow::Borrowed("shell-preferences")) +} + +/// Dedicated plugin for the automatically registered shell-preferences store. +pub struct ShellPreferencesPlugin(SettingsPlugin); + +impl ShellPreferencesPlugin { + /// Create the shell-preferences plugin. + pub fn new() -> Self { + Self(SettingsPlugin::new(shell_preferences_key())) + } +} + +impl Default for ShellPreferencesPlugin { + fn default() -> Self { + Self::new() + } +} + +impl crate::plugin::sealed::Sealed for ShellPreferencesPlugin {} + +impl AppPlugin for ShellPreferencesPlugin { + fn init(&mut self, cx: &mut App, shell: &ShellSeed) -> Result<(), AppShellError> { + self.0.init(cx, shell) + } + + fn on_event(&mut self, event: &AppEvent, cx: &mut App) -> Result<(), AppShellError> { + self.0.on_event(event, cx) + } + + fn shutdown(&mut self, cx: &mut App) { + self.0.shutdown(cx); + } +} + +/// Clone the platform-owned shell preferences without exposing registry internals. +pub fn shell_preferences(cx: &App) -> ShellPreferences { + cx.settings::(shell_preferences_key()) + .clone() +} + +/// Update and debounce-save platform-owned shell preferences. +pub fn update_shell_preferences( + cx: &mut App, + update: impl FnOnce(&mut ShellPreferences), +) -> Result<(), SettingsError> { + cx.update_settings(shell_preferences_key(), |preferences, _cx| { + update(preferences) + }) +} + +#[cfg(test)] +#[path = "settings/tests.rs"] +mod tests; diff --git a/crates/app/src/settings/runtime.rs b/crates/app/src/settings/runtime.rs new file mode 100644 index 00000000..939ced48 --- /dev/null +++ b/crates/app/src/settings/runtime.rs @@ -0,0 +1,296 @@ +use std::any::{Any, TypeId}; +use std::collections::HashMap; +use std::sync::{Arc, mpsc}; + +use gpui::BorrowAppContext; + +use super::*; + +pub(super) trait ErasedSettingsEntry { + fn settings_type_id(&self) -> TypeId; + fn settings_type_name(&self) -> &'static str; + fn value(&self) -> &dyn Any; + fn value_mut(&mut self) -> &mut dyn Any; + fn snapshot_for_update(&self) -> Result; + fn finish_update(&mut self, previous: toml::Value) -> Result<(), SettingsError>; + fn queue_current_erased(&mut self) -> Result<(), SettingsError>; + fn version(&self) -> u64; + fn last_error(&self) -> Option; + fn flush(&mut self) -> Result<(), SettingsError>; + fn flush_for_exit(&mut self) -> Result<(), SettingsError>; +} + +pub(super) struct SettingsEntry { + pub(super) key: String, + pub(super) value: T, + pub(super) store: Arc>, + pub(super) current_version: u32, + pub(super) future_version: Option, + pub(super) future_version_policy: FutureVersionPolicy, + pub(super) validator: Option>, + pub(super) version: u64, + pub(super) lifecycle_error: Option, + pub(super) exit_flushed: bool, +} + +impl SettingsEntry { + fn write_guard(&self) -> Result<(), SettingsError> { + match (self.future_version, self.future_version_policy) { + (Some(found), FutureVersionPolicy::RefuseToWrite) => { + Err(SettingsError::UnsupportedFutureVersion { + found, + supported: self.current_version, + }) + } + _ => Ok(()), + } + } + + pub(super) fn queue_current(&self) -> Result<(), SettingsError> { + self.write_guard()?; + validate_value(&self.value, self.validator)?; + let snapshot = toml::Value::try_from(&self.value) + .map_err(StorageError::from) + .map_err(SettingsError::from)?; + self.store.put(snapshot)?; + Ok(()) + } + + pub(super) fn snapshot_for_update(&self) -> Result { + self.write_guard()?; + toml::Value::try_from(&self.value) + .map_err(StorageError::from) + .map_err(SettingsError::from) + } + + pub(super) fn finish_update(&mut self, previous: toml::Value) -> Result<(), SettingsError> { + if let Err(error) = self.queue_current_erased() { + self.value = decode_value(previous)?; + return Err(error); + } + Ok(()) + } +} + +impl ErasedSettingsEntry for SettingsEntry { + fn settings_type_id(&self) -> TypeId { + TypeId::of::() + } + + fn settings_type_name(&self) -> &'static str { + std::any::type_name::() + } + + fn value(&self) -> &dyn Any { + &self.value + } + + fn value_mut(&mut self) -> &mut dyn Any { + &mut self.value + } + + fn snapshot_for_update(&self) -> Result { + SettingsEntry::snapshot_for_update(self) + } + + fn finish_update(&mut self, previous: toml::Value) -> Result<(), SettingsError> { + SettingsEntry::finish_update(self, previous) + } + + fn queue_current_erased(&mut self) -> Result<(), SettingsError> { + self.queue_current()?; + self.version = self.version.saturating_add(1); + self.exit_flushed = false; + Ok(()) + } + + fn version(&self) -> u64 { + self.version + } + + fn last_error(&self) -> Option { + self.lifecycle_error + .clone() + .or_else(|| self.store.last_error()) + } + + fn flush(&mut self) -> Result<(), SettingsError> { + match self.store.flush() { + Ok(()) => { + self.lifecycle_error = None; + Ok(()) + } + Err(error) => { + self.lifecycle_error = Some(error.to_string()); + Err(error.into()) + } + } + } + + fn flush_for_exit(&mut self) -> Result<(), SettingsError> { + if self.exit_flushed { + return Ok(()); + } + let store = Arc::clone(&self.store); + let (tx, rx) = mpsc::channel(); + let key = self.key.clone(); + if let Err(error) = std::thread::Builder::new() + .name("gpui-settings-flush".to_string()) + .spawn(move || { + let _ = tx.send(store.flush()); + }) + { + let error = SettingsError::FlushWorker(error.to_string()); + self.lifecycle_error = Some(error.to_string()); + return Err(error); + } + + let result = match rx.recv_timeout(EXIT_FLUSH_TIMEOUT) { + Ok(result) => result.map_err(SettingsError::from), + Err(_) => Err(SettingsError::FlushTimedOut { + key, + timeout: EXIT_FLUSH_TIMEOUT, + }), + }; + match &result { + // Only a genuinely completed flush marks the exit flush done. A + // timeout or worker error must NOT set `exit_flushed`, otherwise the + // next call would short-circuit to `Ok(())` and silently suppress the + // unreported persistence failure. + Ok(()) => { + self.exit_flushed = true; + self.lifecycle_error = None; + } + Err(error) => self.lifecycle_error = Some(error.to_string()), + } + result + } +} + +#[derive(Default)] +pub(super) struct SettingsRegistry { + pub(super) entries: HashMap>, +} + +impl gpui::Global for SettingsRegistry {} + +impl SettingsRegistry { + fn entry( + &self, + key: &StoreKey, + ) -> Result<&dyn ErasedSettingsEntry, SettingsError> { + let entry = self + .entries + .get(&key.filename()) + .ok_or_else(|| SettingsError::NotRegistered(key.as_str().to_string()))?; + ensure_type::(entry.as_ref(), key)?; + Ok(entry.as_ref()) + } + + pub(super) fn entry_mut( + &mut self, + key: &StoreKey, + ) -> Result<&mut dyn ErasedSettingsEntry, SettingsError> { + let entry = self + .entries + .get_mut(&key.filename()) + .ok_or_else(|| SettingsError::NotRegistered(key.as_str().to_string()))?; + ensure_type::(entry.as_ref(), key)?; + Ok(entry.as_mut()) + } +} + +fn ensure_type( + entry: &dyn ErasedSettingsEntry, + key: &StoreKey, +) -> Result<(), SettingsError> { + if entry.settings_type_id() == TypeId::of::() { + Ok(()) + } else { + Err(SettingsError::TypeMismatch { + key: key.as_str().to_string(), + expected: std::any::type_name::(), + actual: entry.settings_type_name(), + }) + } +} + +/// Typed settings access on raw [`gpui::App`], matching [`crate::AppShellExt`]. +/// +/// Change observation uses a cheap per-store monotonic version counter. It +/// increments after an update is validated and accepted by `DebouncedStore`. +pub trait SettingsExt { + /// Borrow the current value. Panics if the requested store is not registered. + fn settings(&self, key: StoreKey) -> &T; + + /// Mutate, validate, and queue a debounced save. Invalid changes are rolled back. + fn update_settings( + &mut self, + key: StoreKey, + update: impl FnOnce(&mut T, &mut App) -> R, + ) -> Result; + + /// Current change-observation version. + fn settings_version(&self, key: StoreKey) -> Result; + + /// Most recent background or lifecycle flush error. + fn settings_last_error( + &self, + key: StoreKey, + ) -> Result, SettingsError>; + + /// Synchronously flush pending data and return any persistence failure. + fn flush_settings(&mut self, key: StoreKey) -> Result<(), SettingsError>; +} + +impl SettingsExt for App { + fn settings(&self, key: StoreKey) -> &T { + self.global::() + .entry::(&key) + .unwrap_or_else(|error| panic!("{error}")) + .value() + .downcast_ref::() + .expect("settings registry type check and downcast disagreed") + } + + fn update_settings( + &mut self, + key: StoreKey, + update: impl FnOnce(&mut T, &mut App) -> R, + ) -> Result { + self.update_global::(|registry, cx| { + let entry = registry.entry_mut::(&key)?; + let previous = entry.snapshot_for_update()?; + let typed = entry + .value_mut() + .downcast_mut::() + .expect("settings registry type check and downcast disagreed"); + let result = update(typed, cx); + entry.finish_update(previous)?; + Ok(result) + }) + } + + fn settings_version(&self, key: StoreKey) -> Result { + Ok(self + .global::() + .entry::(&key)? + .version()) + } + + fn settings_last_error( + &self, + key: StoreKey, + ) -> Result, SettingsError> { + Ok(self + .global::() + .entry::(&key)? + .last_error()) + } + + fn flush_settings(&mut self, key: StoreKey) -> Result<(), SettingsError> { + self.global_mut::() + .entry_mut::(&key)? + .flush() + } +} diff --git a/crates/app/src/settings/tests.rs b/crates/app/src/settings/tests.rs new file mode 100644 index 00000000..483bf1b2 --- /dev/null +++ b/crates/app/src/settings/tests.rs @@ -0,0 +1,179 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; + +use gpui_component_storage::PathLayout; +use tempfile::TempDir; + +use super::*; + +#[derive(Debug, Default, PartialEq, Serialize, Deserialize)] +struct TestSettings { + value: String, + stage: u32, +} + +impl AppSettings for TestSettings { + const SCHEMA_VERSION: u32 = 3; +} + +struct TempPaths { + paths: AppPaths, + _root: TempDir, +} + +fn temp_paths() -> TempPaths { + let probe = AppPaths::new( + "gpui-settings-probe", + PathLayout::SingleRoot(".gpui-settings-probe".to_string()), + ) + .unwrap(); + let home = probe + .config_dir() + .parent() + .and_then(std::path::Path::parent) + .unwrap(); + let root = tempfile::Builder::new() + .prefix(".gpui-settings-") + .tempdir_in(home) + .unwrap(); + let root_name = root.path().file_name().unwrap().to_str().unwrap(); + let paths = AppPaths::new( + "gpui-settings-test", + PathLayout::SingleRoot(root_name.to_string()), + ) + .unwrap(); + assert_eq!(paths.config_dir(), root.path().join("config")); + TempPaths { paths, _root: root } +} + +static MIGRATION_STEPS: AtomicUsize = AtomicUsize::new(0); + +fn migrate_1_to_2(mut raw: toml::Value) -> Result { + assert_eq!(raw["value"].as_str(), Some("one")); + raw["stage"] = toml::Value::Integer(2); + MIGRATION_STEPS.fetch_add(1, Ordering::SeqCst); + Ok(raw) +} + +fn migrate_2_to_3(mut raw: toml::Value) -> Result { + assert_eq!(raw["stage"].as_integer(), Some(2)); + raw["stage"] = toml::Value::Integer(3); + MIGRATION_STEPS.fetch_add(1, Ordering::SeqCst); + Ok(raw) +} + +#[test] +fn migration_chain_runs_each_intermediate_step() { + MIGRATION_STEPS.store(0, Ordering::SeqCst); + let temp = temp_paths(); + std::fs::create_dir_all(temp.paths.config_dir()).unwrap(); + std::fs::write( + temp.paths.config_dir().join("settings.toml"), + "schema_version = 1\nvalue = \"one\"\nstage = 1\n", + ) + .unwrap(); + + let entry = SettingsPlugin::::new(StoreKey::PRIMARY) + .migrate(1, 2, migrate_1_to_2) + .migrate(2, 3, migrate_2_to_3) + .open_entry(&temp.paths) + .unwrap(); + + assert_eq!(MIGRATION_STEPS.load(Ordering::SeqCst), 2); + assert_eq!(entry.value.stage, 3); + entry.store.flush().unwrap(); +} + +#[test] +fn future_version_refuses_update_and_preserves_bytes() { + let temp = temp_paths(); + std::fs::create_dir_all(temp.paths.config_dir()).unwrap(); + let path = temp.paths.config_dir().join("settings.toml"); + let bytes = b"schema_version = 99\nvalue = \"future\"\nstage = 99\n"; + std::fs::write(&path, bytes).unwrap(); + + let mut entry = SettingsPlugin::::new(StoreKey::PRIMARY) + .open_entry(&temp.paths) + .unwrap(); + let closure_ran = std::cell::Cell::new(false); + let result = entry.snapshot_for_update().and_then(|previous| { + closure_ran.set(true); + entry.value.value = "changed".to_string(); + entry.finish_update(previous) + }); + assert!(matches!( + result, + Err(SettingsError::UnsupportedFutureVersion { + found: 99, + supported: 3 + }) + )); + assert!( + !closure_ran.get(), + "refused update must not invoke callback" + ); + assert_eq!(entry.value, TestSettings::default()); + assert_eq!(std::fs::read(path).unwrap(), bytes); +} + +#[test] +fn same_type_in_two_named_stores_has_separate_files_and_state() { + let temp = temp_paths(); + let first_key = StoreKey::new("first").unwrap(); + let second_key = StoreKey::new("second").unwrap(); + let mut first = SettingsPlugin::::new(first_key.clone()) + .open_entry(&temp.paths) + .unwrap(); + let mut second = SettingsPlugin::::new(second_key.clone()) + .open_entry(&temp.paths) + .unwrap(); + first.value.value = "first".to_string(); + second.value.value = "second".to_string(); + first.queue_current().unwrap(); + second.queue_current().unwrap(); + first.store.flush().unwrap(); + second.store.flush().unwrap(); + + assert_ne!(first.value, second.value); + assert!(temp.paths.config_dir().join(first_key.filename()).exists()); + assert!(temp.paths.config_dir().join(second_key.filename()).exists()); +} + +#[test] +fn corrupt_file_loads_default() { + let temp = temp_paths(); + std::fs::create_dir_all(temp.paths.config_dir()).unwrap(); + let path = temp.paths.config_dir().join("settings.toml"); + std::fs::write(&path, "not = valid = toml").unwrap(); + + let entry = SettingsPlugin::::new(StoreKey::PRIMARY) + .open_entry(&temp.paths) + .unwrap(); + + assert_eq!(entry.value, TestSettings::default()); + assert!(!path.exists(), "storage must archive corrupt primary"); +} + +#[test] +fn update_flush_reload_roundtrip() { + let temp = temp_paths(); + let mut entry = SettingsPlugin::::new(StoreKey::PRIMARY) + .open_entry(&temp.paths) + .unwrap(); + let previous = entry.snapshot_for_update().unwrap(); + entry.value.value = "persisted".to_string(); + entry.value.stage = 3; + entry.finish_update(previous).unwrap(); + ErasedSettingsEntry::flush(&mut entry).unwrap(); + drop(entry); + + let reloaded = SettingsPlugin::::new(StoreKey::PRIMARY) + .open_entry(&temp.paths) + .unwrap(); + assert_eq!( + reloaded.value, + TestSettings { + value: "persisted".to_string(), + stage: 3, + } + ); +} diff --git a/crates/app/src/shell.rs b/crates/app/src/shell.rs new file mode 100644 index 00000000..c03a9a8e --- /dev/null +++ b/crates/app/src/shell.rs @@ -0,0 +1,548 @@ +//! The `AppShell` builder and phase-driven `run` (plan §3). +//! +//! Builder methods only *record intent*; [`AppShellBuilder::run`] executes the +//! fixed phase order from [`crate::phases`]. Work that must happen before the +//! GPUI event loop (path resolution, env/logging policy, `before_platform`, +//! plugin `configure`) runs in `Preflight`; everything else runs inside the run +//! closure with a raw `&mut gpui::App`. + +use std::any::{Any, TypeId}; +use std::borrow::Cow; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use gpui::{App, Application, AssetSource, QuitMode, SharedString}; +use gpui_component_manifest::schema::IdentityRef; +use gpui_component_storage::{AppPaths, PathLayout}; + +use crate::capabilities::PlatformCapabilities; +use crate::error::AppShellError; +use crate::handles::{self, AppInfo, PendingEvents}; +use crate::lifecycle::{AppEvent, LaunchRequest}; +use crate::liveness::{ExitPolicy, InitialActivation, Liveness}; +use crate::phases::{Phase, PhaseTracker}; +use crate::plugin::{AppPlugin, BuildContext, EventHandler, ShellSeed}; + +/// Process-global environment policy (plan §3 — explicit, never a silent +/// default). Applied once in `Preflight`. +#[non_exhaustive] +pub enum EnvironmentPolicy { + /// Inherit the launching environment unchanged (default; safe for a library). + Inherit, + /// Repair `PATH` from the user's login shell. + /// + /// Not implemented in core: `fix-path-env` is not a dependency. Selecting + /// this logs a warning and behaves as [`EnvironmentPolicy::Inherit`]; apps + /// that need it should run their own repair in `before_platform`. + LoginShell, +} + +// There is deliberately no `Custom(vars)` variant: `std::env::set_var` is +// unsafe (UB with concurrent environment access on Unix), and this builder +// cannot know whether the caller already spawned threads. Apps that need to +// mutate the environment must do so in their own `main()` before constructing +// the shell, where the safety obligation is visibly theirs. + +/// Process-global logging policy (plan §3). The library must not seize the +/// process logger by default. +#[non_exhaustive] +pub enum LoggingPolicy { + /// The application (or its harness) owns logging. Default. + External, + /// Run an app-provided initializer with resolved paths during `Preflight`. + Configure(Box), +} + +/// Selects the GPUI platform backend. Injected for testing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PlatformRunner { + kind: RunnerKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RunnerKind { + Native, + Headless, +} + +impl PlatformRunner { + /// The real platform for the current OS. + pub fn native() -> Self { + Self { + kind: RunnerKind::Native, + } + } + + /// A headless platform, for bootstrap/lifecycle tests. + pub fn headless() -> Self { + Self { + kind: RunnerKind::Headless, + } + } + + fn build(self) -> Application { + match self.kind { + RunnerKind::Native => gpui_platform::application(), + RunnerKind::Headless => gpui_platform::headless(), + } + } +} + +impl Default for PlatformRunner { + fn default() -> Self { + Self::native() + } +} + +/// Entry point: `AppShell::builder(APP_IDENTITY)`. +pub struct AppShell; + +impl AppShell { + /// Start building an application shell from the compiled-in identity. + /// + /// `identity` is the `APP_IDENTITY` produced by `include_identity!()`. + pub fn builder(identity: IdentityRef) -> AppShellBuilder { + AppShellBuilder::new(identity) + } +} + +/// Accumulates configuration; [`AppShellBuilder::run`] executes it. +pub struct AppShellBuilder { + identity: IdentityRef, + assets: Vec>, + path_layout: PathLayout, + environment: EnvironmentPolicy, + logging: LoggingPolicy, + initial_activation: InitialActivation, + exit_policy: ExitPolicy, + configure_app: Option Result>>, + before_platform: Vec Result<(), AppShellError>>>, + state: HashMap>, + plugins: Vec>, + handlers: Vec, + runner: PlatformRunner, +} + +impl AppShellBuilder { + fn new(identity: IdentityRef) -> Self { + Self { + identity, + assets: Vec::new(), + path_layout: PathLayout::PlatformDefault, + environment: EnvironmentPolicy::Inherit, + logging: LoggingPolicy::External, + initial_activation: InitialActivation::Regular, + exit_policy: ExitPolicy::WhenIdle, + configure_app: None, + before_platform: Vec::new(), + state: HashMap::new(), + // Always-present services, installed first so user plugins can rely + // on them during their own init: the platform-owned preferences + // store (theme mode/name, locale) and the window manager. + plugins: vec![ + Box::new(crate::settings::ShellPreferencesPlugin::new()), + Box::new(crate::windows::WindowsPlugin::new()), + ], + handlers: Vec::new(), + runner: PlatformRunner::native(), + } + } + + /// Append an asset source. Sources are tried in registration order; the + /// first to resolve a path wins (no silent last-writer-wins). + pub fn assets(mut self, source: impl AssetSource) -> Self { + self.assets.push(Arc::new(source)); + self + } + + /// Choose the on-disk directory layout (default [`PathLayout::PlatformDefault`]). + pub fn path_layout(mut self, layout: PathLayout) -> Self { + self.path_layout = layout; + self + } + + /// Set the environment policy (default [`EnvironmentPolicy::Inherit`]). + pub fn environment(mut self, policy: EnvironmentPolicy) -> Self { + self.environment = policy; + self + } + + /// Set the logging policy (default [`LoggingPolicy::External`]). + pub fn logging(mut self, policy: LoggingPolicy) -> Self { + self.logging = policy; + self + } + + /// Set the initial-activation policy. Use [`InitialActivation::Passive`] for + /// tray-first apps (default [`InitialActivation::Regular`]). + pub fn initial_activation(mut self, activation: InitialActivation) -> Self { + self.initial_activation = activation; + self + } + + /// Set the exit policy. Use [`ExitPolicy::Explicit`] for apps that outlive + /// their windows (default [`ExitPolicy::WhenIdle`]). + pub fn exit_policy(mut self, policy: ExitPolicy) -> Self { + self.exit_policy = policy; + self + } + + /// Customize the GPUI [`Application`] before it runs (e.g. HTTP client). + pub fn configure_application( + mut self, + f: impl FnOnce(Application) -> Result + 'static, + ) -> Self { + self.configure_app = Some(Box::new(f)); + self + } + + /// Register a stateless side effect to run before the platform starts. Runs + /// on the main thread with no GPUI and no windows. + pub fn before_platform( + mut self, + f: impl FnOnce() -> Result<(), AppShellError> + 'static, + ) -> Self { + self.before_platform.push(Box::new(f)); + self + } + + /// Register typed state prepared before the platform exists (e.g. audio + /// bootstrap). Retrieve it later via `cx.app_state::()`. + pub fn state(mut self, value: T) -> Self { + self.state.insert(TypeId::of::(), Box::new(value)); + self + } + + /// Install an internal plugin. Order is preserved for init; shutdown runs in + /// reverse. + pub fn plugin(mut self, plugin: impl AppPlugin) -> Self { + self.plugins.push(Box::new(plugin)); + self + } + + /// Handle every lifecycle [`AppEvent`]. + pub fn on_event( + mut self, + mut handler: impl FnMut(&AppEvent, &mut App) -> Result<(), AppShellError> + 'static, + ) -> Self { + self.handlers + .push(Box::new(move |event, cx| handler(event, cx))); + self + } + + /// Sugar over `on_event(Started)`: run `f` once the app has launched. + pub fn on_launch( + self, + mut f: impl FnMut(&mut App) -> Result<(), AppShellError> + 'static, + ) -> Self { + self.on_event(move |event, cx| { + if matches!(event, AppEvent::Started(_)) { + f(cx) + } else { + Ok(()) + } + }) + } + + /// Sugar over `on_event(Reopened)`. + pub fn on_reopen( + self, + mut f: impl FnMut(&mut App) -> Result<(), AppShellError> + 'static, + ) -> Self { + self.on_event(move |event, cx| { + if matches!(event, AppEvent::Reopened) { + f(cx) + } else { + Ok(()) + } + }) + } + + /// Inject the platform runner (default native). Use + /// [`PlatformRunner::headless`] in tests. + pub fn runner(mut self, runner: PlatformRunner) -> Self { + self.runner = runner; + self + } + + /// Execute the shell: run the fixed phase sequence, then the GPUI event loop. + pub fn run(self) -> Result<(), AppShellError> { + let Self { + identity, + assets, + path_layout, + environment, + logging, + initial_activation, + exit_policy, + configure_app, + before_platform, + state, + mut plugins, + handlers, + runner, + } = self; + + // ---- Preflight (no GPUI) ---- + validate_identity(&identity)?; + let paths = + AppPaths::new(identity.data_namespace, path_layout).map_err(AppShellError::Paths)?; + apply_environment(environment); + apply_logging(logging, &paths); + for hook in before_platform { + hook()?; + } + let capabilities = PlatformCapabilities::detect(); + let app_info = AppInfo::new(identity, paths, capabilities); + + // Plugin configure() sees identity/paths/capabilities before GPUI exists. + for plugin in &mut plugins { + let mut ctx = BuildContext { info: &app_info }; + plugin.configure(&mut ctx); + } + + let launch = LaunchRequest::from_env(); + + // ---- ConfigureApp ---- + let mut application = runner.build().with_assets(ChainedAssets::new(assets)); + if let Some(configure) = configure_app { + application = configure(application)?; + } + // Quit mode is shell-owned and not customizable: liveness is the single + // quit authority, so every quit routes through `request_quit`. Applied + // AFTER `configure_application` so the app callback cannot re-enable + // platform auto-quit and bypass the lifecycle/teardown path. + application = application.with_quit_mode(QuitMode::Explicit); + + // ---- EarlyListeners ---- + let pending = Arc::new(PendingEvents::default()); + { + let pending = Arc::clone(&pending); + application.on_open_urls(move |urls| { + pending.push(handles::open_event(urls)); + }); + } + { + // Route early reopens through the same queue-until-ready buffer as + // URLs; delivering directly here would be a no-op before the shell + // global is installed, silently losing a startup-time reopen. + let pending = Arc::clone(&pending); + application.on_reopen(move |_cx| { + pending.push(AppEvent::Reopened); + }); + } + + // ---- run: the remaining phases execute on the main thread ---- + let error_cell: Arc>> = Arc::new(Mutex::new(None)); + let liveness = Liveness::new(exit_policy, initial_activation); + let boot = Boot { + app_info, + liveness, + initial_activation, + plugins, + handlers, + pending, + state, + launch, + }; + let error_slot = Arc::clone(&error_cell); + application.run(move |cx| { + if let Err(err) = boot.run(cx) { + // On macOS `cx.quit()` terminates the process with status 0 + // before `Application::run` returns, which would convert a + // startup failure into a successful exit (and let broken boots + // pass the CI smoke gate). Initialized plugins were already + // unwound in reverse by `boot.run`'s error path, so exit + // directly with a failing status. On platforms where `run` + // returns, the error cell below still reports the same error. + log::error!("app shell startup failed: {err}"); + *error_slot.lock().expect("error cell poisoned") = Some(err); + if cfg!(target_os = "macos") { + std::process::exit(2); + } + cx.quit(); + } + }); + + match error_cell.lock().expect("error cell poisoned").take() { + Some(err) => Err(err), + None => Ok(()), + } + } +} + +/// Everything moved into the run closure to execute the post-`ConfigureApp` +/// phases on the main thread. +struct Boot { + app_info: AppInfo, + liveness: Liveness, + initial_activation: InitialActivation, + plugins: Vec>, + handlers: Vec, + pending: Arc, + state: HashMap>, + launch: LaunchRequest, +} + +impl Boot { + fn run(self, cx: &mut App) -> Result<(), AppShellError> { + let Self { + app_info, + liveness, + initial_activation, + mut plugins, + handlers, + pending, + state, + launch, + } = self; + + let mut phases = PhaseTracker::new(); + phases.complete(Phase::Preflight); + phases.complete(Phase::ConfigureApp); + phases.complete(Phase::EarlyListeners); + + // ComponentInit + gpui_component::init(cx); + phases.complete(Phase::ComponentInit); + + // CoreServices: install the global (moves plugins/handlers/state in) and + // start the cross-thread drain loop. + let proxy = handles::install( + cx, + app_info.clone(), + liveness, + std::mem::take(&mut plugins), + handlers, + Arc::clone(&pending), + state, + phases, + ); + cx.global_mut::() + .record_phase(Phase::CoreServices); + + // Install lifecycle observers (incl. the `on_app_quit` teardown hook) + // immediately, BEFORE any application-controlled handler (plugin init, + // `Started`/`on_launch`) can run. Otherwise a `request_quit()` from an + // `on_launch` handler would terminate before the quit observer exists, + // skipping WillExit, reverse plugin shutdown, proxy close, and flush. + handles::register_observers(cx); + + // PluginInit: initialize plugins with the shell seed. A failure here is + // fatal (required service); it aborts startup, unwinding the already- + // initialized prefix in reverse (the documented shutdown contract). + let seed = ShellSeed { + info: app_info, + proxy: proxy.clone(), + }; + let mut installed = cx.global_mut::().take_plugins(); + let mut initialized = 0usize; + let mut init_error = None; + for plugin in &mut installed { + match plugin.init(cx, &seed) { + Ok(()) => initialized += 1, + Err(err) => { + init_error = Some(err); + break; + } + } + } + if let Some(err) = init_error { + // The failing plugin never completed init and is not shut down; the + // successfully-initialized prefix is torn down in reverse order. + for plugin in installed[..initialized].iter_mut().rev() { + plugin.shutdown(cx); + } + return Err(err); + } + cx.global_mut::() + .restore_plugins(installed); + cx.global_mut::() + .record_phase(Phase::PluginInit); + + // DrainQueue: deliver Started, enable post-ready delivery, drain buffer. + handles::deliver_event(cx, &AppEvent::Started(launch)); + let _ = pending.proxy.set(proxy); + handles::drain_pending(cx); + cx.global_mut::() + .record_phase(Phase::DrainQueue); + + // Activation. + match initial_activation { + InitialActivation::Regular => cx.activate(false), + InitialActivation::Passive => {} + } + cx.global_mut::() + .record_phase(Phase::Activation); + + // Startup has reached its stable state. Evaluate exit once so an app + // that launched genuinely idle (no windows opened during `Started`, no + // holds) exits under `ExitPolicy::WhenIdle` instead of living forever + // under the shell-owned `QuitMode::Explicit`. Otherwise nothing would + // ever trigger evaluation (which only fires on window-close/hold-drop). + // No window closed here, so no pending reason is set → attributed to + // `Requested`. + handles::evaluate_exit(cx); + Ok(()) + } +} + +fn validate_identity(identity: &IdentityRef) -> Result<(), AppShellError> { + if identity.app_id.is_empty() { + return Err(AppShellError::Identity("app_id is empty".into())); + } + if identity.data_namespace.is_empty() { + return Err(AppShellError::Identity("data_namespace is empty".into())); + } + Ok(()) +} + +fn apply_environment(policy: EnvironmentPolicy) { + match policy { + EnvironmentPolicy::Inherit => {} + EnvironmentPolicy::LoginShell => { + log::warn!( + "EnvironmentPolicy::LoginShell is not implemented in core; inheriting environment" + ); + } + } +} + +fn apply_logging(policy: LoggingPolicy, paths: &AppPaths) { + match policy { + LoggingPolicy::External => {} + LoggingPolicy::Configure(init) => init(paths), + } +} + +/// Composes asset sources with first-match-wins load and unioned listings. +struct ChainedAssets { + sources: Vec>, +} + +impl ChainedAssets { + fn new(sources: Vec>) -> Self { + Self { sources } + } +} + +impl AssetSource for ChainedAssets { + fn load(&self, path: &str) -> gpui::Result>> { + for source in &self.sources { + if let Some(bytes) = source.load(path)? { + return Ok(Some(bytes)); + } + } + Ok(None) + } + + fn list(&self, path: &str) -> gpui::Result> { + let mut out = Vec::new(); + for source in &self.sources { + out.extend(source.list(path)?); + } + out.sort(); + out.dedup(); + Ok(out) + } +} diff --git a/crates/app/src/theme.rs b/crates/app/src/theme.rs new file mode 100644 index 00000000..f91cd112 --- /dev/null +++ b/crates/app/src/theme.rs @@ -0,0 +1,626 @@ +//! Theme selection, bundled-theme synchronization, and registry integration. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::sync::Arc; + +use anyhow::{Context as _, Result}; +use gpui::{Action, App, Global}; +use gpui_component::{Theme, ThemeConfig, ThemeModePreference, ThemeRegistry}; +use serde::{Deserialize, Serialize}; + +use crate::error::AppShellError; +use crate::plugin::{self, AppPlugin, ShellSeed}; + +const DEFAULT_THEME_SET: &str = "Default"; +const BUNDLED_MANIFEST: &str = "themes-bundled.lst"; + +/// One theme file supplied by a [`ThemeAssetSource`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ThemeAsset { + /// File name relative to the application's theme directory. + pub name: String, + /// Complete JSON file contents. + pub contents: Arc<[u8]>, +} + +impl ThemeAsset { + /// Construct an owned bundled theme file. + pub fn new(name: impl Into, contents: impl Into>) -> Self { + Self { + name: name.into(), + contents: contents.into(), + } + } +} + +/// Minimal embedded-theme abstraction. +/// +/// Apps using `rust_embed` adapt its iterator/get API here. Keeping this trait +/// independent prevents `rust_embed` and its proc macro from becoming mandatory +/// dependencies of every app-shell consumer. +pub trait ThemeAssetSource: Send + Sync + 'static { + /// Return all bundled theme files. + fn theme_assets(&self) -> Result>; +} + +impl ThemeAssetSource for F +where + F: Fn() -> Result> + Send + Sync + 'static, +{ + fn theme_assets(&self) -> Result> { + self() + } +} + +/// Source of themes managed by [`ThemePlugin`]. +pub enum ThemeSource { + /// Embedded files synchronized to `config_dir/themes`. + /// + /// Bundled themes are mirrored to the config directory and then loaded + /// through the registry, so bundled edits and newly installed user themes + /// hot-reload normally. + /// + /// Assets are always synced to disk: gpui-component's [`ThemeRegistry`] + /// exposes no in-memory registration API and keeps its theme maps private, + /// so the watched config directory is the only path that makes bundled + /// themes visible to theme selection. + Bundled { + /// Application-provided embedded files. + assets: Arc, + }, + /// JSON registry rooted at the supplied directory or `config_dir/themes`. + Registry { + /// Override for the watched theme directory. + dir: Option, + }, + /// Fixed palettes. No registry lookup or file watching occurs. + Custom { + /// Light palette. + light: Box, + /// Dark palette. + dark: Box, + }, +} + +impl ThemeSource { + /// Construct a bundled source that syncs into the config directory. + pub fn bundled(assets: impl ThemeAssetSource) -> Self { + Self::Bundled { + assets: Arc::new(assets), + } + } + + /// Construct a registry using `config_dir/themes`. + pub fn registry() -> Self { + Self::Registry { dir: None } + } + + /// Construct fixed light and dark palettes. + pub fn custom(light: ThemeConfig, dark: ThemeConfig) -> Self { + Self::Custom { + light: Box::new(light), + dark: Box::new(dark), + } + } +} + +/// Persistable theme choice. Storage ownership remains with the application. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ThemeSelection { + /// System/light/dark preference. + pub mode: ThemeModePreference, + /// Preferred registry theme-set name. + pub name: Option, +} + +/// Global action selecting a registry theme set. +#[derive(Action, Clone, PartialEq, Eq)] +#[action(namespace = theme, no_json)] +pub struct SwitchTheme(pub String); + +/// Global action selecting system/light/dark mode. +#[derive(Action, Clone, PartialEq, Eq)] +#[action(namespace = theme, no_json)] +pub struct SwitchThemeMode(pub ThemeModePreference); + +/// Logical section for a [`ThemeMenuItem`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThemeMenuGroup { + /// System/light/dark radio group. + Mode, + /// Registry theme-set radio group. + Theme, +} + +/// Command-neutral theme menu contribution. +pub struct ThemeMenuItem { + /// Display label. + pub label: String, + /// GPUI action dispatched when selected. + pub action: Box, + /// Whether this radio item is active. + pub checked: bool, + /// Menu section/radio group. + pub group: ThemeMenuGroup, +} + +type ReadPreference = fn(&App) -> Option; +type WritePreference = fn(&mut App, &ThemeSelection); + +#[derive(Clone, Copy, Default)] +struct PreferenceHooks { + read: Option, + write: Option, +} + +impl PreferenceHooks { + fn read(self, cx: &App) -> Option { + call_read_hook(self.read, cx) + } + + fn write(self, cx: &mut App, selection: &ThemeSelection) { + call_write_hook(self.write, cx, selection); + } +} + +fn call_read_hook( + hook: Option Option>, + cx: &C, +) -> Option { + hook.and_then(|read| read(cx)) +} + +fn call_write_hook( + hook: Option, + cx: &mut C, + selection: &ThemeSelection, +) { + if let Some(write) = hook { + write(cx, selection); + } +} + +struct ThemeState { + selection: ThemeSelection, + hooks: PreferenceHooks, + custom: Option<(Rc, Rc)>, +} + +impl Global for ThemeState {} + +#[derive(Default)] +struct ThemeRegistryVersion(u64); + +impl Global for ThemeRegistryVersion {} + +/// Register a callback for registry loads and hot reloads. +/// +/// Register after `ThemePlugin` initialization. The callback runs after the +/// current selection has been re-applied, so command/menu integrations can +/// rebuild labels and checked state from [`theme_menu_items`]. Retain the +/// returned subscription, or call `detach()` for an app-lifetime listener. +pub fn on_theme_registry_changed( + cx: &mut App, + f: impl FnMut(&mut App) + 'static, +) -> gpui::Subscription { + cx.observe_global::(f) +} + +/// Return mode and theme-set radio items for a command/menu projection. +pub fn theme_menu_items(cx: &App) -> Vec { + let active_mode = Theme::global(cx).mode_preference; + let mut items = [ + ("System", ThemeModePreference::System), + ("Light", ThemeModePreference::Light), + ("Dark", ThemeModePreference::Dark), + ] + .into_iter() + .map(|(label, mode)| ThemeMenuItem { + label: label.to_owned(), + action: Box::new(SwitchThemeMode(mode)), + checked: active_mode == mode, + group: ThemeMenuGroup::Mode, + }) + .collect::>(); + + let uses_registry = cx + .try_global::() + .is_none_or(|state| state.custom.is_none()); + if uses_registry { + let active_name = Theme::global(cx).theme_set_name.clone(); + items.extend( + ThemeRegistry::global(cx) + .sorted_theme_sets() + .into_iter() + .map(|set| ThemeMenuItem { + label: set.name.to_string(), + action: Box::new(SwitchTheme(set.name.to_string())), + checked: active_name == set.name, + group: ThemeMenuGroup::Theme, + }), + ); + } + items +} + +/// App-shell plugin providing theme selection and registry lifecycle. +pub struct ThemePlugin { + source: Option, + hooks: PreferenceHooks, +} + +impl ThemePlugin { + /// Construct a theme plugin. + pub fn new(source: ThemeSource) -> Self { + Self { + source: Some(source), + hooks: PreferenceHooks::default(), + } + } + + /// Supply the only persistence seam used by this module. + /// + /// Without these hooks, selection remains process-local and writes are no-ops. + pub fn with_preferences(mut self, read: ReadPreference, write: WritePreference) -> Self { + self.hooks = PreferenceHooks { + read: Some(read), + write: Some(write), + }; + self + } +} + +impl plugin::sealed::Sealed for ThemePlugin {} + +impl AppPlugin for ThemePlugin { + fn init(&mut self, cx: &mut App, shell: &ShellSeed) -> Result<(), AppShellError> { + let source = self.source.take().expect("ThemePlugin initialized twice"); + let selection = self.hooks.read(cx).unwrap_or_default(); + let mut watch_dir = None; + let custom = match source { + ThemeSource::Bundled { assets } => { + let config_dir = shell.info.paths().config_dir(); + let themes_dir = config_dir.join("themes"); + if let Err(error) = sync_bundled_themes(assets.as_ref(), config_dir) { + log::warn!("failed to sync bundled themes: {error:#}"); + } + watch_dir = Some(themes_dir); + None + } + ThemeSource::Registry { dir } => { + watch_dir = + Some(dir.unwrap_or_else(|| shell.info.paths().config_dir().join("themes"))); + None + } + ThemeSource::Custom { light, dark } => Some((Rc::new(*light), Rc::new(*dark))), + }; + + cx.set_global(ThemeState { + selection, + hooks: self.hooks, + custom, + }); + cx.set_global(ThemeRegistryVersion::default()); + register_actions(cx); + apply_current_selection(cx); + + if let Some(dir) = watch_dir { + cx.observe_global::(registry_reloaded) + .detach(); + if let Err(error) = ThemeRegistry::watch_dir(dir, cx, |_| {}) { + log::warn!("failed to watch theme registry: {error:#}"); + } + } + + Ok(()) + } +} + +fn register_actions(cx: &mut App) { + cx.on_action(|action: &SwitchTheme, cx| { + if cx.global::().custom.is_some() { + return; + } + let mut selection = cx.global::().selection.clone(); + selection.name = Some(action.0.clone()); + update_selection(selection, cx); + }); + cx.on_action(|action: &SwitchThemeMode, cx| { + let mut selection = cx.global::().selection.clone(); + selection.mode = action.0; + update_selection(selection, cx); + }); +} + +fn update_selection(selection: ThemeSelection, cx: &mut App) { + let hooks = cx.global::().hooks; + cx.global_mut::().selection = selection.clone(); + apply_current_selection(cx); + hooks.write(cx, &selection); + cx.refresh_windows(); +} + +fn registry_reloaded(cx: &mut App) { + apply_current_selection(cx); + cx.refresh_windows(); + // Unlike upstream Zed, this fork's `global_mut` pushes + // `NotifyGlobalObservers`, so `observe_global::` + // subscribers (the menu bridge) fire from this bump. + cx.global_mut::().0 += 1; +} + +fn apply_current_selection(cx: &mut App) { + let (selection, custom) = { + let state = cx.global::(); + (state.selection.clone(), state.custom.clone()) + }; + if let Some((light, dark)) = custom { + let theme = Theme::global_mut(cx); + theme.light_theme = light; + theme.dark_theme = dark; + theme.theme_set_name = "Custom".into(); + theme.mode_preference = selection.mode; + Theme::sync_system_appearance(None, cx); + return; + } + + let selected_name = resolve_theme_set_name( + selection.name.as_deref(), + ThemeRegistry::global(cx) + .sorted_theme_sets() + .into_iter() + .map(|set| set.name.to_string()), + ); + let selected = selected_name.and_then(|name| { + ThemeRegistry::global(cx) + .theme_sets() + .get(name.as_str()) + .cloned() + }); + if let Some(set) = selected { + Theme::apply_theme_set(&set, selection.mode, None, cx); + } else { + Theme::global_mut(cx).mode_preference = selection.mode; + Theme::sync_system_appearance(None, cx); + } +} + +fn resolve_theme_set_name( + persisted: Option<&str>, + names: impl IntoIterator, +) -> Option { + let names = names.into_iter().collect::>(); + if let Some(persisted) = persisted + && names.iter().any(|candidate| candidate == persisted) + { + return Some(persisted.to_owned()); + } + if names.iter().any(|name| name == DEFAULT_THEME_SET) { + return Some(DEFAULT_THEME_SET.to_owned()); + } + names.into_iter().next() +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct SyncPlan { + write: Vec, + remove: Vec, +} + +fn plan_bundled_sync( + embedded: &BTreeMap>, + installed: &BTreeMap>, + previous_manifest: &BTreeSet, +) -> SyncPlan { + let write = embedded + .iter() + .filter(|(name, contents)| installed.get(*name) != Some(*contents)) + .map(|(name, _)| name.clone()) + .collect(); + let current_names = embedded.keys().cloned().collect::>(); + let remove = previous_manifest + .difference(¤t_names) + .cloned() + .collect(); + SyncPlan { write, remove } +} + +fn sync_bundled_themes(source: &dyn ThemeAssetSource, config_dir: &Path) -> Result<()> { + let themes_dir = config_dir.join("themes"); + fs::create_dir_all(&themes_dir) + .with_context(|| format!("create theme directory {}", themes_dir.display()))?; + + let embedded = source + .theme_assets()? + .into_iter() + .map(|asset| { + validate_theme_file_name(&asset.name)?; + Ok((asset.name, asset.contents.as_ref().to_vec())) + }) + .collect::>>()?; + let manifest_path = config_dir.join(BUNDLED_MANIFEST); + let previous_manifest = read_manifest(&manifest_path); + let relevant_names = embedded + .keys() + .chain(previous_manifest.iter()) + .cloned() + .collect::>(); + let installed = relevant_names + .into_iter() + .filter_map(|name| { + fs::read(themes_dir.join(&name)) + .ok() + .map(|bytes| (name, bytes)) + }) + .collect::>(); + let plan = plan_bundled_sync(&embedded, &installed, &previous_manifest); + + for name in plan.write { + fs::write(themes_dir.join(&name), &embedded[&name]) + .with_context(|| format!("write bundled theme {name}"))?; + } + for name in plan.remove { + let path = themes_dir.join(&name); + if let Err(error) = fs::remove_file(&path) { + if error.kind() != std::io::ErrorKind::NotFound { + log::warn!("failed to prune bundled theme {}: {error}", path.display()); + } + } + } + write_manifest(&manifest_path, embedded.keys()) +} + +fn validate_theme_file_name(name: &str) -> Result<()> { + let path = Path::new(name); + let valid = !name.is_empty() + && path.file_name().and_then(|value| value.to_str()) == Some(name) + && path.extension().and_then(|value| value.to_str()) == Some("json"); + anyhow::ensure!(valid, "invalid bundled theme file name `{name}`"); + Ok(()) +} + +fn read_manifest(path: &Path) -> BTreeSet { + fs::read_to_string(path) + .unwrap_or_default() + .lines() + .map(str::trim) + .filter(|name| validate_theme_file_name(name).is_ok()) + .map(str::to_owned) + .collect() +} + +fn write_manifest<'a>(path: &Path, names: impl Iterator) -> Result<()> { + let contents = names.cloned().collect::>().join("\n"); + fs::write(path, contents) + .with_context(|| format!("write bundled-theme manifest {}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn names(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_owned()).collect() + } + + #[test] + fn resolves_persisted_name_before_fallbacks() { + assert_eq!( + resolve_theme_set_name(Some("Nord"), names(&["Default", "Nord"])), + Some("Nord".to_owned()) + ); + } + + #[test] + fn resolves_default_then_first_sorted_name() { + assert_eq!( + resolve_theme_set_name(Some("Missing"), names(&["Nord", "Default"])), + Some("Default".to_owned()) + ); + assert_eq!( + resolve_theme_set_name(Some("Missing"), names(&["Ayu", "Zed"])), + Some("Ayu".to_owned()) + ); + } + + #[test] + fn resolves_empty_registry_to_none() { + assert_eq!(resolve_theme_set_name(Some("Missing"), Vec::new()), None); + } + + #[test] + fn bundled_sync_writes_changes_and_prunes_only_manifest_entries() { + let embedded = BTreeMap::from([ + ("changed.json".to_owned(), b"new".to_vec()), + ("same.json".to_owned(), b"same".to_vec()), + ("new.json".to_owned(), b"new".to_vec()), + ]); + let installed = BTreeMap::from([ + ("changed.json".to_owned(), b"old".to_vec()), + ("same.json".to_owned(), b"same".to_vec()), + ("removed.json".to_owned(), b"old".to_vec()), + ("user.json".to_owned(), b"mine".to_vec()), + ]); + let manifest = BTreeSet::from([ + "changed.json".to_owned(), + "same.json".to_owned(), + "removed.json".to_owned(), + ]); + + assert_eq!( + plan_bundled_sync(&embedded, &installed, &manifest), + SyncPlan { + write: names(&["changed.json", "new.json"]), + remove: names(&["removed.json"]), + } + ); + } + + #[test] + fn bundled_sync_writes_registrable_theme_set_into_watched_dir() { + // The registry only surfaces themes it loads from the watched directory, + // so a bundled source must land valid, registrable `ThemeSet` JSON there + // without any pre-existing on-disk state. + let config_dir = tempfile::tempdir().expect("temp config dir"); + let theme_json = br#"{ + "name": "Bundled Sample", + "themes": [ + { "name": "Bundled Sample Light", "mode": "light" }, + { "name": "Bundled Sample Dark", "mode": "dark" } + ] + }"#; + let assets = { + let theme_json = theme_json.to_vec(); + move || { + Ok(vec![ThemeAsset::new( + "bundled-sample.json", + theme_json.clone(), + )]) + } + }; + + sync_bundled_themes(&assets, config_dir.path()).expect("sync bundled themes"); + + let written = fs::read(config_dir.path().join("themes").join("bundled-sample.json")) + .expect("bundled theme written to watched dir"); + // Parse through the exact serde type `ThemeRegistry::reload` uses, proving + // the synced file registers as a named theme set with both modes. + let set: gpui_component::ThemeSet = + serde_json::from_slice(&written).expect("synced file is a valid theme set"); + assert_eq!(set.name.as_ref(), "Bundled Sample"); + assert_eq!(set.themes.len(), 2); + } + + #[test] + fn default_preference_hooks_are_no_op() { + let mut stored: Option = None; + let selection = ThemeSelection { + mode: ThemeModePreference::Dark, + name: Some("Nord".to_owned()), + }; + assert_eq!(call_read_hook(None, &stored), None); + call_write_hook(None, &mut stored, &selection); + assert_eq!(stored, None); + } + + #[test] + fn supplied_preference_hooks_round_trip_through_function_pointers() { + fn read(stored: &Option) -> Option { + stored.clone() + } + fn write(stored: &mut Option, selection: &ThemeSelection) { + *stored = Some(selection.clone()); + } + + let selection = ThemeSelection { + mode: ThemeModePreference::Light, + name: Some("Ayu".to_owned()), + }; + let mut stored = None; + call_write_hook(Some(write), &mut stored, &selection); + assert_eq!(call_read_hook(Some(read), &stored), Some(selection)); + } +} diff --git a/crates/app/src/windows.rs b/crates/app/src/windows.rs new file mode 100644 index 00000000..21eab36c --- /dev/null +++ b/crates/app/src/windows.rs @@ -0,0 +1,527 @@ +//! Window management (plan §3 "Windows", promoted from agent-term + ansible). +//! +//! [`WindowManager`] is an app-scoped GPUI [`gpui::Global`] (installed by +//! [`WindowsPlugin`] at init — *not* a process `OnceLock`). It opens windows and +//! overlays, wraps them in `gpui_component::Root` by policy, numbers/titles them +//! (`"App"`, `"App - 2"`), keeps a stale-handle-safe singleton state machine, +//! and gives each real window a liveness lease so the exit policy sees the +//! window count correctly. +//! +//! ## Borrow discipline +//! +//! GPUI's `open_window` needs `&mut App` re-entrantly, so no method holds a +//! borrow of the [`WindowManager`] global across the open. Each method takes +//! short `global_mut` borrows around the GPUI call — the same move-out pattern +//! the shell core uses for plugin dispatch. +//! +//! ## Pure core +//! +//! Numbering, the singleton transitions, and registry bookkeeping live in the +//! GPUI-free [`registry`] module and are unit-tested there with a fake handle. +//! This module is the thin GPUI edge. + +mod key; +mod registry; +mod spec; + +use std::collections::{HashMap, HashSet}; + +use gpui::{ + AnyWindowHandle, App, AppContext as _, Bounds, Entity, Render, Subscription, Window, + WindowBounds, WindowHandle, +}; +use gpui_component::Root; + +use crate::capabilities::Capability; +use crate::commands::CloseWindow; +#[cfg(target_os = "macos")] +use crate::commands::{Minimize, Zoom}; +use crate::error::AppShellError; +use crate::handles::{AppShellExt as _, ShellState}; +use crate::liveness::ShellHold; +use crate::plugin::{AppPlugin, ShellSeed, sealed}; + +pub use key::WindowKey; +pub use registry::{SingletonPhase, SurfaceKind, WindowRecord}; +pub use spec::{OverlaySpec, RootPolicy, WindowSize, WindowSpec}; + +/// The liveness reason recorded for a window's lease. +const WINDOW_HOLD_REASON: &str = "window"; + +/// Errors from window/overlay operations. +/// +/// Kept local to this module: the core [`AppShellError`] has no window variants +/// and is owned elsewhere. `open`/`open_singleton`/`open_raw`/`open_overlay` +/// therefore return [`WindowError`]; wiring this into the public error taxonomy +/// (if desired) is an integration decision — see the module's report. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum WindowError { + /// The underlying GPUI `open_window` / `open_overlay_surface` call failed. + #[error("failed to open window")] + OpenFailed(#[source] anyhow::Error), + + /// The operation is not supported on this platform/session (e.g. overlay + /// surfaces off macOS); `reason` mirrors the capability report. + #[error("operation unsupported: {reason}")] + Unsupported { + /// Human-readable reason, from [`Capability::Unsupported`]. + reason: &'static str, + }, +} + +/// A freshly opened, `Root`-wrapped window. +/// +/// The auto-`Root`-wrap changes the window's root type, so the handle is typed +/// `WindowHandle` while the caller's content entity is returned alongside +/// it (plan §3 — "make that explicit"). +pub struct OpenedWindow { + /// The window handle (root type is `Root`). + pub window: WindowHandle, + /// The caller's content view, wrapped inside the window's `Root`. + pub content: Entity, +} + +/// Outcome of [`WindowManager::open_singleton`]. +pub enum Singleton { + /// A new window was created. + Opened(OpenedWindow), + /// A live window already existed and was focused; nothing new created. + Reused, + /// A create for this key is already in flight; nothing done. + InFlight, +} + +/// App-scoped window registry + liveness leases. A GPUI [`gpui::Global`]. +pub struct WindowManager { + registry: registry::Registry, + /// Per-window liveness leases, dropped on deregistration so the exit policy + /// re-evaluates. Overlays are intentionally absent (they take no lease). + holds: HashMap, + /// The global window-closed observer that drives [`WindowManager::reconcile`]. + /// Held here so it lives as long as the manager. + close_observer: Option, +} + +impl gpui::Global for WindowManager {} + +impl Default for WindowManager { + fn default() -> Self { + Self::new() + } +} + +impl WindowManager { + /// A new, empty manager. + pub fn new() -> Self { + Self { + registry: registry::Registry::new(), + holds: HashMap::new(), + close_observer: None, + } + } + + /// Open a `Root`-wrapped window from `spec`, building the content view with + /// `build`. Returns the `Root` handle and the content entity. + pub fn open( + cx: &mut App, + spec: WindowSpec, + build: impl FnOnce(&mut Window, &mut App) -> Entity, + ) -> Result, WindowError> { + debug_assert_eq!( + spec.declared_root_policy(), + RootPolicy::ComponentRoot, + "open() wraps in Root; use open_raw() for RootPolicy::Raw" + ); + let key = spec.key(); + let base = base_title(cx, &spec); + let (number, title) = cx.global_mut::().registry.allocate(&base); + + let mut options = spec::base_window_options(&title, spec.background); + options.window_bounds = Some(centered_bounds(spec.size, cx)); + if let Some(pre_show) = spec.pre_show { + pre_show(&mut options); + } + + let post_open = spec.post_open; + let mut content_slot: Option> = None; + let opened = cx.open_window(options, |window, cx| { + if let Some(post_open) = post_open { + post_open(window, cx); + } + let content = build(window, cx); + content_slot = Some(content.clone()); + cx.new(|cx| Root::new(content, window, cx)) + }); + + let window = match opened { + Ok(window) => window, + Err(err) => { + cx.global_mut::() + .registry + .release(&base, number); + return Err(WindowError::OpenFailed(err)); + } + }; + + let content = content_slot.expect("build_root_view ran"); + let handle: AnyWindowHandle = window.into(); + register_window(cx, handle, key, base, number, title); + + Ok(OpenedWindow { window, content }) + } + + /// Open a keyed singleton window: reuse and focus a live one, no-op while a + /// create is in flight, otherwise create. Stale handles (window gone without + /// a clean deregister) are treated as closed and recreated. + pub fn open_singleton( + cx: &mut App, + spec: WindowSpec, + build: impl FnOnce(&mut Window, &mut App) -> Entity, + ) -> Result, WindowError> { + let key = spec.key(); + let phase = cx.global::().registry.singleton_phase(key); + + // Probe liveness only when we think a window is open (focusing it also + // satisfies the reuse case). + let alive = match phase { + SingletonPhase::Open(handle) => handle + .update(cx, |_, window, _| window.activate_window()) + .is_ok(), + _ => false, + }; + + match registry::plan_singleton(phase, alive) { + registry::SingletonAction::InFlight => return Ok(Singleton::InFlight), + registry::SingletonAction::Reuse(_) => return Ok(Singleton::Reused), + registry::SingletonAction::Create => { + // Clear any stale record before recreating. + if let SingletonPhase::Open(handle) = phase { + deregister(cx, handle); + } + } + } + + // Enter `Opening` before the (synchronous) build so a reentrant open for + // this key sees the in-flight state. + cx.global_mut::() + .registry + .set_singleton(key, SingletonPhase::Opening); + + match Self::open(cx, spec, build) { + Ok(opened) => { + let handle: AnyWindowHandle = opened.window.into(); + cx.global_mut::() + .registry + .set_singleton(key, SingletonPhase::Open(handle)); + Ok(Singleton::Opened(opened)) + } + Err(err) => { + cx.global_mut::() + .registry + .set_singleton(key, SingletonPhase::Closed); + Err(err) + } + } + } + + /// Open a window whose content view *is* the root (no `Root` wrapper). For + /// [`RootPolicy::Raw`] specs. + pub fn open_raw( + cx: &mut App, + spec: WindowSpec, + build: impl FnOnce(&mut Window, &mut App) -> Entity, + ) -> Result, WindowError> { + debug_assert_eq!( + spec.declared_root_policy(), + RootPolicy::Raw, + "open_raw() does not wrap in Root; use open() for RootPolicy::ComponentRoot" + ); + let key = spec.key(); + let base = base_title(cx, &spec); + let (number, title) = cx.global_mut::().registry.allocate(&base); + + let mut options = spec::base_window_options(&title, spec.background); + options.window_bounds = Some(centered_bounds(spec.size, cx)); + if let Some(pre_show) = spec.pre_show { + pre_show(&mut options); + } + + let post_open = spec.post_open; + let opened = cx.open_window(options, |window, cx| { + if let Some(post_open) = post_open { + post_open(window, cx); + } + build(window, cx) + }); + + let window = match opened { + Ok(window) => window, + Err(err) => { + cx.global_mut::() + .registry + .release(&base, number); + return Err(WindowError::OpenFailed(err)); + } + }; + + let handle: AnyWindowHandle = window.into(); + register_window(cx, handle, key, base, number, title); + + Ok(window) + } + + /// Open a capability-gated overlay surface (not `Root`-wrapped, not numbered, + /// no liveness lease). Returns [`WindowError::Unsupported`] where the + /// platform reports overlays unavailable. + pub fn open_overlay( + cx: &mut App, + spec: OverlaySpec, + build: impl FnOnce(&mut Window, &mut App) -> Entity, + ) -> Result, WindowError> { + if let Capability::Unsupported { reason } = cx.app_info().capabilities().overlay_surface { + return Err(WindowError::Unsupported { reason }); + } + + let key = spec.key(); + let app_id = cx.app_info().app_id().to_string(); + let mut options = gpui::OverlaySurfaceOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds::centered( + None, spec.size, cx, + ))), + display_id: cx.primary_display().map(|display| display.id()), + show: true, + focus: spec.focus, + window_background: spec.background, + app_id: Some(app_id), + ..Default::default() + }; + if let Some(customize) = spec.customize { + customize(&mut options); + } + + let window = cx + .open_overlay_surface(options, |window, cx| build(window, cx)) + .map_err(WindowError::OpenFailed)?; + let handle: AnyWindowHandle = window.into(); + cx.global_mut::().registry.insert( + handle, + WindowRecord { + key, + base_title: key.as_str().to_string(), + number: 0, + title: key.as_str().to_string(), + kind: SurfaceKind::Overlay, + }, + ); + + Ok(window) + } + + /// Number of open real windows tracked by the manager (excludes overlays). + pub fn window_count(&self) -> usize { + self.registry.window_count() + } + + /// Number of open overlays. + pub fn overlay_count(&self) -> usize { + self.registry.overlay_count() + } + + /// A monotonic version bumped on every registration change. Menu rebuilds + /// (Move-to-Window) observe this to know when to re-project the window list + /// — the minimal observation seam (no callback registry). + pub fn version(&self) -> u64 { + self.registry.version() + } + + /// Snapshot of open real windows as `(handle, key, number, title)`, sorted + /// by number — the Move-to-Window menu projection. + pub fn windows(&self) -> Vec<(AnyWindowHandle, WindowKey, u32, String)> { + let mut windows: Vec<_> = self + .registry + .iter() + .filter(|(_, record)| record.kind == SurfaceKind::Window) + .map(|(handle, record)| (*handle, record.key, record.number, record.title.clone())) + .collect(); + windows.sort_by_key(|(_, _, number, _)| *number); + windows + } +} + +/// Extension trait for read access to the window manager on a raw `App`. +/// +/// Opening windows is done through the `WindowManager::open*` associated +/// functions (they need `&mut App` re-entrantly, which an `&WindowManager` +/// borrow cannot provide). +pub trait AppWindowsExt { + /// The installed window manager, if [`WindowsPlugin`] initialized. + fn window_manager(&self) -> &WindowManager; +} + +impl AppWindowsExt for App { + fn window_manager(&self) -> &WindowManager { + self.global::() + } +} + +/// Register a freshly opened real window: insert its record and take a liveness +/// lease keyed by the handle. +fn register_window( + cx: &mut App, + handle: AnyWindowHandle, + key: WindowKey, + base_title: String, + number: u32, + title: String, +) { + let hold = cx.shell().hold(WINDOW_HOLD_REASON); + let manager = cx.global_mut::(); + manager.registry.insert( + handle, + WindowRecord { + key, + base_title, + number, + title, + kind: SurfaceKind::Window, + }, + ); + manager.holds.insert(handle, hold); +} + +/// Reconcile the registry against the live window set — the primary +/// deregistration trigger, driven by the shell's global `on_window_closed` +/// observer. Any registered surface no longer present in [`App::windows`] has +/// closed (regardless of whether the app still retains its root/content entity) +/// and is deregistered. This is what makes cleanup robust for `open_raw`, where +/// the root *is* the app's view and an entity-release hook would never fire. +fn reconcile(cx: &mut App) { + if !cx.has_global::() { + return; + } + let live: HashSet = cx.windows().into_iter().collect(); + let closed = cx + .global::() + .registry + .closed_handles(|handle| live.contains(handle)); + for handle in closed { + deregister(cx, handle); + } +} + +/// Deregister a surface: drop its liveness lease (re-evaluating exit), remove its +/// record (freeing the number), and reset the singleton phase if this handle was +/// the tracked singleton. Idempotent and shutdown-safe. +fn deregister(cx: &mut App, handle: AnyWindowHandle) { + if !cx.has_global::() { + return; + } + let removed = { + let manager = cx.global_mut::(); + // Dropping the hold here schedules an exit re-evaluation via the proxy. + manager.holds.remove(&handle); + manager.registry.remove(&handle) + }; + if let Some(record) = removed { + let manager = cx.global_mut::(); + if let SingletonPhase::Open(open_handle) = manager.registry.singleton_phase(record.key) { + if open_handle == handle { + manager + .registry + .set_singleton(record.key, SingletonPhase::Closed); + } + } + } +} + +/// The un-numbered base title: the spec's title, or the app display name. +fn base_title(cx: &App, spec: &WindowSpec) -> String { + spec.title + .clone() + .unwrap_or_else(|| cx.app_info().display_name().to_string()) +} + +/// Compute centered window bounds for a [`WindowSize`] on the active display. +fn centered_bounds(size: WindowSize, cx: &App) -> WindowBounds { + let logical = match size { + WindowSize::Fixed(size) => size, + WindowSize::DisplayFraction(fraction) => { + let display = cx + .primary_display() + .map(|display| display.bounds().size) + .unwrap_or_else(|| gpui::size(gpui::px(1024.0), gpui::px(768.0))); + spec::scale_size(display, fraction) + } + }; + WindowBounds::Windowed(Bounds::centered(None, logical, cx)) +} + +/// Register global handlers for the standard window-scoped actions from +/// [`crate::commands`] (the menu/keymap register these; the window manager owns +/// their behavior). Each operates on the platform-active window. +/// +/// All three map to real fork window ops: `remove_window`, `minimize_window`, +/// `zoom_window`. `CloseWindow` (Cmd-W) is cross-platform; `Minimize`/`Zoom` +/// (Cmd-M / green button) are macOS-only actions. +fn register_window_action_handlers(cx: &mut App) { + cx.on_action(|_: &CloseWindow, cx: &mut App| { + if let Some(handle) = cx.active_window() { + let _ = handle.update(cx, |_, window, _| window.remove_window()); + } + }); + + #[cfg(target_os = "macos")] + { + cx.on_action(|_: &Minimize, cx: &mut App| { + if let Some(handle) = cx.active_window() { + let _ = handle.update(cx, |_, window, _| window.minimize_window()); + } + }); + cx.on_action(|_: &Zoom, cx: &mut App| { + if let Some(handle) = cx.active_window() { + let _ = handle.update(cx, |_, window, _| window.zoom_window()); + } + }); + } +} + +/// Installs and tears down the [`WindowManager`] (plan §3, task P1.4). +/// +/// `LastWindowClosed` emission is *not* handled here: the shell core already +/// emits it from its `on_window_closed` observer (see `handles::register_observers`). +/// This plugin only owns the manager's lifecycle. +#[derive(Default)] +pub struct WindowsPlugin; + +impl WindowsPlugin { + /// A new plugin instance. + pub fn new() -> Self { + Self + } +} + +impl sealed::Sealed for WindowsPlugin {} + +impl AppPlugin for WindowsPlugin { + fn init(&mut self, cx: &mut App, _shell: &ShellSeed) -> Result<(), AppShellError> { + cx.set_global(WindowManager::new()); + // Deregistration is driven by actual window closure: reconcile the + // registry against the live window set whenever any window closes. + let observer = cx.on_window_closed(reconcile); + cx.global_mut::().close_observer = Some(observer); + register_window_action_handlers(cx); + Ok(()) + } + + fn shutdown(&mut self, cx: &mut App) { + if !cx.has_global::() || !cx.has_global::() { + return; + } + // Drop every liveness lease so a reverse shutdown does not leave the + // exit policy thinking windows are still holding the app alive. The + // platform tears the windows themselves down as the loop ends. + let manager = cx.global_mut::(); + manager.holds.clear(); + } +} diff --git a/crates/app/src/windows/key.rs b/crates/app/src/windows/key.rs new file mode 100644 index 00000000..a0df023c --- /dev/null +++ b/crates/app/src/windows/key.rs @@ -0,0 +1,40 @@ +//! Stable, non-localized window identity (plan §3 — "titles are not persistence +//! keys"). +//! +//! A [`WindowKey`] names a window *role* (`"main"`, `"settings"`) independently +//! of its user-facing title. The manager keys singleton state and registry +//! bookkeeping on it, so a translated or numbered title never changes identity. + +use std::fmt; + +/// A stable, non-localized identifier for a window role. +/// +/// Wraps a `&'static str` so keys are cheap to copy/compare and cannot drift +/// with locale. Distinct from the window *title*, which is user-facing and may +/// carry a numbering suffix (`"App - 2"`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct WindowKey(&'static str); + +impl WindowKey { + /// Create a key from a compiled-in identifier. + pub const fn new(id: &'static str) -> Self { + Self(id) + } + + /// The underlying identifier. + pub const fn as_str(&self) -> &'static str { + self.0 + } +} + +impl fmt::Display for WindowKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.0) + } +} + +impl From<&'static str> for WindowKey { + fn from(id: &'static str) -> Self { + Self::new(id) + } +} diff --git a/crates/app/src/windows/registry.rs b/crates/app/src/windows/registry.rs new file mode 100644 index 00000000..b23eb086 --- /dev/null +++ b/crates/app/src/windows/registry.rs @@ -0,0 +1,387 @@ +//! Pure window-registry logic: numbering, singleton state machine, and +//! bookkeeping (plan §3 "Windows"). +//! +//! This module deliberately touches *no* GPUI. It is generic over an opaque +//! window handle `H` so the whole surface — number allocation, title formatting, +//! the `Closed | Opening | Open` singleton transitions, and window/overlay +//! counting — is unit-tested without opening a real window. The GPUI-facing +//! [`super::WindowManager`] instantiates it with `gpui::AnyWindowHandle` and +//! sequences the borrows around `open_window`. + +use std::collections::{BTreeSet, HashMap}; +use std::hash::Hash; + +use super::key::WindowKey; + +/// Whether a registered surface is a real window (Root-wrappable, numbered, +/// holds a liveness lease) or an overlay surface (capability-gated, not +/// numbered, no lease). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SurfaceKind { + /// A normal application window. + Window, + /// An overlay surface (click-through / popup). + Overlay, +} + +/// One registered surface. +#[derive(Debug, Clone)] +pub struct WindowRecord { + /// Stable role identity. + pub key: WindowKey, + /// The un-numbered base title this window was opened with. + pub base_title: String, + /// The assigned window number (1-based). `0` for overlays (not numbered). + pub number: u32, + /// The resolved, user-facing title (`base` or `"base - N"`). + pub title: String, + /// Window vs overlay. + pub kind: SurfaceKind, +} + +/// The lifecycle of a keyed singleton window. +/// +/// `Opening` is the async-safety state: it is set *before* the (synchronous, but +/// potentially reentrant) window build begins, so a second `open_singleton` for +/// the same key observes `Opening` and refuses to double-create. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SingletonPhase { + /// No window; a new open should proceed. + Closed, + /// A create is in flight; a concurrent open should no-op. + Opening, + /// A window is open with the given handle (subject to a liveness probe). + Open(H), +} + +/// What an `open_singleton` call should do, given the current phase and whether +/// the tracked handle is still alive. Pure; see [`plan_singleton`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SingletonAction { + /// Proceed to create a new window. + Create, + /// A create is already in flight; do nothing. + InFlight, + /// Focus and reuse the still-live window with this handle. + Reuse(H), +} + +/// Decide what `open_singleton` should do. +/// +/// `alive` is only meaningful when `phase` is [`SingletonPhase::Open`]; callers +/// probe the real handle (e.g. by attempting to focus it) and pass the result. +pub fn plan_singleton(phase: SingletonPhase, alive: bool) -> SingletonAction { + match phase { + SingletonPhase::Closed => SingletonAction::Create, + SingletonPhase::Opening => SingletonAction::InFlight, + SingletonPhase::Open(handle) => { + if alive { + SingletonAction::Reuse(handle) + } else { + // Stale handle: the window is gone but the phase never reset. + SingletonAction::Create + } + } + } +} + +/// Format a window title from its base and 1-based number. +/// +/// Number `1` (the first/only window of that base) keeps the bare title; later +/// windows get a `" - N"` suffix (`"App"`, `"App - 2"`, …). +fn format_title(base: &str, number: u32) -> String { + if number <= 1 { + base.to_string() + } else { + format!("{base} - {number}") + } +} + +/// The pure registry: numbers, records, and singleton phases. +#[derive(Debug)] +pub struct Registry { + records: HashMap, + /// In-use window numbers per base title. Reserved at `allocate`, freed at + /// `remove`/`release`, so numbering survives a build that reentrantly opens + /// another window of the same base. + numbers: HashMap>, + singletons: HashMap>, + version: u64, +} + +impl Default for Registry { + fn default() -> Self { + Self { + records: HashMap::new(), + numbers: HashMap::new(), + singletons: HashMap::new(), + version: 0, + } + } +} + +impl Registry { + /// A fresh, empty registry. + pub fn new() -> Self { + Self::default() + } + + /// Reserve the lowest free number for `base_title` and return it with the + /// formatted title. The number stays reserved until [`Registry::remove`] or + /// [`Registry::release`]. + pub fn allocate(&mut self, base_title: &str) -> (u32, String) { + let set = self.numbers.entry(base_title.to_string()).or_default(); + let mut number = 1; + while set.contains(&number) { + number += 1; + } + set.insert(number); + (number, format_title(base_title, number)) + } + + /// Release a reserved number that never became — or is no longer — a record + /// (e.g. `open_window` failed after [`Registry::allocate`]). + pub fn release(&mut self, base_title: &str, number: u32) { + if let Some(set) = self.numbers.get_mut(base_title) { + set.remove(&number); + if set.is_empty() { + self.numbers.remove(base_title); + } + } + } + + /// Register an opened surface under its handle. Bumps the change version. + pub fn insert(&mut self, handle: H, record: WindowRecord) { + self.records.insert(handle, record); + self.version += 1; + } + + /// Deregister a surface, freeing its window number. Bumps the change + /// version. Returns the removed record, if any. + pub fn remove(&mut self, handle: &H) -> Option { + let record = self.records.remove(handle)?; + self.release(&record.base_title, record.number); + self.version += 1; + Some(record) + } + + /// Number of registered real windows (excludes overlays). + pub fn window_count(&self) -> usize { + self.records + .values() + .filter(|r| r.kind == SurfaceKind::Window) + .count() + } + + /// Number of registered overlays. + pub fn overlay_count(&self) -> usize { + self.records + .values() + .filter(|r| r.kind == SurfaceKind::Overlay) + .count() + } + + /// A monotonic counter bumped on every insert/remove — a minimal + /// observation seam for menu rebuilds (Move-to-Window) without a callback + /// registry. + pub fn version(&self) -> u64 { + self.version + } + + /// Iterate registered `(handle, record)` pairs. + pub fn iter(&self) -> impl Iterator { + self.records.iter() + } + + /// Registered handles for which `is_live` returns `false` — i.e. surfaces + /// that have closed and whose records should be deregistered. + /// + /// This is the pure core of reconciliation: the GPUI edge passes a predicate + /// backed by the live window set, so deregistration is driven by actual + /// window closure rather than entity lifetime (which a retained root/content + /// entity would defeat). + pub fn closed_handles(&self, is_live: impl Fn(&H) -> bool) -> Vec { + self.records + .keys() + .filter(|handle| !is_live(handle)) + .copied() + .collect() + } + + /// The current singleton phase for `key` ([`SingletonPhase::Closed`] if the + /// key was never opened as a singleton). + pub fn singleton_phase(&self, key: WindowKey) -> SingletonPhase { + self.singletons + .get(&key) + .copied() + .unwrap_or(SingletonPhase::Closed) + } + + /// Set the singleton phase for `key`. `Closed` clears the entry. + pub fn set_singleton(&mut self, key: WindowKey, phase: SingletonPhase) { + match phase { + SingletonPhase::Closed => { + self.singletons.remove(&key); + } + other => { + self.singletons.insert(key, other); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const MAIN: WindowKey = WindowKey::new("main"); + const SETTINGS: WindowKey = WindowKey::new("settings"); + + fn record(number: u32, title: &str, kind: SurfaceKind) -> WindowRecord { + WindowRecord { + key: MAIN, + base_title: "App".to_string(), + number, + title: title.to_string(), + kind, + } + } + + #[test] + fn first_window_keeps_bare_title_later_are_suffixed() { + let mut reg: Registry = Registry::new(); + assert_eq!(reg.allocate("App"), (1, "App".to_string())); + assert_eq!(reg.allocate("App"), (2, "App - 2".to_string())); + assert_eq!(reg.allocate("App"), (3, "App - 3".to_string())); + } + + #[test] + fn numbering_reuses_lowest_free_after_release() { + let mut reg: Registry = Registry::new(); + let (n1, _) = reg.allocate("App"); // 1 + let (n2, _) = reg.allocate("App"); // 2 + let (n3, _) = reg.allocate("App"); // 3 + assert_eq!((n1, n2, n3), (1, 2, 3)); + + // Freeing #2 makes 2 the lowest free again. + reg.release("App", 2); + assert_eq!(reg.allocate("App"), (2, "App - 2".to_string())); + // 4 is next after 1,2,3. + assert_eq!(reg.allocate("App"), (4, "App - 4".to_string())); + } + + #[test] + fn numbering_is_per_base_title() { + let mut reg: Registry = Registry::new(); + assert_eq!(reg.allocate("App"), (1, "App".to_string())); + assert_eq!(reg.allocate("Inspector"), (1, "Inspector".to_string())); + assert_eq!(reg.allocate("App"), (2, "App - 2".to_string())); + } + + #[test] + fn remove_frees_the_number_for_reuse() { + let mut reg: Registry = Registry::new(); + let (n, title) = reg.allocate("App"); + reg.insert(10, record(n, &title, SurfaceKind::Window)); + let (n2, _) = reg.allocate("App"); + assert_eq!(n2, 2); + + reg.remove(&10); + // #1 is free again. + assert_eq!(reg.allocate("App"), (1, "App".to_string())); + } + + #[test] + fn counts_distinguish_windows_from_overlays() { + let mut reg: Registry = Registry::new(); + reg.insert(1, record(1, "App", SurfaceKind::Window)); + reg.insert(2, record(2, "App - 2", SurfaceKind::Window)); + reg.insert(3, record(0, "Overlay", SurfaceKind::Overlay)); + assert_eq!(reg.window_count(), 2); + assert_eq!(reg.overlay_count(), 1); + } + + #[test] + fn version_bumps_on_insert_and_remove_only() { + let mut reg: Registry = Registry::new(); + assert_eq!(reg.version(), 0); + // allocate/release alone do not bump (no registered change yet). + let _ = reg.allocate("App"); + assert_eq!(reg.version(), 0); + + reg.insert(1, record(1, "App", SurfaceKind::Window)); + assert_eq!(reg.version(), 1); + reg.remove(&1); + assert_eq!(reg.version(), 2); + reg.remove(&1); // no-op + assert_eq!(reg.version(), 2); + } + + #[test] + fn closed_handles_returns_registered_surfaces_absent_from_live_set() { + let mut reg: Registry = Registry::new(); + reg.insert(1, record(1, "App", SurfaceKind::Window)); + reg.insert(2, record(2, "App - 2", SurfaceKind::Window)); + reg.insert(3, record(0, "Overlay", SurfaceKind::Overlay)); + + // Windows 2 and 3 are gone; 1 is still live. + let live = [1u64]; + let mut closed = reg.closed_handles(|h| live.contains(h)); + closed.sort(); + assert_eq!(closed, vec![2, 3]); + + // All live: nothing to deregister. + let all = [1u64, 2, 3]; + assert!(reg.closed_handles(|h| all.contains(h)).is_empty()); + + // None live: everything is closed. + let mut all_closed = reg.closed_handles(|_| false); + all_closed.sort(); + assert_eq!(all_closed, vec![1, 2, 3]); + } + + #[test] + fn singleton_phase_defaults_to_closed_and_clears() { + let mut reg: Registry = Registry::new(); + assert_eq!(reg.singleton_phase(SETTINGS), SingletonPhase::Closed); + + reg.set_singleton(SETTINGS, SingletonPhase::Opening); + assert_eq!(reg.singleton_phase(SETTINGS), SingletonPhase::Opening); + + reg.set_singleton(SETTINGS, SingletonPhase::Open(42)); + assert_eq!(reg.singleton_phase(SETTINGS), SingletonPhase::Open(42)); + + reg.set_singleton(SETTINGS, SingletonPhase::Closed); + assert_eq!(reg.singleton_phase(SETTINGS), SingletonPhase::Closed); + // Keys are independent. + assert_eq!(reg.singleton_phase(MAIN), SingletonPhase::Closed); + } + + #[test] + fn plan_singleton_closed_creates() { + assert_eq!( + plan_singleton::(SingletonPhase::Closed, false), + SingletonAction::Create + ); + } + + #[test] + fn plan_singleton_opening_is_in_flight() { + assert_eq!( + plan_singleton::(SingletonPhase::Opening, true), + SingletonAction::InFlight + ); + } + + #[test] + fn plan_singleton_open_reuses_when_alive_else_recreates() { + assert_eq!( + plan_singleton(SingletonPhase::Open(7u64), true), + SingletonAction::Reuse(7) + ); + assert_eq!( + plan_singleton(SingletonPhase::Open(7u64), false), + SingletonAction::Create + ); + } +} diff --git a/crates/app/src/windows/spec.rs b/crates/app/src/windows/spec.rs new file mode 100644 index 00000000..041290e5 --- /dev/null +++ b/crates/app/src/windows/spec.rs @@ -0,0 +1,348 @@ +//! Window/overlay open specifications (plan §3 "Windows" builder bullet). +//! +//! [`WindowSpec`] is the builder handed to [`super::WindowManager::open`]: a +//! stable [`WindowKey`], a title, an initial size policy, a [`RootPolicy`], blur +//! /transparency options that delegate to `WindowShell`/`TitleBar`, a pre-show +//! `WindowOptions` customization hook, and a post-open hook for native tweaks +//! (e.g. agent-term's objc2 titlebar). [`OverlaySpec`] is the analogous, much +//! smaller builder for capability-gated overlay surfaces. + +use gpui::{ + App, OverlaySurfaceOptions, Pixels, Size, Window, WindowBackgroundAppearance, WindowOptions, + size, +}; +use gpui_component::WindowShell; + +use super::key::WindowKey; + +/// How the manager treats the caller's content view as a window root. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum RootPolicy { + /// Auto-wrap the content view in `gpui_component::Root` (default). The window + /// handle is therefore typed `WindowHandle`; the content entity is + /// returned separately. Realized by [`super::WindowManager::open`]. + #[default] + ComponentRoot, + /// Use the content view directly as the window root — no `Root` wrapper. + /// Realized by [`super::WindowManager::open_raw`]. + Raw, +} + +/// Initial window size + placement. +#[derive(Debug, Clone, Copy)] +pub enum WindowSize { + /// Centered on the active display at `fraction` of the display size + /// (the story-gallery pattern; default `0.85`). + DisplayFraction(f32), + /// Centered at an explicit logical size. + Fixed(Size), +} + +impl Default for WindowSize { + fn default() -> Self { + WindowSize::DisplayFraction(0.85) + } +} + +/// A window-open specification. Single-use: [`super::WindowManager::open`] +/// consumes it (the hooks are `FnOnce`). +pub struct WindowSpec { + pub(super) key: WindowKey, + pub(super) title: Option, + pub(super) size: WindowSize, + pub(super) root_policy: RootPolicy, + pub(super) background: WindowBackgroundAppearance, + pub(super) pre_show: Option>, + pub(super) post_open: Option>, +} + +impl WindowSpec { + /// Start a spec for the given stable key. + pub fn new(key: impl Into) -> Self { + Self { + key: key.into(), + title: None, + size: WindowSize::default(), + root_policy: RootPolicy::default(), + background: WindowBackgroundAppearance::Opaque, + pre_show: None, + post_open: None, + } + } + + /// Set the base window title. Numbering (`" - N"`) is applied by the manager; + /// pass the un-numbered base here. Defaults to the app display name. + pub fn title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + /// Set the initial size policy (default [`WindowSize::DisplayFraction`]`(0.85)`). + pub fn size(mut self, size: WindowSize) -> Self { + self.size = size; + self + } + + /// Center the window at `fraction` of the active display. + pub fn display_fraction(mut self, fraction: f32) -> Self { + self.size = WindowSize::DisplayFraction(fraction); + self + } + + /// Center the window at an explicit logical size. + pub fn fixed_size(mut self, width: impl Into, height: impl Into) -> Self { + self.size = WindowSize::Fixed(size(width.into(), height.into())); + self + } + + /// Select the root policy. Prefer [`WindowSpec::raw`] for the non-default. + pub fn root_policy(mut self, policy: RootPolicy) -> Self { + self.root_policy = policy; + self + } + + /// Use the content view as the window root directly (no `Root` wrapper). + /// The window must then be opened with [`super::WindowManager::open_raw`]. + pub fn raw(mut self) -> Self { + self.root_policy = RootPolicy::Raw; + self + } + + /// Set the window background appearance directly. + pub fn background(mut self, appearance: WindowBackgroundAppearance) -> Self { + self.background = appearance; + self + } + + /// Request an OS-blurred background clipped to `corner_radius` logical px + /// (`px(0.)` for a full rectangular blur). + pub fn blurred(mut self, corner_radius: impl Into) -> Self { + self.background = WindowBackgroundAppearance::Blurred { + corner_radius: corner_radius.into(), + }; + self + } + + /// Request a plain-transparent background. + pub fn transparent(mut self) -> Self { + self.background = WindowBackgroundAppearance::Transparent; + self + } + + /// Customize the resolved [`WindowOptions`] just before the window is shown + /// (after the manager has applied title/size/background defaults). + pub fn customize_options(mut self, f: impl FnOnce(&mut WindowOptions) + 'static) -> Self { + self.pre_show = Some(Box::new(f)); + self + } + + /// Run a hook against the raw `Window` as it opens, before the root view is + /// built — the sanctioned seam for native tweaks (macOS titlebar, etc.). + pub fn on_open(mut self, f: impl FnOnce(&mut Window, &mut App) + 'static) -> Self { + self.post_open = Some(Box::new(f)); + self + } + + /// The stable key. + pub fn key(&self) -> WindowKey { + self.key + } + + /// The declared root policy. + pub fn declared_root_policy(&self) -> RootPolicy { + self.root_policy + } +} + +/// Build base `WindowOptions` from `WindowShell`/`TitleBar` defaults, then apply +/// `title` and `background`. Bounds are applied separately by the manager (they +/// need `&App` for display centering). +pub(super) fn base_window_options( + title: &str, + background: WindowBackgroundAppearance, +) -> WindowOptions { + let mut options = WindowShell::window_options(); + if let Some(titlebar) = options.titlebar.as_mut() { + titlebar.title = Some(title.to_string().into()); + } + options.window_background = background; + options +} + +/// An overlay-surface specification (capability-gated, not `Root`-wrapped, not +/// numbered). +pub struct OverlaySpec { + pub(super) key: WindowKey, + pub(super) size: Size, + pub(super) background: WindowBackgroundAppearance, + pub(super) focus: bool, + pub(super) customize: Option>, +} + +impl OverlaySpec { + /// Start an overlay spec for the given stable key and logical size. + pub fn new( + key: impl Into, + width: impl Into, + height: impl Into, + ) -> Self { + Self { + key: key.into(), + size: size(width.into(), height.into()), + background: WindowBackgroundAppearance::Transparent, + focus: false, + customize: None, + } + } + + /// Set the overlay background appearance (default transparent). + pub fn background(mut self, appearance: WindowBackgroundAppearance) -> Self { + self.background = appearance; + self + } + + /// Request an OS-blurred background clipped to `corner_radius` logical px. + pub fn blurred(mut self, corner_radius: impl Into) -> Self { + self.background = WindowBackgroundAppearance::Blurred { + corner_radius: corner_radius.into(), + }; + self + } + + /// Whether the overlay should take focus when shown (default `false`). + pub fn focus(mut self, focus: bool) -> Self { + self.focus = focus; + self + } + + /// Customize the resolved [`OverlaySurfaceOptions`] just before creation. + pub fn customize_options( + mut self, + f: impl FnOnce(&mut OverlaySurfaceOptions) + 'static, + ) -> Self { + self.customize = Some(Box::new(f)); + self + } + + /// The stable key. + pub fn key(&self) -> WindowKey { + self.key + } +} + +/// Scale a logical size by `fraction`, guarding against non-finite/degenerate +/// fractions so a bad caller value can never produce a zero/NaN window. +pub(super) fn scale_size(base: Size, fraction: f32) -> Size { + let f = if fraction.is_finite() && fraction > 0.0 { + fraction + } else { + 0.85 + }; + size(base.width * f, base.height * f) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::px; + + #[test] + fn test_window_spec_builder() { + // Defaults. + let spec = WindowSpec::new(WindowKey::new("main")); + assert_eq!(spec.key(), WindowKey::new("main")); + assert_eq!(spec.declared_root_policy(), RootPolicy::ComponentRoot); + assert!(spec.title.is_none()); + assert!(matches!( + spec.size, + WindowSize::DisplayFraction(f) if (f - 0.85).abs() < f32::EPSILON + )); + assert!(matches!( + spec.background, + WindowBackgroundAppearance::Opaque + )); + assert!(spec.pre_show.is_none()); + assert!(spec.post_open.is_none()); + + // Mutators + hooks. + let spec = WindowSpec::new(WindowKey::new("main")) + .title("MyApp") + .fixed_size(px(640.), px(480.)) + .raw() + .blurred(px(8.)) + .customize_options(|_| {}) + .on_open(|_, _| {}); + assert_eq!(spec.title.as_deref(), Some("MyApp")); + assert_eq!(spec.declared_root_policy(), RootPolicy::Raw); + assert!(matches!( + spec.size, + WindowSize::Fixed(s) if s.width == px(640.) && s.height == px(480.) + )); + assert!(matches!( + spec.background, + WindowBackgroundAppearance::Blurred { .. } + )); + assert!(spec.pre_show.is_some()); + assert!(spec.post_open.is_some()); + + // Remaining size/background variants. + let spec = WindowSpec::new(WindowKey::new("w")) + .display_fraction(0.5) + .transparent(); + assert!(matches!( + spec.size, + WindowSize::DisplayFraction(f) if (f - 0.5).abs() < f32::EPSILON + )); + assert!(matches!( + spec.background, + WindowBackgroundAppearance::Transparent + )); + } + + #[test] + fn test_overlay_spec_builder() { + // Defaults. + let spec = OverlaySpec::new(WindowKey::new("hud"), px(320.), px(200.)); + assert_eq!(spec.key(), WindowKey::new("hud")); + assert_eq!(spec.size.width, px(320.)); + assert_eq!(spec.size.height, px(200.)); + assert!(matches!( + spec.background, + WindowBackgroundAppearance::Transparent + )); + assert!(!spec.focus); + assert!(spec.customize.is_none()); + + // Mutators + hook. + let spec = OverlaySpec::new(WindowKey::new("hud"), px(10.), px(10.)) + .background(WindowBackgroundAppearance::Opaque) + .focus(true) + .customize_options(|_| {}); + assert!(matches!( + spec.background, + WindowBackgroundAppearance::Opaque + )); + assert!(spec.focus); + assert!(spec.customize.is_some()); + + let spec = OverlaySpec::new(WindowKey::new("hud"), px(10.), px(10.)).blurred(px(4.)); + assert!(matches!( + spec.background, + WindowBackgroundAppearance::Blurred { .. } + )); + } + + #[test] + fn scale_size_guards_degenerate_fractions() { + let base = size(px(100.), px(200.)); + let scaled = scale_size(base, 0.5); + assert_eq!(scaled.width, px(50.)); + assert_eq!(scaled.height, px(100.)); + // Non-finite / non-positive fractions fall back to 0.85. + for bad in [f32::NAN, f32::INFINITY, 0.0, -1.0] { + let scaled = scale_size(base, bad); + assert_eq!(scaled.width, px(85.)); + assert_eq!(scaled.height, px(170.)); + } + } +} diff --git a/crates/app/tests/headless.rs b/crates/app/tests/headless.rs new file mode 100644 index 00000000..3336d45b --- /dev/null +++ b/crates/app/tests/headless.rs @@ -0,0 +1,90 @@ +//! Headless bootstrap test: drive the shell through the injected headless runner +//! on the process main thread and assert the lifecycle fires end-to-end. +//! +//! Uses `harness = false` (see this crate's `Cargo.toml`): GPUI panics unless +//! its `App` is constructed on the main thread, which the default test harness +//! (worker threads) cannot provide. +//! +//! FORK REALITY: the headless platform's post-`quit` behavior is not uniform +//! across platforms — macOS terminates the process via `NSApp terminate`, while +//! on Linux/Windows the run loop neither terminates nor returns to `main`. So all +//! verification happens *inside* the launch closure, and the test terminates with +//! an explicit `std::process::exit(0)` at the success point rather than depending +//! on those loop-return semantics. A watchdog thread bounds the run so a boot that +//! never reaches `on_launch` fails as a non-zero exit instead of hanging CI. +//! Shutdown ordering is covered by the pure unit tests. + +use std::time::Duration; + +use gpui_component_app::gpui::App; +use gpui_component_app::prelude::*; +use gpui_component_manifest::schema::IdentityRef; + +fn test_identity() -> IdentityRef { + IdentityRef { + app_id: "com.example.appshelltest", + display_name: "App Shell Test", + data_namespace: "appshelltest", + binary_name: None, + org: None, + publisher: None, + url_schemes: &[], + categories: &[], + macos: None, + linux: None, + windows: None, + legacy_ids: &[], + min_os: None, + version: "0.0.0", + cfbundle_short_version: "0.0.0", + msix_version: "0.0.0.0", + } +} + +fn main() { + // Watchdog: if the shell never reaches `on_launch` (and thus never quits), + // fail loudly instead of hanging. + std::thread::spawn(|| { + std::thread::sleep(Duration::from_secs(30)); + eprintln!("headless test watchdog fired: shell did not boot/quit in time"); + std::process::exit(1); + }); + + let result = AppShell::builder(test_identity()) + .runner(PlatformRunner::headless()) + // Tray-first shape: no window, passive activation, explicit exit. + .initial_activation(InitialActivation::Passive) + .exit_policy(ExitPolicy::Explicit) + .on_launch(|cx: &mut App| { + // The shell global is installed and AppInfo is reachable via the + // extension trait with a raw &mut App. + let info = cx.app_info(); + assert_eq!(info.app_id(), "com.example.appshelltest"); + assert_eq!(info.version(), "0.0.0"); + assert_eq!(info.paths().namespace(), "appshelltest"); + assert!(!info.capabilities().credential_store.is_supported()); + + // A liveness lease can be taken and released. + let hold = cx.shell().hold("test"); + assert_eq!(hold.reason(), "test"); + drop(hold); + + println!("headless shell lifecycle: ok"); + + // Every lifecycle assertion has passed — success is fully known here. + // Terminate deterministically on every platform instead of driving a + // quit and relying on loop-return semantics: `request_quit()` ends the + // process only on macOS (via `NSApp terminate`); on Linux/Windows the + // headless run loop neither terminates the process nor returns to + // `main` after `cx.quit()`, so a quit-driven exit hangs until the + // watchdog fires. Quit/teardown ordering is covered by the pure unit + // tests; this test only asserts the boot lifecycle reaches `on_launch` + // with a working shell global. + std::process::exit(0); + }) + .run(); + + // `on_launch` exits the process on success, so `run` only returns here if the + // shell failed to boot far enough to deliver `Started`. + panic!("headless shell did not reach on_launch: run returned {result:?}"); +} diff --git a/crates/story/examples/tiles.rs b/crates/story/examples/tiles.rs index 865a1591..34681664 100644 --- a/crates/story/examples/tiles.rs +++ b/crates/story/examples/tiles.rs @@ -442,6 +442,7 @@ fn main() { cx.set_menus(vec![Menu { name: "GPUI App".into(), items: vec![MenuItem::action("Quit", Quit)], + disabled: false, }]); cx.activate(true); diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md new file mode 100644 index 00000000..34e8fece --- /dev/null +++ b/docs/COMPATIBILITY.md @@ -0,0 +1,30 @@ +# Compatibility Matrix + +Single source of truth for which gpui fork revision each gpui-component release is +built and tested against. Maintained by the `/update-gpui` skill — every gpui bump +updates this table, the workspace version, and the release tag together. + +Consumers (agent-term, ansible, Andromeda, and future apps) should: + +- pin one tag of this workspace's crates (`gpui-component`, `gpui-component-app`, + `gpui-component-storage`, `gpui-component-manifest` release together at the + workspace version); +- declare gpui/gpui_platform (where needed directly) at the **same rev** listed here, + centralized in `[workspace.dependencies]`; +- run a CI lint over `cargo metadata` rejecting more than one resolved + source/rev for `gpui`, `gpui_platform`, or `gpui-component` + (see docs/learned/app-platform-plan.md, D6). + +| gpui-component version (tag) | gpui fork rev | Date | Notes | +|---|---|---|---| +| 0.5.1 (`v0.5.1`) | `4332ea7deae4838c12bad6ea64292ca22a33cf98` | 2026-07-16 | First tracked pairing; app-platform crates (storage, manifest) introduced. | + +## Release discipline + +1. gpui fork commits that gpui-component adopts get an annotated tag in the fork repo + (`gpui-vYYYY.MM.DD-`). +2. This workspace shares one `[workspace.package] version`; bumping it (via + `/update-gpui` or a release) creates the matching annotated tag `v` here. +3. A gpui rev bump or breaking API change ⇒ minor bump while pre-1.0. +4. Release-candidate CI builds the three app repos against the proposed rev before the + tag is blessed (plan §6 gate 8). diff --git a/docs/learned/app-platform-plan.md b/docs/learned/app-platform-plan.md new file mode 100644 index 00000000..e21110a5 --- /dev/null +++ b/docs/learned/app-platform-plan.md @@ -0,0 +1,387 @@ +# App Platform Plan — making gpui-component a viable app-building platform + +> Status: architecture plan v2, 2026-07-16. v1 was synthesized from a full shell-code +> inventory of agent-term, ansible, and Andromeda + three competing proposals + +> adversarial review. v2 folds in two independent external reviews (codex/gpt-5.6-sol, +> which verified claims against the vendored fork source, and GPT-5.5 Pro via oracle). +> Both approved the direction and independently found the same blocker and contract +> gaps — convergent findings below are high-confidence. +> `read_when`: starting work on `gpui-component-app`, `gpui-component-storage`, +> `gpui-component-manifest`, app identity, or migrating one of the three apps. + +## 1. Problem statement (verified, not aspirational) + +Three real apps build on gpui-component today. Each re-implements the same app shell: + +| | agent-term | ansible | Andromeda | +|---|---|---|---| +| Shape | multi-window, document-ish | tray-first overlay, **no main window** | single window | +| Bootstrap | 180-LOC `run()` | 548-LOC `app.rs` | 26-LOC `run()` | +| Identity | Cargo bundle/deb/rpm/wix metadata + .desktop + About dialog | bundle metadata + Info.plist + entitlements + MSIX/Inno/Flatpak + PACKAGING.md | none (gap) | +| Settings | TOML via `dirs`, dual configs, `~/.agent-term` root | `config.rs` — dirs + atomic write + schema-version envelope, tested (best impl) | none (dead Settings UI) | +| Window/layout persistence | 766-LOC layout crate (debounced JSON, backup rotation); no bounds | none | none | +| Theme | hardcoded Rust palettes (opts out of JSON registry) | bundled-theme sync + watch_dir + settings glue | watch_dir + menu rebuild | +| Extra | window registry, custom updater (ed25519 key **zeroed**), objc2 titlebar | tray + tokio bridge, permission gate, singleton settings window | — | + +Cross-cutting problems: + +1. **Version drift** — the three apps pin *three different revs* of gpui AND of gpui-component. +2. **Identity scattered** — bundle id/name/version live in 5–8 places per app; nothing derives from one declaration. +3. **Same glue ×3** — bootstrap, App/Edit menu wiring, theme-settings glue, asset chaining. +4. **Nobody has window-bounds persistence**; layout persistence solved once (agent-term), settings solved well once (ansible). +5. gpui fork has unused app primitives the library never wraps — but some are **stubs**: + review verified URL-scheme registration is unimplemented on Windows + (`vendor/gpui/crates/gpui_windows/src/platform.rs:842`) and Linux + (`gpui_linux/src/platform.rs:685`), and X11 overlay click-through is a silent no-op + (`x11/window.rs:1619`). "Exists in the fork" ≠ "works on 3 OSes". + +## 2. Decisions + +**D1 — Build the platform layer in the gpui-component workspace, not a separate repo.** +A separate repo adds a fourth independently-pinned link and worsens drift. In-workspace, +the platform inherits the workspace's single gpui rev by construction and releases in +lockstep with gpui-component. + +**D2 — Three new crates (revised from two).** +- `crates/app-manifest` → **`gpui-component-manifest`** — **Phase 1, not Phase 4** + (blocker fix, see D4): no-gpui identity schema, parsing, validation, + target-version derivation, and build.rs codegen helper. The packaging CLI later + reuses this same library. +- `crates/app-storage` → **`gpui-component-storage`** — foundation, no gpui dependency: + path resolution, atomic write, schema-version envelope, debounced store with backup + rotation. Seeded from ansible's `config.rs` and agent-term's `DebouncedStorage` + **plus a written portability/concurrency contract** (§4a) — "copy verbatim" is the + seed, not the spec. +- `crates/app` → **`gpui-component-app`** — the `AppShell` builder over an internal + (sealed) plugin/phase mechanism. Services are **always-compiled modules activated at + runtime by builder calls**; Cargo features are reserved for heavy native deps only + (`tray`, maybe `file-logging`, `theme-watch`). Rationale: features are unioned across + the dep graph and additive — a default-on feature matrix for cheap modules is + cosmetic and leaks transitively. Tray may become its own crate + (`gpui-component-tray`) if GTK/AppIndicator deps hurt build times — that's a + legitimate crate boundary; "tray + tokio" is not. + +**D3 — Naming (locked).** +- **`AppShell`** — the builder/facade. Pairs with `WindowShell` (per-window chrome vs + app lifecycle). Kept thin: builder methods install internal plugins; explicit phase + order is enforced centrally, not by builder-call order. +- **`AppInfo`** — immutable identity/paths/capabilities. `Clone + Send + Sync`. +- **`AppProxy`** — cross-thread dispatch only (bounded command sender that wakes the + main loop). `Clone + Send + Sync`; dispatch returns `AppClosed` after shutdown + begins. **Lives in core** (not the tray feature) — serves tray, hotkeys, watchers, + audio callbacks, updater events alike. +- Main-thread shell state is a **GPUI global** accessed via an extension trait + (`AppShellExt` on `gpui::App`: `cx.app_info()`, `cx.app_proxy()`, `cx.windows()`, + `cx.settings::(key)`) — no second TypeId DI container beside GPUI's own global + store, and no `AppHandle` type that mixes thread affinities. Compile-time + `assert_impl_all!`/`assert_not_impl_any!` for the auto-trait contracts. +- `configure_application(FnOnce(Application) -> Result)` (not + "configure_platform" — GPUI's platform already exists by then). +- `PathLayout::{PlatformDefault, SingleRoot}` (not "Xdg" — wrong term on macOS/Windows). +- `ThemeSource::{Bundled, Custom}` (avoid collision with existing `theme/schema.rs` + `ThemeConfig`). + +**D4 — Identity single-source: `[package.metadata.gpui-app]` + app-local build.rs.** +⚠ v1 had a hard blocker both reviews caught: a dependency's build.rs sees *its own* +`CARGO_MANIFEST_DIR`/`OUT_DIR`, never the consuming app's. Corrected design (the Tauri +`tauri-build` pattern): + +```toml +[build-dependencies] +gpui-component-manifest = { ... } +``` +```rust +// app's own build.rs (2 lines) +fn main() { gpui_component_manifest::build::emit_identity().expect("invalid [package.metadata.gpui-app]"); } +``` +`include_identity!()` then includes codegen from the **app's** `OUT_DIR` (with +`rerun-if-changed`). `version` is never declared in the metadata table — canonical +SemVer is `CARGO_PKG_VERSION`; packaging versions (CFBundle 3-int, MSIX 4-part) are +*derived artifacts* with deterministic per-target derivation + validation, plus an +optional CI build number. Schema separates: stable app ID, stable data namespace +(never derived from mutable display name), display name, binary name, org/publisher, +url schemes/file associations, categories, entitlements/usage strings, legacy ID/path +aliases. Test fixture: an actual *downstream* workspace build (an in-repo example can +mask the manifest-boundary bug). + +**D5 — Extraction over invention** (unchanged). Core v1 = code already running in ≥2 +apps or 1 app + worse hand-rolls elsewhere. Labeled exceptions stay opt-in/thin. + +**D6 — Versioning: one *resolved* gpui, not "one dependency".** +v1's "apps may only depend on gpui-component-app" reverses layering — reusable UI +crates inside an app workspace legitimately depend on `gpui-component`/`gpui` directly. +Corrected invariant: +- app binary/shell crate → `gpui-component-app` (re-exports as ergonomic convenience); +- reusable UI crates → `gpui-component`; direct `gpui` where layering/macros need it + (audit gpui proc-macros for generated `::gpui` paths before relying on re-exports); +- each app repo centralizes versions in `[workspace.dependencies]`; +- **CI lint on `cargo metadata`/`cargo tree -d`**: reject >1 resolved package + ID/source/rev for `gpui`, `gpui_platform`, `gpui-component` — catches transitive + duplicates a source-grep never sees. +- gpui fork gets annotated tags; `/update-gpui` is the only bump point and updates the + shared `[workspace.package] version` (note: workspace currently has none — add it) + + COMPATIBILITY.md. +- **Release-candidate CI builds all three app repos** against the proposed platform + rev — an automated downstream gate, stronger than a matrix doc + policy. + +**D7 — Updater: disable the live hole now, not just freeze.** +agent-term ships a **zeroed ed25519 public key** — verification is non-functional +*today*. Action in agent-term (independent of platform work): disable the updater or +make release builds fail while the placeholder key is present. Then key custody +(keypair, private key in CI secret/1Password), then revisit extraction. + +**D8 — wasm out of scope for v1** (unchanged). Storage goes behind a small backend +trait so a shim is possible later; nothing wasm is built now. + +**D9 — Capability honesty over parity theater.** Core exposes runtime +`PlatformCapabilities { overlay_surface, tray, dock_menu, credentials, url_schemes, +precise_window_positioning, … }` and fallible APIs return typed `Unsupported` — never +silent no-ops (the fork has silent stubs today, §1.5). Capabilities are runtime, not +`cfg(target_os)` — tray depends on the desktop session; overlay differs X11 vs +Wayland. Apps can inspect capabilities before committing to a zero-window shape. +**URL-scheme registration moves out of core** until registration + delivery work +end-to-end on all three OSes. + +## 3. The `AppShell` API (target shape, v2) + +Happy-path `main.rs` stays ~20 lines (the `windows_subsystem` attr must stay in app +code — a library can't set a crate-level attribute; the Phase-4 scaffold templates it): + +```rust +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +use gpui_component_app::prelude::*; + +gpui_component_app::include_identity!(); // codegen from app's build.rs (D4) + +fn main() -> anyhow::Result<()> { + AppShell::builder(APP_IDENTITY) + .settings::(StoreKey::PRIMARY) + .theme(ThemeSource::bundled(THEMES)) + .menus(MenuPlan::standard().with_theme_menu()) + .on_launch(|cx| { // sugar over on_event(Started) + cx.windows().open(WindowSpec::new(WindowKey::new("main")).title("MyApp"), MainView::new)?; + Ok(()) + }) + .run() +} +``` + +Core contracts (each traces to a review finding): + +- **Lifecycle is an event stream, not a launch callback.** `on_launch`/`on_reopen` + sugar remains, but the real model is: + ```rust + #[non_exhaustive] + pub enum AppEvent { + Started(LaunchRequest), // args, cwd, urls/files, launch source + Activated, Reopened, + OpenRequested(OpenRequest), // urls/files — may arrive BEFORE ready + LastWindowClosed, + Suspend, Resume, // reserved seams; may report Unsupported + ShutdownRequested(ShutdownReason), + WillExit, + } + ``` + The shell registers raw platform listeners (`on_open_urls`, `on_reopen`) immediately + after platform construction, **queues events until services are initialized**, then + delivers `Started` + drained queue (Electron/GApplication/Zed pattern). This + reserves single-instance `SecondInstance` delivery and deep links without a breaking + redesign later. +- **Thread affinity is explicit** (D3): `AppInfo`/`AppProxy` are `Send+Sync`; + main-thread state is a GPUI global reached via `AppShellExt`. Callbacks receive + `&mut gpui::App` raw — no context wrapper, ever. +- **Pre-platform preparation is typed state, not just a hook.** ansible's + CoreAudio-before-GPUI constraint is expressed in `main()`: + ```rust + let audio = AudioBootstrap::prepare()?; // no GPUI exists yet + AppShell::builder(APP_IDENTITY).state(audio)… + ``` + `before_platform(f)` stays for stateless global side effects. Contract: main thread, + no GPUI, no windows; failure aborts cleanly before the event loop starts. +- **Liveness is hold/release, not a static quit mode.** + `let hold = cx.shell().hold("tray")` — windows, tray, background services hold + leases; last hold dropped with no eligible windows → shell may exit (GApplication + model). Tray acquires its hold only **after successful creation**; tray failure + releases it and triggers a configured fallback (open window / revert to + window-lifetime / exit with surfaced error). Kills the unkillable-invisible-process + state. Separately configured: initial activation (`InitialActivation::Passive` for + tray-first — **no unconditional `activate(true)`**), macOS dock policy, quit policy. +- **Persistence guarantee is honest**: settings persist continuously within a + configured debounce window; orderly shell-mediated shutdown attempts a bounded + synchronous final flush **and reports failure** (GPUI quit observers get ~100ms — + verified in fork `app.rs`); abrupt termination may lose the debounce window. All + platform-owned quit actions route through one `request_quit()` path. Tests cover + every *normal* quit path (menu quit, programmatic, last-window-close, tray quit, + failed startup after changes, worker shutdown). +- **Windows**: `WindowKey` stable non-localized identity (titles are not persistence + keys); registry is an app-scoped GPUI global (not a process `OnceLock`); singleton + state is `Closed | Opening | Open` (prevents async double-create); + `RootPolicy::{ComponentRoot, Raw}` (overlays aren't the only legal non-Root case); + `open` returns `OpenedWindow { window: WindowHandle, content: Entity }` + (auto-Root-wrap changes the handle type — make that explicit); pre-show option + customization + post-native-window hook (agent-term's objc2 titlebar tweak gets a + sanctioned seam). Zero open windows stays a first-class state. +- **Commands before menus**: a command registry (stable ID, GPUI action, scope, + enabled/checked, localized label, default keybinding, menu placements) is the + canonical vocabulary; native menu, tray menu, dock menu, and future keymap files are + *projections* of it. Theme service contributes theme commands; window manager + contributes Move-to-Window; input contributes Edit. Menu rebuild reacts to theme + registry AND window registry AND enabled/checked state. Keybinding precedence + defined now (component < shell < app < user overrides < explicit disable) even + though the user keymap file ships later. +- **Process-global policies are explicit, never silent defaults**: + - logging: library defaults to `LoggingPolicy::External` (a lib must not grab the + process-global logger — conflicts with tests/tracing/agent-term's sink); helpers + provide log-dir + rotation; the *scaffold* opts new apps into file logging. + - `EnvironmentPolicy::{Inherit (default), LoginShell, Custom}` — fix_path_env is + policy, not default (only 1 of 3 apps needs it). + - locale: component lib initializes only its own rust-i18n strings; app localization + is a provider hook, not an imposed framework. +- **Testability**: `AppShell::builder(...).runner(PlatformRunner::native())` with a + headless runner (`gpui_platform::headless()`) for bootstrap-order/lifecycle tests. + Startup-failure policy is explicit per service (required vs degradable; tray → + fallback, theme watcher → static themes, file log → warn, `on_launch` error → + defined behavior). Public API errors are a stable `AppShellError`, not `anyhow` + through library callbacks. +- **Secrets rule**: settings stores + rotated backups are never for secrets (rotation + multiplies copies). Credential storage goes through the fork's keychain primitives + behind a thin abstraction (later, capability-gated). +- **Assets**: explicit `.assets(...)` builder input; namespaced mounts with defined + collision precedence — no silent last-writer-wins. + +### 4a. Settings & storage contracts (was underspecified in v1) + +Settings API (per store; **named stores from day one** — agent-term already has +settings + MCP config + layout, a singleton type-keyed service gets outgrown +immediately): + +```rust +SettingsPlugin::::new(StoreKey::PRIMARY) + .current_version(3) + .migrate(1, 2, migrate_1_to_2) // registered chain, not a raw-TOML free-for-all + .migrate(2, 3, migrate_2_to_3) + .validate(validate) // can reject before becoming current + .future_version_policy(FutureVersionPolicy::RefuseToWrite) +// access: cx.settings::(StoreKey::PRIMARY) / cx.update_settings(key, |s, cx| …)? +// change observation + save-error surfacing to settings UI defined. +``` + +Four load outcomes, distinguished (a **newer** schema is NOT corruption): older → +migrate; current → deserialize+validate; **newer → preserve untouched, return +`UnsupportedFutureVersion`** (a downgrade must never archive-and-overwrite newer +data); malformed → archive `.bak.vN` per explicit recovery policy. + +Theme/shell state does **not** live in app settings types (v1's sketch was +unimplementable — the trait exposed no fields): a small platform-owned +`ShellPreferences` store holds theme mode/selected theme/locale; apps may opt into +embedding via an adapter instead. + +Storage engine spec (beyond the seeded code): unique temp names in target dir; +Windows replace semantics; fsync expectations stated (atomic vs crash-durable); +per-store writer lock or generation/CAS with conflict error (two instances exist +until single-instance ships); preserve last committed file until replacement +succeeds; rotate backups only after identifying a valid committed generation; worker +I/O errors propagate to the app (Drop is best-effort only — explicit +`flush()/shutdown()` return results); **process-level** concurrency tests, not +two-objects-one-process tests; stable storage namespace distinct from display name; +legacy path migration aliases (agent-term's `~/.agent-term`). + +## 4. Service tiers + +| Service | Tier | Source | +|---|---|---| +| Paths (`PathLayout`), atomic write, envelope, `DebouncedStore` + backups | Core (storage crate) | ansible config.rs + agent-term storage, seeded + §4a contract | +| Identity codegen + dirs + About data | Core (manifest crate + shell) | D4 | +| Typed settings (named stores, migration chain, future-version policy) | Core | §4a | +| `ShellPreferences` (theme mode, locale) | Core | replaces v1's impossible theme↔settings coupling | +| Lifecycle events + queue-until-ready | Core | reserved seams for single-instance/deep links | +| `AppProxy` off-thread→main bridge | **Core** (moved from tray) | serves tray/hotkeys/watchers/audio | +| Liveness holds + activation policy | Core | replaces static QuitMode-only model | +| Window manager (keys, registry-as-global, singleton state machine, overlay, RootPolicy) | Core | agent-term + ansible, promoted + hardened | +| Command registry → menus/tray/dock projections | Core | supersedes v1's menu-centric MenuPlan coupling | +| Theme glue (mode persistence, watch_dir, bundled sync) | Core, opt-out `ThemeSource::custom` | ansible + Andromeda | +| Asset chaining (namespaced) | Core | all 3 apps | +| Logging helpers | Core helpers, **policy default External** | review: process-global logger is app's call | +| Capabilities matrix + typed `Unsupported` | Core | D9 | +| Tray + dock policy | Opt-in feature/crate; uses core `AppProxy`; **no bundled tokio** (app supplies handle) | ansible | +| Window bounds persistence | Opt-in, net-new; persists logical size + scale + monitor id + maximized/fullscreen + position-unavailable (Wayland) | nobody has it | +| Permission gate | App code + `.state()` preflight + gate-window-in-launch pattern | ansible | +| Updater | **Disabled in agent-term now** (D7); extraction later | — | +| Layout/session schema | App code on `DebouncedStore` | agent-term | +| URL schemes / single-instance / CLI / crash reporting | **Not built**; lifecycle events + capability slots reserved | fork stubs incomplete (§1.5) | + +## 5. Roadmap + +**Phase 0 — foundations** +1. CI matrix (macOS/Windows/Linux): build + **launch** native smoke apps (compile-green + proves little for platform-divergent code); feature-matrix builds (none/default/tray/all). +2. `gpui-component-storage` with the §4a contract + process-level concurrency tests. +3. `gpui-component-manifest` + downstream-workspace test fixture (D4 blocker fix). +4. Tag discipline; add missing `[workspace.package] version`; `/update-gpui` owns bumps. +5. agent-term: disable/release-block the zeroed-key updater (D7). + +**Phase 1 — `gpui-component-app` MVP** +Identity include, `AppShell` phases/plugin core (sealed trait until all three +migrations exercised it), lifecycle events + queueing, `AppInfo`/`AppProxy`/globals +split (+ auto-trait compile assertions), window manager, settings plugin, +`ShellPreferences`, command registry + standard menus, theme plugin, capabilities, +headless runner. Two in-repo conformance examples: `examples/app_shell` (single-window +happy path) and **`examples/app_shell_tray`** (tray-first, zero windows, passive +activation, tray-failure fallback — ansible's shape proves the platform; Andromeda's +only proves the happy path). Phase-1 `doctor` command: parse normalized manifest and +*verify* the apps' existing Info.plist/.desktop/wix/MSIX/Inno/Flatpak/bundle metadata +against it (identity converges before the generator exists, instead of adding an +eighth copy). + +**Phase 2 — migrate the apps** (each step a shippable PR deleting real code) +1. **Andromeda** — cheapest end-to-end proof of plumbing (~150 LOC deleted, gains + identity + working settings). +2. **ansible immediately after** (not later) — the architectural validation: storage + re-parenting (~230), singleton controller (~200), theme (~180), tray+proxy (~130), + builder + typed preflight + holds (~400). Gate: the tray conformance example's + behaviors all hold in the real app. +3. **agent-term** — layout crate onto `DebouncedStore` (~300), window registry (~122), + settings incl. second named store for MCP config (~110), command/menu blocks (~70), + builder (~140). Keeps palettes (`ThemeSource::custom`), objc2 titlebar (post-native + hook), frozen updater. + +**Phase 3 — opt-ins with demand**: bounds persistence, user keymap file (over the +already-stable command IDs), first-run/onboarding hook, credential-store abstraction. + +**Phase 4 — packaging + scaffold** +`cargo gpui-app` generates *inputs* for existing bundlers from the same manifest +library; acceptance = ansible's PACKAGING.md verification commands + deterministic +`generate --check`. `create-gpui-app` scaffold (owns `windows_subsystem`, build.rs, +CI workflow, default logging policy). + +## 6. Stabilization gates (API is not 1.0 until all hold) + +1. Downstream fixture proves identity codegen reads the *app's* package (incl. + workspace-inherited version). +2. Early URLs/files are buffered and delivered post-init. +3. `AppInfo`/`AppProxy`/main-thread state have distinct, compile-tested auto-trait + contracts. +4. Storage spec covers writer conflicts, newer-schema behavior, error propagation, + orderly-vs-abrupt termination. +5. Tray-first conformance app starts with zero windows, no unconditional activation, + tested tray-failure fallback. +6. Shell runs through an injected/headless runner. +7. CI launches native smoke apps on 3 OSes and checks min/max feature sets. +8. Release-candidate CI builds all three real app repos against one resolved gpui stack. +9. Stable command IDs exist before menus/keybindings are generalized. +10. agent-term's zero-key updater is disabled or release-blocked. + +## 7. Known risks + +- **Fork stubs**: URL registration (Win/Linux) and X11 overlay click-through are + incomplete in the fork — capability-gate, don't wrap (D9). Verify overlay surface + + `QuitMode`-with-zero-windows on Win/Linux before ansible migrates. +- **Linux tray (Wayland/GNOME)** unreliable; GTK-loop coexistence with GPUI needs a + *runtime* integration test, not a compile check. +- **App→platform tag drift** reduced, not eliminated — mitigated by the downstream RC + CI gate (D6), which is automation, not policy. +- **Scope creep of AppShell into a monolith** — both reviews' top long-term worry. + Guardrails: sealed plugin trait, explicit phases, commands-not-menus, policies over + silent defaults, and the D5 extraction rule. +- **Storage generality** — core owns bytes-on-disk + debounce + backups; domain + schemas (LayoutSnapshot) stay app-side. diff --git a/examples/app_assets/Cargo.toml b/examples/app_assets/Cargo.toml index 8e344bb2..36cf1181 100644 --- a/examples/app_assets/Cargo.toml +++ b/examples/app_assets/Cargo.toml @@ -10,6 +10,7 @@ anyhow.workspace = true gpui.workspace = true gpui_platform.workspace = true gpui-component = { workspace = true } +gpui-component-assets = { workspace = true } rust-embed = { version = "8", features = ["interpolate-folder-path"] } [lints] diff --git a/examples/app_shell/Cargo.toml b/examples/app_shell/Cargo.toml new file mode 100644 index 00000000..923d876b --- /dev/null +++ b/examples/app_shell/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "app_shell" +description = "Conformance example for the AppShell platform." +version.workspace = true +publish = false +edition.workspace = true + +[package.metadata.gpui-app] +app_id = "com.gpui-component.app-shell-example" +display_name = "App Shell Example" +categories = ["Development"] + +[dependencies] +gpui-component-app.workspace = true +# Referenced by the `include_identity!()`-generated `APP_IDENTITY` (its type is +# `::gpui_component_manifest::schema::IdentityRef`), so it is a runtime dep even +# though no source line names it — hence the cargo-machete ignore below. +gpui-component-manifest.workspace = true +serde.workspace = true + +[build-dependencies] +gpui-component-manifest.workspace = true + +[package.metadata.cargo-machete] +ignored = ["gpui-component-manifest"] + +[lints] +workspace = true diff --git a/examples/app_shell/build.rs b/examples/app_shell/build.rs new file mode 100644 index 00000000..0e513b81 --- /dev/null +++ b/examples/app_shell/build.rs @@ -0,0 +1,3 @@ +fn main() { + gpui_component_manifest::build::emit_identity().expect("invalid [package.metadata.gpui-app]"); +} diff --git a/examples/app_shell/src/main.rs b/examples/app_shell/src/main.rs new file mode 100644 index 00000000..66656df1 --- /dev/null +++ b/examples/app_shell/src/main.rs @@ -0,0 +1,149 @@ +//! `app_shell` — single-window AppShell conformance example. +//! +//! Exercises the whole downstream chain in-repo: a `[package.metadata.gpui-app]` +//! table + `build.rs` produce the compiled-in `APP_IDENTITY`, and the shell +//! builder wires the wave-2 services (typed settings, theme, native menus) around +//! a single `gpui-component` window. +//! +//! Run it: `cargo run -p app_shell`. The `--smoke` flag makes it launch, then +//! request a clean quit after a few seconds with exit code 0 — the shape the CI +//! `native-launch-smoke` job depends on. +//! +//! Smoke contract: **exit 0 iff the window opened and the shell quit cleanly.** +//! A failed native launch must exit non-zero, so a broken window path cannot pass +//! CI. This matters because a `Started` handler error is only *logged* by the +//! shell (`deliver_event`), not propagated — returning `Err` from `on_launch` +//! would otherwise let startup idle-exit 0 with no window ever shown. + +#![cfg_attr( + all(not(debug_assertions), target_os = "windows"), + windows_subsystem = "windows" +)] + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread; +use std::time::Duration; + +use gpui_component_app::gpui::*; +use gpui_component_app::prelude::*; +use gpui_component_app::ui::{ActiveTheme as _, v_flex}; +use serde::{Deserialize, Serialize}; + +gpui_component_app::include_identity!(); + +/// How long a `--smoke` run stays up before requesting a clean quit. +const SMOKE_LIFETIME: Duration = Duration::from_secs(3); +/// Hard upper bound: if the app never reaches launch, fail loudly instead of +/// hanging CI. Mirrors the watchdog in `crates/app/tests/headless.rs`. +const SMOKE_WATCHDOG: Duration = Duration::from_secs(30); + +/// Set once the main window has actually opened. The `--smoke` quit path asserts +/// it: a run that never opened its window exits non-zero (see the module docs). +static WINDOW_READY: AtomicBool = AtomicBool::new(false); + +/// A tiny persisted settings schema, proving the identity -> storage chain: it is +/// keyed by the app's `data_namespace`, which comes from `APP_IDENTITY`. +#[derive(Serialize, Deserialize, Default)] +struct ExampleSettings { + /// Number of times this app has been launched. + launch_count: u32, +} + +impl AppSettings for ExampleSettings { + const SCHEMA_VERSION: u32 = 1; +} + +/// The window content: a themed placeholder so the theme service is visibly live. +struct MainView; + +impl Render for MainView { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .size_full() + .items_center() + .justify_center() + .bg(cx.theme().background) + .text_color(cx.theme().foreground) + .child("App Shell conformance example") + } +} + +fn main() { + let smoke = std::env::args().any(|arg| arg == "--smoke"); + if smoke { + spawn_watchdog(); + } + + let result = AppShell::builder(APP_IDENTITY) + .settings::(StoreKey::PRIMARY) + .theme(ThemeSource::registry()) + .menus(MenuPlan::standard().with_theme_menu()) + .on_launch(move |cx| { + // Arm the smoke quit/verify thread first: even a startup that returns + // early below still reaches a decision (and fails non-zero) instead + // of idling to the watchdog. + if smoke { + schedule_smoke_quit(cx); + } + + // Touch the settings store so persistence is actually exercised. + // A returned `Err` is only *logged* by the shell and would let + // startup idle-exit 0, so any startup failure fails hard instead. + if let Err(err) = cx.update_settings(StoreKey::PRIMARY, |s: &mut ExampleSettings, _| { + s.launch_count += 1; + }) { + eprintln!("app_shell settings update failed: {err}"); + std::process::exit(1); + } + + match WindowManager::open( + cx, + WindowSpec::new("main").title("App Shell Example"), + |_, cx| cx.new(|_| MainView), + ) { + Ok(_) => WINDOW_READY.store(true, Ordering::SeqCst), + // A failed native launch must not pass the smoke gate as a clean + // exit 0: fail hard here rather than let startup idle-exit. + Err(err) => { + eprintln!("app_shell failed to open window: {err}"); + std::process::exit(1); + } + } + Ok(()) + }) + .run(); + + // FORK REALITY: on macOS the platform terminates the process inside `run()` + // (NSApp terminate -> exit(0)), so this is reached only on platforms whose + // event loop returns (Linux/Windows). Do not rely on post-`run` code there. + if let Err(err) = result { + eprintln!("app_shell exited with error: {err}"); + std::process::exit(1); + } +} + +/// After [`SMOKE_LIFETIME`], either quit cleanly (window opened) or fail the run +/// (window never became ready) — the smoke contract's decision point. +fn schedule_smoke_quit(cx: &mut App) { + let proxy = cx.app_proxy(); + thread::spawn(move || { + thread::sleep(SMOKE_LIFETIME); + if WINDOW_READY.load(Ordering::SeqCst) { + // Quit through the single shutdown path so settings flush + plugin + // shutdown run; a clean quit exits 0. + let _ = proxy.dispatch(|cx| cx.request_quit()); + } else { + eprintln!("app_shell smoke: window never became ready; failing"); + std::process::exit(1); + } + }); +} + +/// Fail the process (non-zero) if a `--smoke` run never boots and quits in time. +fn spawn_watchdog() { + thread::spawn(|| { + thread::sleep(SMOKE_WATCHDOG); + eprintln!("app_shell smoke watchdog fired: shell did not boot/quit in time"); + std::process::exit(1); + }); +} diff --git a/examples/app_shell_tray/Cargo.toml b/examples/app_shell_tray/Cargo.toml new file mode 100644 index 00000000..b6c10be2 --- /dev/null +++ b/examples/app_shell_tray/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "app_shell_tray" +description = "Conformance example for the AppShell platform." +version.workspace = true +publish = false +edition.workspace = true + +[package.metadata.gpui-app] +app_id = "com.gpui-component.app-shell-tray-example" +display_name = "App Shell Tray Example" +categories = ["Development"] + +[dependencies] +gpui-component-app.workspace = true +# Referenced by the `include_identity!()`-generated `APP_IDENTITY` (its type is +# `::gpui_component_manifest::schema::IdentityRef`), so it is a runtime dep even +# though no source line names it — hence the cargo-machete ignore below. +gpui-component-manifest.workspace = true + +[build-dependencies] +gpui-component-manifest.workspace = true + +[package.metadata.cargo-machete] +ignored = ["gpui-component-manifest"] + +[lints] +workspace = true diff --git a/examples/app_shell_tray/build.rs b/examples/app_shell_tray/build.rs new file mode 100644 index 00000000..0e513b81 --- /dev/null +++ b/examples/app_shell_tray/build.rs @@ -0,0 +1,3 @@ +fn main() { + gpui_component_manifest::build::emit_identity().expect("invalid [package.metadata.gpui-app]"); +} diff --git a/examples/app_shell_tray/src/main.rs b/examples/app_shell_tray/src/main.rs new file mode 100644 index 00000000..4491d979 --- /dev/null +++ b/examples/app_shell_tray/src/main.rs @@ -0,0 +1,158 @@ +//! `app_shell_tray` — tray-first AppShell conformance example. +//! +//! A tray/menu-bar app launches with **no window** and must not exit just because +//! zero windows are open. This example simulates that shape without pulling in a +//! real tray crate: +//! +//! - [`InitialActivation::Passive`] — launch without stealing focus, zero windows. +//! - A fake "tray" service takes a [`ShellHold`] liveness lease so the app stays +//! alive at zero windows ([`ExitPolicy::WhenIdle`] would otherwise exit). +//! - A timer stands in for a tray-icon click: it opens a singleton window +//! (`open_singleton`), then releases the tray hold so the window's own lease +//! keeps the app alive. Closing that window then drops to zero holds / zero +//! windows and the shell exits — demonstrating the hold/release exit contract. +//! +//! A REAL tray app would instead keep the tray hold for the whole session (so +//! closing the last window returns to the tray rather than quitting) and open the +//! window from the status-item callback rather than a timer. +//! +//! Run it: `cargo run -p app_shell_tray`. The `--smoke` flag requests a clean +//! quit after a few seconds with exit code 0, for the CI `native-launch-smoke` +//! job. +//! +//! Smoke contract: **exit 0 iff the window opened and the shell quit cleanly.** +//! A failed `open_singleton` fails the run hard (see [`open_main_window`]) rather +//! than dropping the tray hold and letting the shell idle-exit 0 with no window. + +#![cfg_attr( + all(not(debug_assertions), target_os = "windows"), + windows_subsystem = "windows" +)] + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread; +use std::time::Duration; + +use gpui_component_app::gpui::*; +use gpui_component_app::prelude::*; +use gpui_component_app::ui::{ActiveTheme as _, v_flex}; + +gpui_component_app::include_identity!(); + +/// How long the app stays window-less before the fake tray opens its window. +/// Kept below [`SMOKE_LIFETIME`] so a `--smoke` run still exercises the open path. +const TRAY_OPEN_DELAY: Duration = Duration::from_secs(1); +/// How long a `--smoke` run stays up before requesting a clean quit. +const SMOKE_LIFETIME: Duration = Duration::from_secs(3); +/// Hard upper bound: fail loudly instead of hanging CI if the app never quits. +const SMOKE_WATCHDOG: Duration = Duration::from_secs(30); + +/// Set once the tray's window has actually opened. The `--smoke` quit path asserts +/// it: a run that never opened its window exits non-zero (see the module docs). +static WINDOW_READY: AtomicBool = AtomicBool::new(false); + +/// The window the fake tray opens; themed so the theme service is visibly live. +struct TrayWindow; + +impl Render for TrayWindow { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .size_full() + .items_center() + .justify_center() + .bg(cx.theme().background) + .text_color(cx.theme().foreground) + .child("Opened from the (simulated) tray") + } +} + +fn main() { + let smoke = std::env::args().any(|arg| arg == "--smoke"); + if smoke { + spawn_watchdog(); + } + + let result = AppShell::builder(APP_IDENTITY) + .initial_activation(InitialActivation::Passive) + .exit_policy(ExitPolicy::WhenIdle) + .theme(ThemeSource::registry()) + .menus(MenuPlan::standard().with_theme_menu()) + .on_launch(move |cx| { + // Arm the smoke quit/verify thread first: a run that never opens its + // window must fail non-zero, not idle-exit 0. + if smoke { + schedule_smoke_quit(cx); + } + + // The "tray" takes a liveness lease: with zero windows and this hold + // outstanding, `ExitPolicy::WhenIdle` keeps the app alive. + let hold = cx.shell().hold("tray"); + let proxy = cx.app_proxy(); + + // Stand in for a tray-icon click after a short delay. + thread::spawn(move || { + thread::sleep(TRAY_OPEN_DELAY); + let _ = proxy.dispatch(move |cx| open_main_window(cx, hold)); + }); + + Ok(()) + }) + .run(); + + // FORK REALITY: on macOS the platform terminates the process inside `run()`, + // so this is reached only where the event loop returns (Linux/Windows). + if let Err(err) = result { + eprintln!("app_shell_tray exited with error: {err}"); + std::process::exit(1); + } +} + +/// Open the singleton main window, consuming the tray `hold`. +/// +/// On success the window's own liveness lease takes over, so the tray hold is +/// released. On failure the smoke contract requires a hard non-zero exit rather +/// than dropping the hold and letting the shell idle-exit 0 with no window. +fn open_main_window(cx: &mut App, hold: ShellHold) { + match WindowManager::open_singleton( + cx, + WindowSpec::new("main").title("App Shell Tray Example"), + |_, cx| cx.new(|_| TrayWindow), + ) { + Ok(_) => { + WINDOW_READY.store(true, Ordering::SeqCst); + // The window now keeps the app alive; a real tray app keeps this + // hold for the whole session instead. + drop(hold); + } + Err(err) => { + eprintln!("app_shell_tray failed to open window: {err}"); + std::process::exit(1); + } + } +} + +/// After [`SMOKE_LIFETIME`], either quit cleanly (window opened) or fail the run +/// (window never became ready) — the smoke contract's decision point. +fn schedule_smoke_quit(cx: &mut App) { + let proxy = cx.app_proxy(); + thread::spawn(move || { + thread::sleep(SMOKE_LIFETIME); + if WINDOW_READY.load(Ordering::SeqCst) { + // Quit through the single shutdown path so plugin shutdown runs + // regardless of the tray hold; a clean quit exits 0. + let _ = proxy.dispatch(|cx| cx.request_quit()); + } else { + eprintln!("app_shell_tray smoke: window never became ready; failing"); + std::process::exit(1); + } + }); +} + +/// Fail the process (non-zero) if a `--smoke` run never boots and quits in time. +fn spawn_watchdog() { + thread::spawn(|| { + thread::sleep(SMOKE_WATCHDOG); + eprintln!("app_shell_tray smoke watchdog fired: shell did not boot/quit in time"); + std::process::exit(1); + }); +} diff --git a/examples/system_monitor/Cargo.toml b/examples/system_monitor/Cargo.toml index 6b2294da..10d7491f 100644 --- a/examples/system_monitor/Cargo.toml +++ b/examples/system_monitor/Cargo.toml @@ -12,7 +12,6 @@ gpui_platform.workspace = true gpui-component-assets.workspace = true gpui-component.workspace = true sysinfo = "0.37" -smol = "2" battery = "0.7" [target.'cfg(target_os = "macos")'.dependencies] diff --git a/examples/system_monitor/src/main.rs b/examples/system_monitor/src/main.rs index 7ef724d2..46d0a19e 100644 --- a/examples/system_monitor/src/main.rs +++ b/examples/system_monitor/src/main.rs @@ -12,7 +12,6 @@ use gpui_component::{ table::{Column, ColumnSort, Table, TableDelegate, TableState}, v_flex, }; -use smol::Timer; use sysinfo::{Disks, Pid, System}; // Define the Quit action @@ -299,7 +298,7 @@ impl SystemMonitor { // Start the update loop cx.spawn(async move |this, cx| { loop { - Timer::after(INTERVAL).await; + cx.background_executor().timer(INTERVAL).await; let result = this.update(cx, |this, cx| { this.collect_metrics(cx);