diff --git a/.agent/context/cli.md b/.agent/context/cli.md index c0b18c0..2d82431 100644 --- a/.agent/context/cli.md +++ b/.agent/context/cli.md @@ -4,7 +4,7 @@ status: current version: 0.1.0 last_updated: 2026-05-24 last_verified: 2026-05-24 -source_refs: ["crates/aura/src/cli/", "docs/cli.md"] +source_refs: ["crates/aura-cli/src/", "docs/cli.md"] owner: "@rfluid" tags: [context, cli, conventions] --- @@ -22,7 +22,7 @@ under `aura ` that prints a result or mutates state is enough. Read commands should accept `--format text|json` from day one so the feature works in `jq`/status-bar pipelines without scraping. -Concrete examples already in tree (`crates/aura/src/cli/`): +Concrete examples already in tree (`crates/aura-cli/src/`): | Module | Pattern it demonstrates | | ------------ | ----------------------------------------------------------------------- | @@ -47,22 +47,23 @@ Concrete examples already in tree (`crates/aura/src/cli/`): name = "setup-config")]` on a top-level variant that calls the canonical handler. Installer scripts and old muscle memory keep working; help output stays clean. -4. **Profile resolution goes through `cli::resolve::resolve_profile`.** +4. **Profile resolution goes through `crate::resolve::resolve_profile`.** `--profile` flag → `state.active_profile` → first agent in config. Don't reinvent this — `usage`, `quota`, and `plugin run` all use it. -5. **Tray dispatch lives in `main.rs`.** The CLI is the entry point; - `main.rs` only falls through to the tray when `cli.command` is - `None`. Headless subcommands must never spin up GPUI. +5. **Tray dispatch lives in `crates/aura/src/main.rs`.** The CLI is the + entry point; `main` only falls through to `aura_ui::run` when + `cli.command` is `None`. Headless subcommands must never spin up GPUI — + `aura-cli` has no UI deps, so keep it that way. ## Adding a new subcommand -1. Create `crates/aura/src/cli/.rs` with a `Cli` struct +1. Create `crates/aura-cli/src/.rs` with a `Cli` struct (`#[derive(Args)]`) and a `run(self) -> Result<()>` method. 2. Wire it into `cli/mod.rs`: `mod ;` plus a `Command::` variant. 3. If the subcommand reads data: take `--format` and route through - `cli::format::print_json` for the JSON arm. -4. If it shells out to an editor: use `cli::theme::open_in_editor`. + `crate::format::print_json` for the JSON arm. +4. If it shells out to an editor: use `crate::theme::open_in_editor`. 5. Update `docs/cli.md` and the README CLI section. 6. Add tests if the logic is non-trivial. Pure formatters can be tested inline in the module. diff --git a/.agent/context/conventions.md b/.agent/context/conventions.md index db43216..926fb9b 100644 --- a/.agent/context/conventions.md +++ b/.agent/context/conventions.md @@ -4,7 +4,7 @@ status: current version: 0.1.1 last_updated: 2026-05-24 last_verified: 2026-05-24 -source_refs: ["crates/aura/src/cli/"] +source_refs: ["crates/aura-cli/src/"] owner: "@rfluid" tags: [context] --- diff --git a/.agent/skills/add-or-change-config.md b/.agent/skills/add-or-change-config.md index 087d906..b6ddb7b 100644 --- a/.agent/skills/add-or-change-config.md +++ b/.agent/skills/add-or-change-config.md @@ -8,8 +8,8 @@ source_refs: - crates/aura-core/src/config.rs - crates/aura-core/src/config_migrate.rs - crates/aura-core/src/config_schema.rs - - crates/aura/src/runtime.rs - - crates/aura/src/cli/config.rs + - crates/aura-ui/src/runtime.rs + - crates/aura-cli/src/config.rs - docs/configuration.md owner: "@rfluid" tags: [skill, config, cli] @@ -35,9 +35,9 @@ that's by design, not an obstacle. Work *with* it. | Typed struct | `crates/aura-core/src/config.rs` | always (the field itself) | | Field registry | `crates/aura-core/src/config_schema.rs` | always (describe + get/set) | | Migration registry | `crates/aura-core/src/config_migrate.rs` | you move, rename, or remove an existing key | -| Runtime mirror | `crates/aura/src/runtime.rs` | the field must reach the tray loop *and* modal | -| Consumer | `crates/aura/src/app.rs`, `main.rs`, … | the field actually does something | -| CLI handler | `crates/aura/src/cli/config.rs` | almost never — it's registry-driven | +| Runtime mirror | `crates/aura-ui/src/runtime.rs` | the field must reach the tray loop *and* modal | +| Consumer | `crates/aura-ui/src/app.rs`, `main.rs`, … | the field actually does something | +| CLI handler | `crates/aura-cli/src/config.rs` | almost never — it's registry-driven | | Docs | `docs/configuration.md`, `docs/cli.md`, `README.md` | always | The CLI (`describe` / `get` / `set` / `wizard` / `init` / `document`) and the diff --git a/.agent/skills/diagnose-and-publish-issue.md b/.agent/skills/diagnose-and-publish-issue.md index 97acf85..789eae3 100644 --- a/.agent/skills/diagnose-and-publish-issue.md +++ b/.agent/skills/diagnose-and-publish-issue.md @@ -80,10 +80,10 @@ before composing a public issue. | Report area | Sources to check | | --- | --- | | Install/update/uninstall | `README.md` installation/update sections, `install.sh`, `uninstall.sh`, `scripts/install.ps1`, `scripts/uninstall.ps1`, `justfile` | -| Tray icon/click behavior | `README.md` "Making the tray icon always visible" and "Compatibility", `docs/platform-tray-icon.md`, `crates/aura/src/tray.rs`, `crates/aura/src/tray_status.rs`, `crates/aura/src/main.rs` | -| Modal placement/resize/focus | `README.md` "Modal placement on Wayland", `docs/configuration.md` modal/backend sections, `docs/platform-tray-icon.md`, `docs/troubleshooting/modal-stretches-on-resize-kde.md`, `crates/aura/src/app.rs`, `crates/aura/src/placement.rs`, `crates/aura/src/platform.rs` | +| Tray icon/click behavior | `README.md` "Making the tray icon always visible" and "Compatibility", `docs/platform-tray-icon.md`, `crates/aura-ui/src/tray.rs`, `crates/aura-ui/src/tray_status.rs`, `crates/aura-ui/src/lib.rs` | +| Modal placement/resize/focus | `README.md` "Modal placement on Wayland", `docs/configuration.md` modal/backend sections, `docs/platform-tray-icon.md`, `docs/troubleshooting/modal-stretches-on-resize-kde.md`, `crates/aura-ui/src/app.rs`, `crates/aura-ui/src/placement.rs`, `crates/aura-ui/src/platform.rs` | | Config/theme/state | `docs/configuration.md`, `docs/cli.md`, `crates/aura-core/src/config*.rs`, `crates/aura-core/src/state.rs`, `.agent/skills/add-or-change-config.md` | -| Usage/quota/agent data | `README.md`, `docs/cli.md`, `.agent/context/stack.md`, relevant `crates/aura-core` and `crates/aura/src/cli` sources | +| Usage/quota/agent data | `README.md`, `docs/cli.md`, `.agent/context/stack.md`, relevant `crates/aura-core` and `crates/aura-ui/src/cli` sources | | Plugins | `README.md` plugin section, `docs/plugin-system.md`, `docs/plugin-authoring.md`, `plugins/*/README.md`, relevant plugin/core sources | | Issue publishing | `.github/ISSUE_TEMPLATE/bug_report.yml`, `.github/ISSUE_TEMPLATE/config.yml`, `CONTRIBUTING.md` | diff --git a/.agent/workflows/customizable-themes.md b/.agent/workflows/customizable-themes.md index c3e3e87..d5c0247 100644 --- a/.agent/workflows/customizable-themes.md +++ b/.agent/workflows/customizable-themes.md @@ -8,7 +8,7 @@ source_refs: - .design/customization.md - .design/tokens.md - .design/agents.md - - crates/aura/src/app.rs + - crates/aura-ui/src/app.rs - crates/aura-core/src/config.rs owner: "@rfluid" tags: [theming, customization, plan] @@ -22,7 +22,7 @@ source of truth for schema / precedence; this file is the work order. ## Scope Land `~/.config/aura/theme.toml` as an override layer on top of the -hard-coded `COLOR_*` constants in `crates/aura/src/app.rs:19-31`. +hard-coded `COLOR_*` constants in `crates/aura-ui/src/app.rs:19-31`. Defaults preserved; missing file → current look. Per-agent overrides reuse the existing `AgentConfig.color` plumbing (already implemented at `crates/aura-core/src/config.rs:29`), so this phase focuses on global @@ -71,7 +71,7 @@ spaces. ## Step 2 — Wire `Theme` into `AuraView` -`crates/aura/src/app.rs`: +`crates/aura-ui/src/app.rs`: 1. Add `theme: Theme` field to `AuraView` (after `config_path`), default-loaded in `new`. diff --git a/.design/agents.md b/.design/agents.md index 6cfa969..13ea685 100644 --- a/.design/agents.md +++ b/.design/agents.md @@ -6,7 +6,7 @@ profile is the active one — active pills, progress-bar fills, the tab underlin — though that wiring is not yet built (it is the next step after the luminance-fallback below lands). -Source: `crates/aura/src/app.rs:25-27` and `app.rs:848-862`. +Source: `crates/aura-ui/src/app.rs:25-27` and `app.rs:848-862`. ## Canonical brand colors @@ -45,7 +45,7 @@ saturated-but-dim brand greens. ## Helper signature -Add to `crates/aura/src/app.rs` (or a new `crates/aura/src/theme.rs` once the +Add to `crates/aura-ui/src/app.rs` (or a new `crates/aura-ui/src/theme.rs` once the file grows past comfort): ```rust @@ -100,7 +100,7 @@ agent is selected (e.g. error state, no profiles configured). When you add a variant to `AgentKind`: 1. Add a `COLOR_` constant near `app.rs:26`. -2. Add the icon SVG to `crates/aura/icons/.svg`. +2. Add the icon SVG to `assets/icons/.svg`. 3. Extend the match in `agent_icon` (`app.rs:853`). 4. Update the **canonical brand colors** table above. 5. If the brand color trips the `> 0.85` luminance threshold, no extra work — diff --git a/.design/customization.md b/.design/customization.md index a83f5d0..e8cf84f 100644 --- a/.design/customization.md +++ b/.design/customization.md @@ -2,7 +2,7 @@ **Status**: implemented. The schema below is the source of truth; the runtime loader lives at `crates/aura-core/src/theme.rs`, with the AuraView wiring in -`crates/aura/src/app.rs`. Built-in defaults are baked into +`crates/aura-ui/src/app.rs`. Built-in defaults are baked into `Theme::default()` and the seed file `crates/aura-core/src/theme_default.toml`. The goal: let users override **any** token from `tokens.md` and any agent's @@ -110,7 +110,7 @@ with no extra UI work. ## Implementation notes -- Add `crates/aura/src/theme.rs` exporting a `Theme` struct mirroring the TOML +- Add `crates/aura-ui/src/theme.rs` exporting a `Theme` struct mirroring the TOML schema, with `Theme::default()` returning the current `COLOR_*` constants. - Replace direct `COLOR_*` references in `app.rs` with reads from `self.theme.colors.bg`, etc. (`u32` values, same `rgb(...)` wrapping). diff --git a/.design/tokens.md b/.design/tokens.md index 357b394..ac67266 100644 --- a/.design/tokens.md +++ b/.design/tokens.md @@ -1,13 +1,13 @@ # Tokens Canonical design tokens for Aura. These mirror the `const COLOR_*` declarations -in `crates/aura/src/app.rs:16-23` and the GPUI utilities used throughout the +in `crates/aura-ui/src/app.rs:16-23` and the GPUI utilities used throughout the render code. Do not invent new tokens without adding them here first. ## Colors All colors are stored as `u32` literals (RGB, no alpha) and wrapped at the -call-site with `gpui::rgb(...)`. Source of truth: `crates/aura/src/app.rs:16-27`. +call-site with `gpui::rgb(...)`. Source of truth: `crates/aura-ui/src/app.rs:16-27`. ### Neutrals (surface stack) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 9794561..91507aa 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -169,7 +169,7 @@ body: attributes: label: Possible cause / hypothesis description: | - Optional. Only include if you have *evidence* — a stack trace pointing at a function, a `git bisect` result, a code path you read. Reference paths like `crates/aura/src/app.rs:274`. Speculation without evidence is fine to leave blank; the maintainers prefer "I don't know" over guesses. + Optional. Only include if you have *evidence* — a stack trace pointing at a function, a `git bisect` result, a code path you read. Reference paths like `crates/aura-ui/src/app.rs:274`. Speculation without evidence is fine to leave blank; the maintainers prefer "I don't know" over guesses. validations: required: false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 044047e..a219498 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,8 +54,10 @@ A first build pulls a lot of GPUI dependencies — give it a few minutes. ``` aura/ ├── crates/ -│ ├── aura/ # Binary crate — GPUI app, tray, modal UI -│ └── aura-core/ # Library — config, readers, plugin runner, quota logic +│ ├── aura/ # Master binary crate — `aura` entry point, routes to cli or ui +│ ├── aura-cli/ # Library — headless `aura ` surface (clap) +│ ├── aura-core/ # Library — config, readers, plugin runner, quota logic +│ └── aura-ui/ # Library — GPUI app, tray, modal UI ├── plugins/ │ └── rtk-gains/ # First-party plugin: RTK token savings panel ├── docs/ # User & architecture docs (see "Documentation index" below) @@ -131,7 +133,7 @@ cargo test --workspace - Add the dep to `[workspace.dependencies]` in the root `Cargo.toml` if it is used by more than one crate, then reference it as `foo = { workspace = true }` in each member's `Cargo.toml`. -- Keep internal crate versions (`aura-core`, `aura-plugin-rtk`) in sync — they +- Keep internal crate versions (`aura-cli`, `aura-core`, `aura-ui`, `aura-plugin-rtk`) in sync — they are managed by `scripts/prepare-release.sh`. ### Working on the UI diff --git a/Cargo.lock b/Cargo.lock index a05d6bc..10ece6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -483,31 +483,24 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "aura" version = "0.1.36" +dependencies = [ + "anyhow", + "aura-cli", + "aura-ui", +] + +[[package]] +name = "aura-cli" +version = "0.1.0" dependencies = [ "anyhow", "aura-core", - "block", - "chrono", "clap", "clap_complete", - "cocoa 0.27.0", - "dirs 7.0.0", - "gpui", - "gpui-selectable-text", - "ksni", "libc", - "objc", - "raw-window-handle", - "resvg 0.48.1", - "semver", "serde", "serde_json", - "tiny-skia 0.12.0", "toml 1.1.3+spec-1.1.0", - "tray-icon", - "ureq", - "windows 0.61.3", - "x11rb 0.14.0", ] [[package]] @@ -549,6 +542,33 @@ dependencies = [ "serde_json", ] +[[package]] +name = "aura-ui" +version = "0.1.0" +dependencies = [ + "anyhow", + "aura-core", + "block", + "chrono", + "cocoa 0.27.0", + "dirs 7.0.0", + "gpui", + "gpui-selectable-text", + "ksni", + "libc", + "objc", + "raw-window-handle", + "resvg 0.48.1", + "semver", + "serde", + "serde_json", + "tiny-skia 0.12.0", + "tray-icon", + "ureq", + "windows 0.61.3", + "x11rb 0.14.0", +] + [[package]] name = "autocfg" version = "1.5.0" diff --git a/Cargo.toml b/Cargo.toml index 54d1d6f..b20c3c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,9 @@ [workspace] members = [ "crates/aura", + "crates/aura-cli", "crates/aura-core", + "crates/aura-ui", "plugins/hello", "plugins/rtk-gains", ] @@ -21,7 +23,9 @@ license = "MIT" [workspace.dependencies] # Internal crates — versions kept in sync by scripts/prepare-release.sh +aura-cli = { path = "crates/aura-cli", version = "0.1.0" } aura-core = { path = "crates/aura-core", version = "0.1.21" } +aura-ui = { path = "crates/aura-ui", version = "0.1.0" } aura-plugin-rtk = { path = "plugins/rtk-gains", version = "0.1.2" } # External diff --git a/PLAN.md b/PLAN.md index fec148f..8ba3fae 100644 --- a/PLAN.md +++ b/PLAN.md @@ -8,6 +8,7 @@ ## Phase 1 — Cargo workspace & CI ✓ - [x] Create `Cargo.toml` workspace at repo root with members: `crates/aura`, `crates/aura-core`, `plugins/rtk-gains` + - Later split: `crates/aura` is now a thin master binary over `crates/aura-cli` (headless subcommands) and `crates/aura-ui` (GPUI app) - [x] `crates/aura-core` — library crate (data layer, plugin runner, config, state); no UI deps - [x] `crates/aura` — binary crate (GPUI app, tray icon, modal); depends on `aura-core` - [x] `plugins/rtk-gains` — standalone binary crate (the RTK Gains plugin) diff --git a/crates/aura-cli/Cargo.toml b/crates/aura-cli/Cargo.toml new file mode 100644 index 0000000..c90e232 --- /dev/null +++ b/crates/aura-cli/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "aura-cli" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +# Headless `aura ` surface. No UI deps: every subcommand runs +# without spinning up GPUI. +[dependencies] +aura-core.workspace = true +anyhow.workspace = true +clap.workspace = true +clap_complete.workspace = true +serde.workspace = true +serde_json.workspace = true +toml.workspace = true + +[target.'cfg(unix)'.dependencies] +# SIGPIPE reset in `dispatch`, SIGTERM handling in `aura update`. +libc = "0.2" diff --git a/crates/aura/src/cli/agents.rs b/crates/aura-cli/src/agents.rs similarity index 100% rename from crates/aura/src/cli/agents.rs rename to crates/aura-cli/src/agents.rs diff --git a/crates/aura/src/cli/completions.rs b/crates/aura-cli/src/completions.rs similarity index 100% rename from crates/aura/src/cli/completions.rs rename to crates/aura-cli/src/completions.rs diff --git a/crates/aura/src/cli/config.rs b/crates/aura-cli/src/config.rs similarity index 100% rename from crates/aura/src/cli/config.rs rename to crates/aura-cli/src/config.rs diff --git a/crates/aura/src/cli/doctor.rs b/crates/aura-cli/src/doctor.rs similarity index 100% rename from crates/aura/src/cli/doctor.rs rename to crates/aura-cli/src/doctor.rs diff --git a/crates/aura/src/cli/format.rs b/crates/aura-cli/src/format.rs similarity index 100% rename from crates/aura/src/cli/format.rs rename to crates/aura-cli/src/format.rs diff --git a/crates/aura/src/cli/keys.rs b/crates/aura-cli/src/keys.rs similarity index 100% rename from crates/aura/src/cli/keys.rs rename to crates/aura-cli/src/keys.rs diff --git a/crates/aura/src/cli/mod.rs b/crates/aura-cli/src/lib.rs similarity index 78% rename from crates/aura/src/cli/mod.rs rename to crates/aura-cli/src/lib.rs index 3004ec0..2b75dee 100644 --- a/crates/aura/src/cli/mod.rs +++ b/crates/aura-cli/src/lib.rs @@ -1,7 +1,7 @@ //! Clap-driven CLI surface for `aura`. //! //! With no subcommand `aura` falls through to the tray entry point in -//! `main.rs`. Every other invocation is headless: each subcommand variant +//! `aura-ui`. Every other invocation is headless: each subcommand variant //! routes to a `run()` on its argument struct and exits without spinning //! up GPUI. //! @@ -25,7 +25,7 @@ mod update; mod usage; use anyhow::Result; -use clap::{Parser, Subcommand}; +use clap::{CommandFactory, FromArgMatches, Parser, Subcommand}; #[derive(Debug, Parser)] #[command( @@ -91,7 +91,27 @@ pub enum Command { SetupConfig, } +impl Cli { + /// Parse `std::env::args`, reporting `version` for `--version`. The + /// `aura` binary passes its own `CARGO_PKG_VERSION` so the flag tracks + /// the release tag rather than this library crate's version. + pub fn parse_with_version(version: &'static str) -> Self { + let matches = Self::command().version(version).get_matches(); + Self::from_arg_matches(&matches).unwrap_or_else(|e| e.exit()) + } +} + pub fn dispatch(command: Command) -> Result<()> { + // Rust ignores SIGPIPE, which turns `aura keys describe | head` into + // a "failed printing to stdout" panic. A CLI should just stop quietly + // when its reader goes away, as every Unix tool does. + #[cfg(unix)] + // SAFETY: restoring the default disposition of a signal, before any + // other thread exists. + unsafe { + libc::signal(libc::SIGPIPE, libc::SIG_DFL); + } + match command { Command::Config(args) => args.run(), Command::State(args) => args.run(), diff --git a/crates/aura/src/cli/plugin.rs b/crates/aura-cli/src/plugin.rs similarity index 100% rename from crates/aura/src/cli/plugin.rs rename to crates/aura-cli/src/plugin.rs diff --git a/crates/aura/src/cli/quota.rs b/crates/aura-cli/src/quota.rs similarity index 100% rename from crates/aura/src/cli/quota.rs rename to crates/aura-cli/src/quota.rs diff --git a/crates/aura/src/cli/resolve.rs b/crates/aura-cli/src/resolve.rs similarity index 100% rename from crates/aura/src/cli/resolve.rs rename to crates/aura-cli/src/resolve.rs diff --git a/crates/aura/src/cli/state.rs b/crates/aura-cli/src/state.rs similarity index 100% rename from crates/aura/src/cli/state.rs rename to crates/aura-cli/src/state.rs diff --git a/crates/aura/src/cli/theme.rs b/crates/aura-cli/src/theme.rs similarity index 100% rename from crates/aura/src/cli/theme.rs rename to crates/aura-cli/src/theme.rs diff --git a/crates/aura/src/cli/update.rs b/crates/aura-cli/src/update.rs similarity index 100% rename from crates/aura/src/cli/update.rs rename to crates/aura-cli/src/update.rs diff --git a/crates/aura/src/cli/usage.rs b/crates/aura-cli/src/usage.rs similarity index 100% rename from crates/aura/src/cli/usage.rs rename to crates/aura-cli/src/usage.rs diff --git a/crates/aura-core/src/theme.rs b/crates/aura-core/src/theme.rs index 77a85a8..d56df7a 100644 --- a/crates/aura-core/src/theme.rs +++ b/crates/aura-core/src/theme.rs @@ -467,7 +467,7 @@ mod tests { fn default_matches_legacy_constants() { let t = Theme::default(); // These are the exact values the binary used to hard-code in - // `crates/aura/src/app.rs` (see `.design/customization.md`). + // `crates/aura-ui/src/app.rs` (see `.design/customization.md`). assert_eq!(t.colors.bg, 0x0e0e10); assert_eq!(t.colors.surface, 0x1a1a1f); assert_eq!(t.colors.surface_hi, 0x252530); diff --git a/crates/aura-ui/Cargo.toml b/crates/aura-ui/Cargo.toml new file mode 100644 index 0000000..1107998 --- /dev/null +++ b/crates/aura-ui/Cargo.toml @@ -0,0 +1,83 @@ +[package] +name = "aura-ui" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +# gpui drives the in-app modal on all platforms. +# The crates.io 0.2.2 snapshot panicked on macOS 26 (Tahoe) because it +# subclassed NSApplication via ClassDecl; Tahoe calls +sharedApplication +# before the #[ctor] registers the subclass. We vendor gpui and patch that +# ivar out (see vendor/gpui and the [patch.crates-io] in the root Cargo.toml). +[dependencies] +aura-core.workspace = true +anyhow.workspace = true +chrono.workspace = true +dirs.workspace = true +gpui = { version = "0.2", features = ["runtime_shaders"] } +gpui-selectable-text = "0.1" +raw-window-handle = "0.6" +resvg = { version = "0.48", default-features = false } +semver.workspace = true +serde.workspace = true +serde_json.workspace = true +ureq.workspace = true +tiny-skia = "0.12" + +# Tray backend split by OS: +# +# * Linux/BSD use `ksni`, a direct StatusNotifierItem implementation. +# Plasma / GNOME / other desktops call `Activate()` over D-Bus on a +# primary-click, which gives us the wifi/volume single-click UX that +# libayatana-appindicator (via tray-icon's `gtk` feature) refuses to +# surface. ksni also runs its own D-Bus event loop, so we drop the +# dedicated GTK thread the tray-icon backend required. +# +# * macOS / Windows keep using `tray-icon`, whose AppKit / Win32 backends +# handle clicks natively. +[target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "netbsd", target_os = "openbsd"))'.dependencies] +# ksni runs its D-Bus event loop on its own thread and we drain events +# with `try_recv`. `blocking` enables the synchronous `spawn()` entry +# point; `async-io` is the smol-style backend it uses under the hood +# (lighter than the default `tokio` feature). +ksni = { version = "0.3", default-features = false, features = ["blocking", "async-io"] } +# Reposition the modal after the auto-fit resize on X11 (`anchor = "bottom"`). +# GPUI exposes no move API, so we issue a ConfigureWindow ourselves against the +# XCB window id (see platform::set_window_origin). Already in the tree via gpui. +# No-op on Wayland, where the compositor owns surface placement. +x11rb = "0.14" + +[target.'cfg(not(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "netbsd", target_os = "openbsd")))'.dependencies] +tray-icon = { version = "0.24", default-features = false } + +[target.'cfg(unix)'.dependencies] +# flock-based single-instance guard. See `platform::acquire_single_instance`. +libc = "0.2" + +[target.'cfg(target_os = "macos")'.dependencies] +block = "0.1" +cocoa = "=0.27.0" +objc = "0.2" + +[target.'cfg(target_os = "windows")'.dependencies] +windows = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_System_Threading", + "Win32_UI_WindowsAndMessaging", + "Win32_Graphics_Dwm", + # MonitorFromPoint, for the per-monitor DPI lookup that converts + # tray-icon's physical click coordinates to logical pixels. + "Win32_Graphics_Gdi", + # GetDpiForMonitor / GetDpiForSystem + GetSystemMetricsForDpi — that same + # conversion, plus picking the tray icon's raster size from SM_CXSMICON at + # the current DPI instead of letting Shell_NotifyIcon downscale for us. + "Win32_UI_HiDpi", + "Win32_UI_Shell", + "Win32_System_Pipes", + "Win32_Storage_FileSystem", +] } + +[lints.rust] +# objc 0.2 macros (msg_send!, class!, sel_impl!) emit #[cfg(cargo-clippy)] +# internally. Register it as expected so the unexpected_cfgs lint stays quiet. +unexpected_cfgs = "allow" diff --git a/crates/aura/src/app.rs b/crates/aura-ui/src/app.rs similarity index 99% rename from crates/aura/src/app.rs rename to crates/aura-ui/src/app.rs index 398643d..c5dbdee 100644 --- a/crates/aura/src/app.rs +++ b/crates/aura-ui/src/app.rs @@ -3304,7 +3304,7 @@ impl AuraView { let lex = lexicon::pick(self.config.content.goblin_mode); // Append the running version so users can confirm the build they're // on without trawling stderr or `aura --version`. - let updates_label = (lex.check_updates_fmt)(env!("CARGO_PKG_VERSION")); + let updates_label = (lex.check_updates_fmt)(crate::app_version()); let items: [(&'static str, &'static str, String, ModalAction); 5] = [ ( "modal-updates", @@ -3869,7 +3869,7 @@ fn rgba(value: u32) -> gpui::Rgba { gpui::Rgba { r, g, b, a } } -// `work_area` lives at `crate::work_area`; both this module and `main.rs` +// `work_area` lives at `crate::work_area`; both this module and `lib.rs` // use it. See `work_area.rs` for the parsing rationale. /// Pure form of `AuraView::show_update_button`. Returns true when there diff --git a/crates/aura/src/assets.rs b/crates/aura-ui/src/assets.rs similarity index 100% rename from crates/aura/src/assets.rs rename to crates/aura-ui/src/assets.rs diff --git a/crates/aura/src/format.rs b/crates/aura-ui/src/format.rs similarity index 100% rename from crates/aura/src/format.rs rename to crates/aura-ui/src/format.rs diff --git a/crates/aura/src/keys.rs b/crates/aura-ui/src/keys.rs similarity index 100% rename from crates/aura/src/keys.rs rename to crates/aura-ui/src/keys.rs diff --git a/crates/aura-ui/src/lib.rs b/crates/aura-ui/src/lib.rs new file mode 100644 index 0000000..7eec1e7 --- /dev/null +++ b/crates/aura-ui/src/lib.rs @@ -0,0 +1,886 @@ +//! GPUI tray app and modal for Aura. +//! +//! The `aura` binary calls [`run`] when invoked with no subcommand; every +//! headless subcommand lives in `aura-cli` and never touches this crate's +//! event loop. + +mod app; +mod assets; +mod format; +mod keys; +mod placement; +mod platform; +mod runtime; +mod tray; +mod tray_status; +mod updater; +mod work_area; + +#[cfg(target_os = "macos")] +use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(target_os = "macos")] +use std::sync::Arc; +use std::sync::OnceLock; +use std::time::Duration; + +use anyhow::Result; +use aura_core::{config::AppConfig, state::AppState}; +use gpui::{ + prelude::*, Application, TitlebarOptions, WindowBounds, WindowDecorations, WindowHandle, + WindowKind, WindowOptions, +}; +// Only the keepalive window needs these, and Linux doesn't build it. +#[cfg(not(target_os = "linux"))] +use gpui::{div, px, size, Bounds, IntoElement, Render}; + +use crate::tray::TrayEvent; +use crate::{app::AuraView, assets::EmbeddedAssets}; + +/// DWM-cloak or -uncloak a window on Windows. Cloaking makes the window +/// invisible to the user (DWM hides it during composition) while it still +/// receives WM_PAINT and renders normally — used to hide the first-frame +/// resize flash (the window opens at its remembered height — MODAL_H on the +/// first open of the process — and the auto-fit pass corrects it on the next +/// frame; without cloaking the user sees a one-frame flicker). +#[cfg(target_os = "windows")] +pub(crate) fn win32_set_cloak(window: &gpui::Window, cloak: bool) { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + use windows::Win32::Foundation::HWND; + use windows::Win32::Graphics::Dwm::{DwmSetWindowAttribute, DWMWA_CLOAK}; + + // Use fully-qualified syntax: Window has an inherent window_handle() that + // returns AnyWindowHandle; we want the raw_window_handle trait method. + let wh = match ::window_handle(window) { + Ok(wh) => wh, + Err(_) => return, + }; + let RawWindowHandle::Win32(h) = wh.as_raw() else { + return; + }; + let hwnd = HWND(h.hwnd.get() as usize as *mut _); + // pvAttribute is a pointer to a BOOL (i32, 4 bytes): 1 = cloak, 0 = uncloak. + let val: i32 = cloak as i32; + let _ = unsafe { + DwmSetWindowAttribute( + hwnd, + DWMWA_CLOAK, + std::ptr::addr_of!(val).cast(), + std::mem::size_of::() as u32, + ) + }; +} + +/// How often the GPUI main thread checks for pending tray menu events. +/// 150 ms is well under the human "instant" threshold (~200 ms) for the +/// click → modal latency while costing essentially nothing CPU-wise. +const MENU_POLL_INTERVAL: Duration = Duration::from_millis(150); + +/// Version reported by the modal and compared against GitHub releases. +/// Set once by [`run`] from the `aura` binary's own `CARGO_PKG_VERSION`, so +/// it tracks the release tag rather than this library crate's version. +static APP_VERSION: OnceLock<&'static str> = OnceLock::new(); + +/// The running Aura release version. Falls back to this crate's version when +/// [`run`] hasn't set it (unit tests). +pub(crate) fn app_version() -> &'static str { + APP_VERSION + .get() + .copied() + .unwrap_or(env!("CARGO_PKG_VERSION")) +} + +/// Tray entry point. `version` is the release version of the `aura` binary, +/// shown in the modal and used by the update check. +pub fn run(version: &'static str) -> Result<()> { + let _ = APP_VERSION.set(version); + + // Single-instance guard: if another Aura is already running, ping it + // (see `platform::try_recv_activation` below) and exit. The lock is + // held (intentionally leaked) for the lifetime of the process; the OS + // releases it on exit. See `platform::acquire_single_instance`. + if !platform::acquire_single_instance() { + return Ok(()); + } + + // ── Load config ─────────────────────────────────────────────────────────── + // + // `AppState` is *not* loaded here on purpose — `toggle_window` reloads it + // from disk each time the modal opens, so a profile change made in one + // session is visible the next time the user clicks the tray icon. + // + // `AppConfig` is also reloaded on every tray click (see the `Show` arm + // below) and on every Refresh-button click (see `app::do_refresh`). + // The shared `runtime` module mirrors a handful of `[display]` fields + // into atomics so both reload paths keep `run`'s tray loop in sync + // with the modal view. + let config_path = AppConfig::default_path(); + let config = AppConfig::load_with_discovery(&config_path)?; + runtime::set_from_config(&config); + + // Seed the height the modal opens at from the last session, so the first + // open is placed as well as every later one — see + // `runtime::seed_modal_height`. A failure here is not worth reporting: + // `toggle_window` falls back to `placement::MODAL_H` and the auto-fit + // takes over from there. + let mut persisted_modal_height = AppState::load().ok().and_then(|s| s.modal_height); + runtime::seed_modal_height(persisted_modal_height); + + // Start the sponsor nudge's one-week clock the first time the tray runs + // (including the first launch after upgrading from a build that never + // recorded it — see `sponsor::record_first_run`). Only written when the + // stamp is missing, and read-modify-write so nothing else is clobbered. + // A state file that fails to parse is left alone rather than overwritten + // with defaults; the nudge simply waits until it reads again. + if let Ok(mut state) = AppState::load() { + if aura_core::sponsor::record_first_run(&mut state, chrono::Utc::now()) { + if let Err(e) = state.save() { + eprintln!("aura: could not record the first run: {e}"); + } + } + } + + // ── Install tray icon ───────────────────────────────────────────────────── + // + // Failure is not fatal, but it *is* serious: the tray icon is Aura's only + // entry point, so a process that keeps running without one is invisible — + // no icon, no window, and nothing to click to get either. We therefore + // both shout on stderr (which lands in the journal / launchd log) and set + // a flag that makes the run loop open the modal once, so the user gets a + // window instead of silence. + // + // On Linux this path is now much rarer than it was: `tray::install` asks + // ksni to treat a missing StatusNotifierWatcher as a soft error and keep + // retrying, which covers both "the panel hasn't claimed the bus name yet" + // at login and "SNI support was enabled after the fact". + let tray = match tray::install() { + Ok(t) => Some(t), + Err(e) => { + eprintln!( + "aura: could not install the tray icon: {e}\n\ + aura: opening the window directly — this session has no icon to click. \ + Re-run `aura` (or use the app-menu entry) to bring the window back." + ); + None + } + }; + let tray_missing = tray.is_none(); + + // Background indicator refresh. Runs on its own thread (the quota lookup + // is blocking I/O), pushes into `tray::set_status`, and is drained on the + // main thread by the poll loop below. Disabled by `tray.indicator`. + if let Some(interval) = config.tray.refresh_interval() { + tray_status::spawn_poll(config_path.clone(), interval); + } + + // ── Launch GPUI app ─────────────────────────────────────────────────────── + // + // No user-visible window is opened at startup, and the tray has to + // outlive every modal open/close cycle. The two platform families get + // there differently: + // + // * Linux: GPUI's Wayland and X11 clients stop the event loop the + // moment `state.windows.is_empty()`. We opt out of that with + // `set_quit_on_last_window_closed(false)` (our vendored patch) and + // open no window at all, so the compositor never sees a stray + // surface from us. + // * macOS / Windows: the platform keeps the process alive by itself, + // but GPUI still needs a window to hang the run loop off, so we + // open the hidden keepalive described on `open_keepalive_window`. + // + // On Linux the backend GPUI picks is not incidental: Wayland forbids a + // client from positioning its own toplevel, which silently disables + // `window.anchor` and every other placement decision Aura makes. The + // guard below applies `window.linux_backend` for exactly the duration of + // `Application::new()` (see `platform::select_display_backend`) and then + // puts the environment back, so child processes are unaffected. + let app = { + let _backend = platform::select_display_backend(&config.window.linux_backend); + Application::new().with_assets(EmbeddedAssets) + }; + app.run(move |cx| { + // Selectable labels deliberately have no focus handle, so install + // the crate's observer-based bridge for copy/select-all and + // shift+arrow extension when no focused control claimed the key. + // + // Escape is handled here instead of by the bridge + // (`clear_on_escape: false`) because the two meanings have to be + // ordered: Escape clears a live text selection, and only closes + // the popup when there is nothing to clear. Leaving both to fire + // as independent keystroke observers would make the outcome depend + // on subscriber iteration order — one Escape could clear *and* + // close. + gpui_selectable_text::register_keyboard_bridge_with( + cx, + gpui_selectable_text::KeyboardBridge { + clear_on_escape: false, + ..Default::default() + }, + ) + .detach(); + // + // With the keymap installed (`keybindings.enabled`), Escape is an + // ordinary binding (`dismiss` / `close_overlay`) that does the same + // ordering itself, and that the user may remap or unbind — so this + // observer only covers the keymap-off case. + cx.observe_keystrokes(|event, window, cx| { + // Something with focus already claimed this keystroke. + if event.action.is_some() || runtime::keybindings_active() { + return; + } + let keystroke = &event.keystroke; + if keystroke.key != "escape" || keystroke.modifiers.modified() { + return; + } + if gpui_selectable_text::registry::clear_active_selection(window, cx) { + return; + } + // Closing a tray popup with Escape is the convention on every + // desktop; the poll loop does the actual teardown because it + // owns the window handle. + runtime::request_dismiss(); + }) + .detach(); + + // GPUI forces NSApplicationActivationPolicyRegular in + // did_finish_launching; reapply the user's preference here so it + // sticks. `runtime::set_from_config` (called at startup before + // .run) only fires once, *before* GPUI launches — without this + // second push, the user's Accessory choice would be overwritten + // by the time we hit the run closure on macOS. + platform::apply_app_switcher_policy(runtime::show_in_app_switcher()); + + // Hold the handle in the move-closure so it isn't dropped. + #[cfg(not(target_os = "linux"))] + let _keepalive = open_keepalive_window(cx); + // No handle to hold on Linux — nothing is opened; the loop is + // kept alive by the opt-out instead of by a window. + #[cfg(target_os = "linux")] + cx.set_quit_on_last_window_closed(false); + + let config = config.clone(); + let config_path = config_path.clone(); + + cx.spawn(async move |cx| { + // Owned here so the icon lives exactly as long as the loop + // that drives it — and so the loop can push status updates + // into it. `TrayHandle` is `!Send` on macOS / Windows (it + // wraps an AppKit / Win32 object); GPUI's foreground executor + // has no `Send` bound, which is what makes this legal. + let mut tray = tray; + + // The currently-open window, if any. We toggle on each + // "Show Aura" click: open if closed, close if open. + let mut current: Option> = None; + + // macOS: NSEvent global monitor flag. Accessory apps can't + // reliably set [NSApp mainWindow], which kills cx.active_window + // detection; the global monitor is the working alternative. + #[cfg(target_os = "macos")] + let outside_clicked = Arc::new(AtomicBool::new(false)); + #[cfg(target_os = "macos")] + let mut click_monitor: Option = None; + + // Grace-period counter: skip focus-loss checks for this many + // poll intervals after opening the modal so the platform + // can finish delivering focus / setting up the monitor before + // we start watching for losses. + let mut just_opened: u8 = 0; + + // When the tray never installed, the user has no way to ask + // for the window — so ask on their behalf, once. + if tray_missing { + current = toggle(cx, None, config.clone(), config_path.clone(), None).await; + if current.is_some() { + just_opened = 4; + } + } + + loop { + // Poll: ksni / tray-icon both expose blocking + // crossbeam channels under the hood, so we drain + // them between short sleeps. + cx.background_executor().timer(MENU_POLL_INTERVAL).await; + + // Apply any indicator state queued since the last tick. + // Must happen on this thread: AppKit refuses NSStatusItem + // mutation from anywhere but the main thread. + if let Some(tray) = tray.as_mut() { + tray.apply_pending_status(); + } + + // Write back the height the content settled at, so the next + // session's first open is placed correctly too (see + // `runtime::seed_modal_height`). Change-gated, and only real + // content is ever recorded (the auto-fit skips placeholder + // measurements), so this is one small write per open at most — + // not one per resize, and nothing at all while the modal sits + // open. Deliberately not deferred to the modal closing: a + // session that ends with the window still up would save + // nothing. Read-modify-write, so a profile the user picked in + // the modal isn't clobbered. + if let Some(height) = runtime::modal_height_to_persist(persisted_modal_height) { + let mut state = AppState::load().unwrap_or_default(); + state.modal_height = Some(height); + if let Err(e) = state.save() { + eprintln!("aura: could not save the modal height: {e}"); + } + // Either way, stop trying: a disk that refused once will + // refuse every 150ms, and the value still serves this + // session from memory. + persisted_modal_height = Some(height); + } + + // Escape, routed here from the keystroke observer in the + // run closure. Unconditional: unlike focus loss this is an + // explicit "close it" from the user, so neither + // `dismiss_on_focus_loss` nor an in-flight plugin action + // suppresses it. + if runtime::take_dismiss_request() { + #[cfg(target_os = "macos")] + if current.is_some() { + outside_clicked.store(false, Ordering::Relaxed); + if let Some(m) = click_monitor.take() { + platform::remove_click_outside_monitor(m); + } + if !runtime::show_in_app_switcher() { + platform::apply_app_switcher_policy(false); + } + } + if let Some(handle) = current.take() { + let _ = cx.update(|cx| { + let _ = handle.update(cx, |_view, window, _cx| window.remove_window()); + }); + } + } + + if runtime::dismiss_on_focus_loss() + && current.is_some() + && !runtime::plugin_action_inflight() + { + let lost_focus = if just_opened > 0 { + just_opened -= 1; + false + } else { + #[cfg(target_os = "macos")] + { + outside_clicked.load(Ordering::Relaxed) + } + #[cfg(not(target_os = "macos"))] + { + cx.update(|cx| cx.active_window().is_none()) + .unwrap_or(false) + } + }; + + if lost_focus { + #[cfg(target_os = "macos")] + { + outside_clicked.store(false, Ordering::Relaxed); + if let Some(m) = click_monitor.take() { + platform::remove_click_outside_monitor(m); + } + if !runtime::show_in_app_switcher() { + platform::apply_app_switcher_policy(false); + } + } + if let Some(handle) = current.take() { + let _ = cx.update(|cx| { + let _ = + handle.update(cx, |_view, window, _cx| window.remove_window()); + }); + } + } + } + + // A second `aura` launch (e.g. from the app-search + // launcher) lost the single-instance race and pinged us + // instead of silently exiting into nothing. Treat it as + // "show the window" — but don't toggle an already-open + // one closed the way a tray click would; just focus it. + if platform::try_recv_activation() { + if let Some(handle) = ¤t { + let _ = cx.update(|cx| { + let _ = + handle.update(cx, |_view, window, _cx| window.activate_window()); + }); + } else { + let fresh_config = AppConfig::load_with_discovery(&config_path) + .unwrap_or_else(|e| { + eprintln!( + "aura: config reload failed ({e}); using cached snapshot" + ); + config.clone() + }); + runtime::set_from_config(&fresh_config); + + current = toggle(cx, None, fresh_config, config_path.clone(), None).await; + + if current.is_some() { + just_opened = 4; // ~600 ms at 150 ms/poll + #[cfg(target_os = "macos")] + { + outside_clicked.store(false, Ordering::Relaxed); + click_monitor = Some(platform::install_click_outside_monitor( + Arc::clone(&outside_clicked), + )); + } + } + } + } + + while let Some(event) = tray::try_recv_event() { + match event { + TrayEvent::Show { anchor } => { + // Reload AppConfig from disk so edits made + // since the last open (whether via the + // settings panel, an external editor, or + // `aura plugin add`) take effect on this + // open. Fall back to the startup snapshot + // if the reload fails so a transient I/O + // error doesn't break the toggle. + let fresh_config = AppConfig::load_with_discovery(&config_path) + .unwrap_or_else(|e| { + eprintln!( + "aura: config reload failed ({e}); using cached snapshot" + ); + config.clone() + }); + runtime::set_from_config(&fresh_config); + + // If a window was open, tear down its monitor + // and demote the activation policy before the + // toggle (which closes it). + #[cfg(target_os = "macos")] + if current.is_some() { + if let Some(m) = click_monitor.take() { + platform::remove_click_outside_monitor(m); + } + if !runtime::show_in_app_switcher() { + platform::apply_app_switcher_policy(false); + } + } + + current = toggle( + cx, + current.take(), + fresh_config, + config_path.clone(), + anchor, + ) + .await; + + if current.is_some() { + just_opened = 4; // ~600 ms at 150 ms/poll + // macOS: install the click-outside + // monitor for the new window. + #[cfg(target_os = "macos")] + { + outside_clicked.store(false, Ordering::Relaxed); + click_monitor = Some(platform::install_click_outside_monitor( + Arc::clone(&outside_clicked), + )); + } + } + } + TrayEvent::OpenConfig => { + open_config_file(&config_path); + } + TrayEvent::OpenConfigTutorial => { + platform::open_url(app::CONFIG_TUTORIAL_URL); + } + TrayEvent::Quit => { + // Explicit user exit from the right-click + // menu. cx.quit() tears down the GPUI + // event loop; aura exits cleanly so + // systemd's Restart=on-failure won't + // respawn us. + let _ = cx.update(|cx| cx.quit()); + return; + } + } + } + } + }) + .detach(); + }); + + Ok(()) +} + +fn open_config_file(config_path: &std::path::Path) { + if !config_path.exists() { + if let Err(e) = AppConfig::load(config_path) { + eprintln!("aura: could not create config before opening it: {e}"); + return; + } + } + platform::open_path(config_path); +} + +/// Empty root view for the hidden keepalive window. The view is never +/// rendered to a screen — its only job is to satisfy `open_window`'s +/// `V: Render` bound so the window can exist in `state.windows`. +#[cfg(not(target_os = "linux"))] +struct KeepAliveView; + +#[cfg(not(target_os = "linux"))] +impl Render for KeepAliveView { + fn render( + &mut self, + _window: &mut gpui::Window, + _cx: &mut gpui::Context, + ) -> impl IntoElement { + div() + } +} + +/// Open the always-present keepalive window (macOS and Windows only). +/// See the call site for why GPUI needs it. Failures are non-fatal but +/// logged: if the keepalive can't open, aura will still work — just with +/// the old "process exits on last window close" behaviour. +/// +/// Linux doesn't build this at all. It used to, and the surface was a +/// steady source of trouble: GPUI's Wayland backend silently ignores +/// `show: false` (it creates an xdg_toplevel and commits the surface +/// unconditionally), so KWin treated the 1×1 keepalive as a real window +/// — decorating it, listing it, and, because `on_window_should_close` +/// refuses every close request, naming it under "The following +/// applications did not close" on the logout screen, where it blocked +/// shutdown for two minutes. Opting out of GPUI's quit-on-last-window +/// rule instead means there is no surface for the compositor to find. +/// +/// What's left here keeps that history in mind, since the same window is +/// still created on the other two platforms: +/// +/// * open it at `(-9999, -9999)` so even if the platform doesn't clamp +/// it back on-screen, the user can't accidentally focus or click it; +/// * `minimize_window()` it immediately (except on Windows, where +/// SW_MINIMIZE would force a hidden window visible); +/// * give it a distinct `app_id` ("aura-keepalive") so a task manager +/// doesn't group it under the main "Aura" entry; +/// * give it a human-readable title ("Aura"), because window lists and +/// session managers surface titles in places we don't control and an +/// untitled entry tells the user nothing. `WindowOptions::titlebar` +/// can't do this for us — setting it would also change the window's +/// decoration behaviour — so we call `set_window_title` after open, +/// which routes through the per-platform `set_title`; +/// * intercept every platform-level close request with +/// `on_window_should_close` returning `false`, so the tray can't be +/// killed by a stray click. Our own `toggle()` uses +/// `window.remove_window()`, which bypasses this guard (it's an +/// internal close, not a platform request). +#[cfg(not(target_os = "linux"))] +fn open_keepalive_window(cx: &mut gpui::App) -> Option> { + let opts = WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds::new( + gpui::point(px(-9999.), px(-9999.)), + size(px(1.), px(1.)), + ))), + titlebar: None, + focus: false, + show: false, + // On Windows use PopUp (WS_EX_TOOLWINDOW) so the hidden keepalive + // doesn't create a taskbar button. Normal (WS_EX_APPWINDOW) is fine + // on other platforms where the window is never surfaced to the user. + #[cfg(target_os = "windows")] + kind: WindowKind::PopUp, + #[cfg(not(target_os = "windows"))] + kind: WindowKind::Normal, + is_movable: false, + is_resizable: false, + is_minimizable: false, + app_id: Some("aura-keepalive".into()), + ..Default::default() + }; + + match cx.open_window(opts, |_window, cx| cx.new(|_| KeepAliveView)) { + Ok(handle) => { + // Best-effort hide + lock. The `update` returns Err only if + // the window vanished between open and now (shouldn't happen); + // either way we return the handle so the caller's reference + // keeps the keepalive alive. + let _ = handle.update(cx, |_view, window, cx| { + // Name the surface before anything else so compositors and + // session managers have it from the first commit. See the + // doc comment: an untitled keepalive shows up as a nameless + // entry in KDE's "these applications did not close" list. + window.set_window_title("Aura"); + window.on_window_should_close(cx, |_, _| false); + // On Wayland, `show: false` is ignored — the compositor + // creates a surface unconditionally. Minimize immediately so + // KDE places it in the taskbar overflow instead of the + // desktop. On Windows, SW_MINIMIZE on a hidden window would + // force it visible (minimized), so skip this call there. + #[cfg(not(target_os = "windows"))] + window.minimize_window(); + }); + Some(handle) + } + Err(e) => { + eprintln!("warning: failed to open keepalive window: {e}"); + None + } + } +} + +/// If `existing` is alive, close it and return `None`; otherwise open a +/// fresh window and return its handle. Called from both the tray "Show" +/// menu item and a primary-click on the tray icon — each click flips +/// modal visibility. +async fn toggle( + cx: &gpui::AsyncApp, + existing: Option>, + config: AppConfig, + config_path: std::path::PathBuf, + tray_anchor: Option, +) -> Option> { + cx.update(move |cx| toggle_window(cx, existing, config, config_path, tray_anchor)) + .ok() + .flatten() +} + +// Modal geometry (size + anchor math) lives in `placement.rs` — the single +// source of truth shared by `toggle_window` (open) and `app.rs`'s auto-fit +// reposition. See that module for the per-OS anchoring rules. + +fn toggle_window( + cx: &mut gpui::App, + existing: Option>, + config: AppConfig, + config_path: std::path::PathBuf, + tray_anchor: Option, +) -> Option> { + if let Some(handle) = existing { + // `update` returns Err if the window has already been removed; + // either way we're done with this handle. + let _ = handle.update(cx, |_view, window, _cx| window.remove_window()); + return None; + } + + // Reload AppState from disk so the active profile reflects what the user + // picked in any prior modal session. The process keeps running between + // modal open/close cycles (see the keepalive window), so a snapshot + // loaded once at startup would go stale on the first profile change. + let state = AppState::load().unwrap_or_else(|e| { + eprintln!("aura: could not reload state, using defaults: {e}"); + AppState::default() + }); + + // Same reasoning for the keymap: re-read `keybindings.toml` on every open + // so an edit applies without a restart. + let keymap = aura_core::keymap::Keymap::load(&aura_core::keymap::Keymap::default_path()); + keys::install(cx, &keymap, config.keybindings.enabled); + + let anchor = placement::Anchor::from_config(&config.window.anchor); + // `display_id` rides along to `AuraView` so the auto-fit callback caps the + // modal's height against the screen it actually opened on. Reading + // `primary_display()` there instead would measure the wrong taskbar the + // moment the tray lives on a secondary monitor. + // Open at the height the content settled at last time, so the auto-fit + // pass has nothing to correct and the window doesn't visibly jump one + // frame after it appears. `MODAL_H` on the first open of the process. + let open_h = runtime::last_modal_height().unwrap_or(placement::MODAL_H); + let (bounds, display_id) = placement::modal_bounds(cx, tray_anchor, anchor, open_h); + // `window.show_in_app_switcher` controls whether the modal appears in + // the OS's "where are my windows" surfaces — Cmd+Tab + Dock on macOS, + // Alt+Tab + taskbar on Windows, panel + window switcher on Linux. + // + // Linux / Windows: + // - true → WindowKind::Normal (xdg_toplevel / WS_EX_APPWINDOW). + // - false → WindowKind::PopUp (no taskbar entry, WS_EX_TOOLWINDOW). + // + // `WindowKind::PopUp` also strips chrome on every backend: Windows applies + // `WS_EX_TOOLWINDOW` + `WINDOW_STYLE(0x0)` (no caption, no resize frame), + // and X11 sets `_NET_WM_WINDOW_TYPE_NOTIFICATION` which tells the WM to + // drop decorations. So `window_chrome` has to override the kind too — + // otherwise the titlebar/is_resizable we set below are silently ignored. + // Side-effect: enabling chrome also puts the modal in the taskbar / + // alt-tab list, which is consistent with it being a "real" window. + // + // macOS: ALWAYS Normal. GPUI maps WindowKind::PopUp to NSPanel with + // NSWindowStyleMaskNonactivatingPanel, which deliberately prevents the + // window from becoming key. We need the window to be key so + // `cx.active_window()` can track focus (the focus-loss check below + // depends on this). What keeps Aura out of Cmd+Tab on macOS is the + // NSApplicationActivationPolicy — promoted to Regular only while the + // modal is open, demoted back to Accessory on close (see + // `platform::apply_app_switcher_policy` calls). + #[cfg(target_os = "macos")] + let kind = WindowKind::Normal; + #[cfg(not(target_os = "macos"))] + let kind = if config.window.show_in_app_switcher || config.window.chrome { + WindowKind::Normal + } else { + WindowKind::PopUp + }; + // `window.chrome` controls only the native title bar: + // false (default): chromeless tray-popup, fixed width. + // true: native OS chrome (title bar + min/max/close). window_decorations: + // Server asks Wayland compositors to draw SSD. + // Whether the modal auto-fits its content height is a separate axis, + // governed by `window.auto_resize` in app.rs (see on_children_prepainted) — + // independent of chrome, so the auto-fit works in both modes. + // + // window_decorations must be `Some(..)` in both branches, not `None` for + // the chromeless case: GPUI's `request_decorations` defaults a `None` to + // `WindowDecorations::Server` (see gpui `window.rs`). Both Linux backends + // (this branch runs on X11 and Wayland alike — no per-backend split here) + // override `request_decorations`, so that default actively asks for + // server-side chrome: on X11 it writes an explicit `_MOTIF_WM_HINTS` + // "show decorations" hint, on Wayland it requests SSD via the + // xdg-decoration protocol. Some WMs/compositors (KWin observed) honor + // that over the `_NET_WM_WINDOW_TYPE_NOTIFICATION` borderless hint from + // `WindowKind::PopUp`, so the titlebar reappears even though + // `window_chrome` is false. Requesting `Client` explicitly writes the + // "hide decorations" hint/request instead. (macOS and Windows don't + // override `request_decorations` at all — this field is a no-op there; + // chrome is controlled by `titlebar`/`kind` alone on those platforms.) + let (titlebar, is_resizable, window_decorations) = if config.window.chrome { + ( + Some(TitlebarOptions::default()), + true, + Some(WindowDecorations::Server), + ) + } else { + (None, false, Some(WindowDecorations::Client)) + }; + let opts = WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + titlebar, + is_resizable, + window_decorations, + // Set a stable Wayland app_id / X11 WM_CLASS so KWin window rules + // (see README "Modal placement on Wayland") can match this surface. + // Without this, KDE shows "Window class not available" when the + // user tries to Detect Window Properties on the modal. + app_id: Some("aura".into()), + // Target the display the tray icon lives on. Not optional on Windows: + // `open_window` validates the requested bounds against this display + // (the primary one when unset) and silently substitutes its centred + // default when they fall outside — which is what any secondary-monitor + // origin looks like. macOS also resolves the origin relative to this + // screen's frame; see `placement::to_window_origin`. + display_id, + kind, + // On macOS, GPUI creates the window with NSTitled|NSFullSizeContentView + // even when titlebar:None. The native title-bar drag zone covers our + // header; with is_movable:true the OS handles drags there, which can + // route mouse events outside GPUI's queue. Disabling movability tells + // AppKit to forward those clicks to the content view instead, so the + // header buttons behave like normal content. + is_movable: false, + ..Default::default() + }; + + // Cloak (Windows) hides the first-frame flash that only happens when the + // auto-fit step shrinks the window from its open-time MODAL_H to the + // content height. So it must track `auto_resize` (the same flag that gates + // the auto-fit callback / uncloak in app.rs) — NOT `window_chrome`. Tying + // it to chrome would leave a chromeless + fixed-size window (auto_resize = + // false) cloaked forever, since no uncloak step ever runs. + #[cfg(target_os = "windows")] + let cloak = config.window.auto_resize(); + + match cx.open_window(opts, |window, cx| { + cx.new(|cx| { + let view = AuraView::new( + config, + config_path, + state, + keymap, + display_id, + tray_anchor, + cx, + ); + // Key bindings dispatch from the focused element, and nothing + // else in the modal takes focus, so the root holds it for the + // window's lifetime (a click anywhere re-focuses it). + window.focus(&view.focus_handle); + view + }) + }) { + Ok(handle) => { + // On macOS, if we are running as NSApplicationActivationPolicyAccessory + // (background-only mode), the OS won't grant foreground focus to the + // window. Promote to Regular while the modal is open so activate() + // and active_window() work normally. We demote back to Accessory + // when the window is closed (see the focus-loss / remove_window paths). + #[cfg(target_os = "macos")] + if !runtime::show_in_app_switcher() { + platform::apply_app_switcher_policy(true); + } + + // Name the surface. Every window list we don't control reads + // this — alt-tab, task managers, session managers — and an + // untitled entry tells the user nothing about which app it + // belongs to. `titlebar` can't carry the name for us: the + // Wayland backend ignores `TitlebarOptions::title` (only + // `set_title` reaches `xdg_toplevel`), and with + // `window_chrome = false` there are no `TitlebarOptions` at all. + // `set_window_title` routes through the per-platform `set_title` + // in both configurations. With chrome on, this is also what the + // decorated title bar now shows. + let _ = handle.update(cx, |_, window, _| window.set_window_title("Aura")); + + // Re-assert the origin we asked `open_window` for. A window + // manager is free to ignore the position a client requests at map + // time unless the WM_NORMAL_HINTS carry `PPosition`, which GPUI + // does not set — KWin applies its own placement policy to the + // first window a process opens and centres it. The auto-fit pass + // corrects that a frame or two later, which is exactly the visible + // jump this call removes. Absolute coordinates: on X11 + // `placement::to_window_origin` is the identity, so `bounds` is + // already in the space `set_window_origin` wants. + #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + { + let _ = handle.update(cx, |_, window, cx| { + // No frame shift: the window manager has not framed the + // window yet, so there is nothing to measure. The auto-fit + // pass corrects for it once `_NET_FRAME_EXTENTS` appears. + platform::set_window_origin(window, cx, bounds.origin, bounds.size, 0.0) + }); + } + + cx.activate(true); + + #[cfg(target_os = "windows")] + { + // Cloak immediately so the first frame (at the open height, + // before on_children_prepainted fits it to the content) is + // invisible. AuraView's on_children_prepainted uncloak fires + // on the second frame after the resize, showing the window at + // the correct size. + // + // Skipped when `auto_resize` is off (`cloak` is false): there + // is no auto-shrink step then, so cloaking would leave the + // window invisible forever. + let _ = handle.update(cx, |_, window, _| { + if cloak { + win32_set_cloak(window, true); + } + window.activate_window(); + }); + } + #[cfg(target_os = "macos")] + { + let _ = handle.update(cx, |_, window, _| { + // Raise above other apps' windows. GPUI's Normal kind sets + // NSNormalWindowLevel, so without this the modal opens + // behind whatever app the user was focused on. + platform::raise_window_to_floating(window); + window.activate_window(); + }); + } + #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] + { + let _ = handle.update(cx, |_, window, _| window.activate_window()); + } + + Some(handle) + } + Err(e) => { + eprintln!("aura: failed to open window: {e}"); + None + } + } +} diff --git a/crates/aura/src/placement.rs b/crates/aura-ui/src/placement.rs similarity index 99% rename from crates/aura/src/placement.rs rename to crates/aura-ui/src/placement.rs index 712c993..180dc11 100644 --- a/crates/aura/src/placement.rs +++ b/crates/aura-ui/src/placement.rs @@ -12,7 +12,7 @@ //! The [`Anchor`] (from `window.anchor` in the config) selects how the modal //! behaves as it auto-fits its content height. Two callers share the module: //! -//! 1. [`modal_bounds`] — `main.rs::toggle_window` uses it for the initial +//! 1. [`modal_bounds`] — `lib.rs::toggle_window` uses it for the initial //! window bounds at open (size + origin for the height it opens at). //! 2. [`modal_origin`] — `app.rs`'s auto-fit callback uses it to recompute //! where the (now shorter) window should sit after it shrinks to the diff --git a/crates/aura/src/platform.rs b/crates/aura-ui/src/platform.rs similarity index 100% rename from crates/aura/src/platform.rs rename to crates/aura-ui/src/platform.rs diff --git a/crates/aura/src/runtime.rs b/crates/aura-ui/src/runtime.rs similarity index 96% rename from crates/aura/src/runtime.rs rename to crates/aura-ui/src/runtime.rs index a14031e..a514aa1 100644 --- a/crates/aura/src/runtime.rs +++ b/crates/aura-ui/src/runtime.rs @@ -1,4 +1,4 @@ -//! Cross-cutting runtime state shared by `main.rs` (tray poll loop) and +//! Cross-cutting runtime state shared by `lib.rs` (tray poll loop) and //! `app.rs` (the view's refresh task). //! //! The poll loop in `main()` and the in-modal "Refresh" task each reload @@ -21,7 +21,7 @@ use aura_core::config::AppConfig; /// the user clicks the refresh icon. static DISMISS_ON_FOCUS_LOSS: AtomicBool = AtomicBool::new(true); -/// Mirrors `AppConfig.window.show_in_app_switcher`. Used by main.rs +/// Mirrors `AppConfig.window.show_in_app_switcher`. Used by lib.rs /// when opening the modal (picks `WindowKind`) and as the source of /// truth for the macOS process-wide NSApp activation policy applied at /// startup and on every refresh. @@ -117,7 +117,7 @@ pub fn take_dismiss_request() -> bool { /// Whether the keymap is installed (`keybindings.enabled`). While it is, Escape /// is an ordinary binding the user can remap or unbind; while it isn't, the -/// fallback Escape observer in `main.rs` keeps Escape closing the modal. +/// fallback Escape observer in `lib.rs` keeps Escape closing the modal. static KEYBINDINGS_ACTIVE: AtomicBool = AtomicBool::new(false); /// See [`KEYBINDINGS_ACTIVE`]. diff --git a/crates/aura/src/tray.rs b/crates/aura-ui/src/tray.rs similarity index 99% rename from crates/aura/src/tray.rs rename to crates/aura-ui/src/tray.rs index d4c99ab..831c1ac 100644 --- a/crates/aura/src/tray.rs +++ b/crates/aura-ui/src/tray.rs @@ -9,7 +9,7 @@ //! macOS / Windows still use [`tray_icon`]: those backends already get //! single-click activation natively via AppKit / Win32. //! -//! Both backends feed a unified [`TrayEvent`] stream that `main.rs` drains +//! Both backends feed a unified [`TrayEvent`] stream that `lib.rs` drains //! from the GPUI side via [`try_recv_event`], and both accept live state //! pushes through [`set_status`] / [`apply_pending_status`] so the icon can //! act as a real indicator instead of a static launcher. @@ -168,7 +168,7 @@ impl Default for TrayVisuals { } /// Live indicator state. Pushed from whatever loaded fresh usage data (the -/// modal's refresh, or `main.rs`'s background poll) and applied to the icon on +/// modal's refresh, or `lib.rs`'s background poll) and applied to the icon on /// the main thread. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TrayStatus { @@ -257,7 +257,7 @@ impl TrayStatus { /// /// The indirection exists because AppKit requires `NSStatusItem` mutation on /// the main thread, and the callers that *have* fresh data (the refresh -/// worker, the background poll) run off it. `main.rs`'s poll loop drains this +/// worker, the background poll) run off it. `lib.rs`'s poll loop drains this /// on the GPUI main thread via [`apply_pending_status`]. static PENDING_STATUS: Mutex> = Mutex::new(None); diff --git a/crates/aura/src/tray_status.rs b/crates/aura-ui/src/tray_status.rs similarity index 100% rename from crates/aura/src/tray_status.rs rename to crates/aura-ui/src/tray_status.rs diff --git a/crates/aura/src/updater.rs b/crates/aura-ui/src/updater.rs similarity index 92% rename from crates/aura/src/updater.rs rename to crates/aura-ui/src/updater.rs index b0e1924..8ed55a2 100644 --- a/crates/aura/src/updater.rs +++ b/crates/aura-ui/src/updater.rs @@ -2,7 +2,7 @@ //! //! Aura ships from GitHub releases. There is no in-app downloader (see //! `docs/plans/update-button.md` for the rationale): we just compare the -//! local `CARGO_PKG_VERSION` against `releases/latest` from the GitHub +//! running `aura` version against `releases/latest` from the GitHub //! REST API and, when a newer tag is out, surface a header button that //! opens the README's `### Updating` anchor. //! @@ -36,11 +36,11 @@ pub struct UpdateInfo { pub latest: Version, } -/// `env!("CARGO_PKG_VERSION")` parsed into a `semver::Version`. Panics at -/// compile-time-determined-string-parse only if Aura's own version -/// somehow fails to parse, which would be a build-script bug. +/// The `aura` binary's version (see `crate::app_version`) parsed into a +/// `semver::Version`. Panics only if Aura's own version somehow fails to +/// parse, which would be a manifest bug. pub fn current_version() -> Version { - Version::parse(env!("CARGO_PKG_VERSION")).expect("aura version is valid semver") + Version::parse(crate::app_version()).expect("aura version is valid semver") } /// Synchronous network call. Spawned on the background executor by @@ -58,7 +58,7 @@ pub fn fetch_latest() -> Result> { .into(); // GitHub requires a User-Agent on every API request. - let ua = format!("aura/{}", env!("CARGO_PKG_VERSION")); + let ua = format!("aura/{}", crate::app_version()); let mut response = agent .get(RELEASES_API_URL) .header("User-Agent", ua.as_str()) diff --git a/crates/aura/src/work_area.rs b/crates/aura-ui/src/work_area.rs similarity index 99% rename from crates/aura/src/work_area.rs rename to crates/aura-ui/src/work_area.rs index ff4d643..2951314 100644 --- a/crates/aura/src/work_area.rs +++ b/crates/aura-ui/src/work_area.rs @@ -8,7 +8,7 @@ //! //! 1. `app.rs` resize callback: caps the auto-grown modal height so it //! can't extend past the top of a bottom taskbar. -//! 2. `main.rs` `toggle_window`: anchors the modal at the bottom-right +//! 2. `lib.rs` `toggle_window`: anchors the modal at the bottom-right //! corner of the work area so it appears where the tray icon lives. //! //! Per-platform sources, in order of preference: diff --git a/crates/aura/Cargo.toml b/crates/aura/Cargo.toml index a6583e2..395acf6 100644 --- a/crates/aura/Cargo.toml +++ b/crates/aura/Cargo.toml @@ -4,83 +4,9 @@ version = "0.1.36" edition.workspace = true license.workspace = true -# gpui drives the in-app modal on all platforms. -# The crates.io 0.2.2 snapshot panicked on macOS 26 (Tahoe) because it -# subclassed NSApplication via ClassDecl; Tahoe calls +sharedApplication -# before the #[ctor] registers the subclass. We vendor gpui and patch that -# ivar out (see vendor/gpui and the [patch.crates-io] in the root Cargo.toml). +# Master crate: the `aura` binary. Headless subcommands go to `aura-cli`; +# no subcommand launches the tray app from `aura-ui`. [dependencies] -aura-core.workspace = true -anyhow.workspace = true -chrono.workspace = true -clap.workspace = true -clap_complete.workspace = true -dirs.workspace = true -gpui = { version = "0.2", features = ["runtime_shaders"] } -gpui-selectable-text = "0.1" -raw-window-handle = "0.6" -resvg = { version = "0.48", default-features = false } -semver.workspace = true -serde.workspace = true -serde_json.workspace = true -ureq.workspace = true -tiny-skia = "0.12" -toml.workspace = true - -# Tray backend split by OS: -# -# * Linux/BSD use `ksni`, a direct StatusNotifierItem implementation. -# Plasma / GNOME / other desktops call `Activate()` over D-Bus on a -# primary-click, which gives us the wifi/volume single-click UX that -# libayatana-appindicator (via tray-icon's `gtk` feature) refuses to -# surface. ksni also runs its own D-Bus event loop, so we drop the -# dedicated GTK thread the tray-icon backend required. -# -# * macOS / Windows keep using `tray-icon`, whose AppKit / Win32 backends -# handle clicks natively. -[target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "netbsd", target_os = "openbsd"))'.dependencies] -# ksni runs its D-Bus event loop on its own thread and we drain events -# with `try_recv`. `blocking` enables the synchronous `spawn()` entry -# point; `async-io` is the smol-style backend it uses under the hood -# (lighter than the default `tokio` feature). -ksni = { version = "0.3", default-features = false, features = ["blocking", "async-io"] } -# Reposition the modal after the auto-fit resize on X11 (`anchor = "bottom"`). -# GPUI exposes no move API, so we issue a ConfigureWindow ourselves against the -# XCB window id (see platform::set_window_origin). Already in the tree via gpui. -# No-op on Wayland, where the compositor owns surface placement. -x11rb = "0.14" - -[target.'cfg(not(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "netbsd", target_os = "openbsd")))'.dependencies] -tray-icon = { version = "0.24", default-features = false } - -[target.'cfg(unix)'.dependencies] -# flock-based single-instance guard. See `platform::acquire_single_instance`. -libc = "0.2" - -[target.'cfg(target_os = "macos")'.dependencies] -block = "0.1" -cocoa = "=0.27.0" -objc = "0.2" - -[target.'cfg(target_os = "windows")'.dependencies] -windows = { version = "0.61", features = [ - "Win32_Foundation", - "Win32_System_Threading", - "Win32_UI_WindowsAndMessaging", - "Win32_Graphics_Dwm", - # MonitorFromPoint, for the per-monitor DPI lookup that converts - # tray-icon's physical click coordinates to logical pixels. - "Win32_Graphics_Gdi", - # GetDpiForMonitor / GetDpiForSystem + GetSystemMetricsForDpi — that same - # conversion, plus picking the tray icon's raster size from SM_CXSMICON at - # the current DPI instead of letting Shell_NotifyIcon downscale for us. - "Win32_UI_HiDpi", - "Win32_UI_Shell", - "Win32_System_Pipes", - "Win32_Storage_FileSystem", -] } - -[lints.rust] -# objc 0.2 macros (msg_send!, class!, sel_impl!) emit #[cfg(cargo-clippy)] -# internally. Register it as expected so the unexpected_cfgs lint stays quiet. -unexpected_cfgs = "allow" +anyhow.workspace = true +aura-cli.workspace = true +aura-ui.workspace = true diff --git a/crates/aura/src/main.rs b/crates/aura/src/main.rs index bab3b27..cc65596 100644 --- a/crates/aura/src/main.rs +++ b/crates/aura/src/main.rs @@ -2,883 +2,19 @@ // prompt alongside the GUI process. #![cfg_attr(target_os = "windows", windows_subsystem = "windows")] -mod app; -mod assets; -mod cli; -mod format; -mod keys; -mod placement; -mod platform; -mod runtime; -mod tray; -mod tray_status; -mod updater; -mod work_area; - -#[cfg(target_os = "macos")] -use std::sync::atomic::{AtomicBool, Ordering}; -#[cfg(target_os = "macos")] -use std::sync::Arc; -use std::time::Duration; - use anyhow::Result; -use aura_core::{config::AppConfig, state::AppState}; -use clap::Parser; -use gpui::{ - prelude::*, Application, TitlebarOptions, WindowBounds, WindowDecorations, WindowHandle, - WindowKind, WindowOptions, -}; -// Only the keepalive window needs these, and Linux doesn't build it. -#[cfg(not(target_os = "linux"))] -use gpui::{div, px, size, Bounds, IntoElement, Render}; - -use crate::tray::TrayEvent; -use crate::{app::AuraView, assets::EmbeddedAssets}; -/// DWM-cloak or -uncloak a window on Windows. Cloaking makes the window -/// invisible to the user (DWM hides it during composition) while it still -/// receives WM_PAINT and renders normally — used to hide the first-frame -/// resize flash (the window opens at its remembered height — MODAL_H on the -/// first open of the process — and the auto-fit pass corrects it on the next -/// frame; without cloaking the user sees a one-frame flicker). -#[cfg(target_os = "windows")] -pub(crate) fn win32_set_cloak(window: &gpui::Window, cloak: bool) { - use raw_window_handle::{HasWindowHandle, RawWindowHandle}; - use windows::Win32::Foundation::HWND; - use windows::Win32::Graphics::Dwm::{DwmSetWindowAttribute, DWMWA_CLOAK}; - - // Use fully-qualified syntax: Window has an inherent window_handle() that - // returns AnyWindowHandle; we want the raw_window_handle trait method. - let wh = match ::window_handle(window) { - Ok(wh) => wh, - Err(_) => return, - }; - let RawWindowHandle::Win32(h) = wh.as_raw() else { - return; - }; - let hwnd = HWND(h.hwnd.get() as usize as *mut _); - // pvAttribute is a pointer to a BOOL (i32, 4 bytes): 1 = cloak, 0 = uncloak. - let val: i32 = cloak as i32; - let _ = unsafe { - DwmSetWindowAttribute( - hwnd, - DWMWA_CLOAK, - std::ptr::addr_of!(val).cast(), - std::mem::size_of::() as u32, - ) - }; -} - -/// How often the GPUI main thread checks for pending tray menu events. -/// 150 ms is well under the human "instant" threshold (~200 ms) for the -/// click → modal latency while costing essentially nothing CPU-wise. -const MENU_POLL_INTERVAL: Duration = Duration::from_millis(150); +/// Release version of Aura. Passed down so `aura --version`, the modal and +/// the update check all report the release tag, not a library crate's version. +const VERSION: &str = env!("CARGO_PKG_VERSION"); fn main() -> Result<()> { // Subcommand dispatch — handled before GPUI init so headless commands // don't spin up a window/event loop. `aura` with no subcommand falls - // through to the tray entry point below. - let cli = cli::Cli::parse(); + // through to the tray entry point. + let cli = aura_cli::Cli::parse_with_version(VERSION); if let Some(command) = cli.command { - // Rust ignores SIGPIPE, which turns `aura keys describe | head` into - // a "failed printing to stdout" panic. A CLI should just stop quietly - // when its reader goes away, as every Unix tool does. - #[cfg(unix)] - // SAFETY: restoring the default disposition of a signal, before any - // other thread exists. - unsafe { - libc::signal(libc::SIGPIPE, libc::SIG_DFL); - } - return cli::dispatch(command); - } - - // Single-instance guard: if another Aura is already running, ping it - // (see `platform::try_recv_activation` below) and exit. The lock is - // held (intentionally leaked) for the lifetime of the process; the OS - // releases it on exit. See `platform::acquire_single_instance`. - if !platform::acquire_single_instance() { - return Ok(()); - } - - // ── Load config ─────────────────────────────────────────────────────────── - // - // `AppState` is *not* loaded here on purpose — `toggle_window` reloads it - // from disk each time the modal opens, so a profile change made in one - // session is visible the next time the user clicks the tray icon. - // - // `AppConfig` is also reloaded on every tray click (see the `Show` arm - // below) and on every Refresh-button click (see `app::do_refresh`). - // The shared `runtime` module mirrors a handful of `[display]` fields - // into atomics so both reload paths keep `main`'s tray loop in sync - // with the modal view. - let config_path = AppConfig::default_path(); - let config = AppConfig::load_with_discovery(&config_path)?; - runtime::set_from_config(&config); - - // Seed the height the modal opens at from the last session, so the first - // open is placed as well as every later one — see - // `runtime::seed_modal_height`. A failure here is not worth reporting: - // `toggle_window` falls back to `placement::MODAL_H` and the auto-fit - // takes over from there. - let mut persisted_modal_height = AppState::load().ok().and_then(|s| s.modal_height); - runtime::seed_modal_height(persisted_modal_height); - - // Start the sponsor nudge's one-week clock the first time the tray runs - // (including the first launch after upgrading from a build that never - // recorded it — see `sponsor::record_first_run`). Only written when the - // stamp is missing, and read-modify-write so nothing else is clobbered. - // A state file that fails to parse is left alone rather than overwritten - // with defaults; the nudge simply waits until it reads again. - if let Ok(mut state) = AppState::load() { - if aura_core::sponsor::record_first_run(&mut state, chrono::Utc::now()) { - if let Err(e) = state.save() { - eprintln!("aura: could not record the first run: {e}"); - } - } - } - - // ── Install tray icon ───────────────────────────────────────────────────── - // - // Failure is not fatal, but it *is* serious: the tray icon is Aura's only - // entry point, so a process that keeps running without one is invisible — - // no icon, no window, and nothing to click to get either. We therefore - // both shout on stderr (which lands in the journal / launchd log) and set - // a flag that makes the run loop open the modal once, so the user gets a - // window instead of silence. - // - // On Linux this path is now much rarer than it was: `tray::install` asks - // ksni to treat a missing StatusNotifierWatcher as a soft error and keep - // retrying, which covers both "the panel hasn't claimed the bus name yet" - // at login and "SNI support was enabled after the fact". - let tray = match tray::install() { - Ok(t) => Some(t), - Err(e) => { - eprintln!( - "aura: could not install the tray icon: {e}\n\ - aura: opening the window directly — this session has no icon to click. \ - Re-run `aura` (or use the app-menu entry) to bring the window back." - ); - None - } - }; - let tray_missing = tray.is_none(); - - // Background indicator refresh. Runs on its own thread (the quota lookup - // is blocking I/O), pushes into `tray::set_status`, and is drained on the - // main thread by the poll loop below. Disabled by `tray.indicator`. - if let Some(interval) = config.tray.refresh_interval() { - tray_status::spawn_poll(config_path.clone(), interval); - } - - // ── Launch GPUI app ─────────────────────────────────────────────────────── - // - // No user-visible window is opened at startup, and the tray has to - // outlive every modal open/close cycle. The two platform families get - // there differently: - // - // * Linux: GPUI's Wayland and X11 clients stop the event loop the - // moment `state.windows.is_empty()`. We opt out of that with - // `set_quit_on_last_window_closed(false)` (our vendored patch) and - // open no window at all, so the compositor never sees a stray - // surface from us. - // * macOS / Windows: the platform keeps the process alive by itself, - // but GPUI still needs a window to hang the run loop off, so we - // open the hidden keepalive described on `open_keepalive_window`. - // - // On Linux the backend GPUI picks is not incidental: Wayland forbids a - // client from positioning its own toplevel, which silently disables - // `window.anchor` and every other placement decision Aura makes. The - // guard below applies `window.linux_backend` for exactly the duration of - // `Application::new()` (see `platform::select_display_backend`) and then - // puts the environment back, so child processes are unaffected. - let app = { - let _backend = platform::select_display_backend(&config.window.linux_backend); - Application::new().with_assets(EmbeddedAssets) - }; - app.run(move |cx| { - // Selectable labels deliberately have no focus handle, so install - // the crate's observer-based bridge for copy/select-all and - // shift+arrow extension when no focused control claimed the key. - // - // Escape is handled here instead of by the bridge - // (`clear_on_escape: false`) because the two meanings have to be - // ordered: Escape clears a live text selection, and only closes - // the popup when there is nothing to clear. Leaving both to fire - // as independent keystroke observers would make the outcome depend - // on subscriber iteration order — one Escape could clear *and* - // close. - gpui_selectable_text::register_keyboard_bridge_with( - cx, - gpui_selectable_text::KeyboardBridge { - clear_on_escape: false, - ..Default::default() - }, - ) - .detach(); - // - // With the keymap installed (`keybindings.enabled`), Escape is an - // ordinary binding (`dismiss` / `close_overlay`) that does the same - // ordering itself, and that the user may remap or unbind — so this - // observer only covers the keymap-off case. - cx.observe_keystrokes(|event, window, cx| { - // Something with focus already claimed this keystroke. - if event.action.is_some() || runtime::keybindings_active() { - return; - } - let keystroke = &event.keystroke; - if keystroke.key != "escape" || keystroke.modifiers.modified() { - return; - } - if gpui_selectable_text::registry::clear_active_selection(window, cx) { - return; - } - // Closing a tray popup with Escape is the convention on every - // desktop; the poll loop does the actual teardown because it - // owns the window handle. - runtime::request_dismiss(); - }) - .detach(); - - // GPUI forces NSApplicationActivationPolicyRegular in - // did_finish_launching; reapply the user's preference here so it - // sticks. `runtime::set_from_config` (called at startup before - // .run) only fires once, *before* GPUI launches — without this - // second push, the user's Accessory choice would be overwritten - // by the time we hit the run closure on macOS. - platform::apply_app_switcher_policy(runtime::show_in_app_switcher()); - - // Hold the handle in the move-closure so it isn't dropped. - #[cfg(not(target_os = "linux"))] - let _keepalive = open_keepalive_window(cx); - // No handle to hold on Linux — nothing is opened; the loop is - // kept alive by the opt-out instead of by a window. - #[cfg(target_os = "linux")] - cx.set_quit_on_last_window_closed(false); - - let config = config.clone(); - let config_path = config_path.clone(); - - cx.spawn(async move |cx| { - // Owned here so the icon lives exactly as long as the loop - // that drives it — and so the loop can push status updates - // into it. `TrayHandle` is `!Send` on macOS / Windows (it - // wraps an AppKit / Win32 object); GPUI's foreground executor - // has no `Send` bound, which is what makes this legal. - let mut tray = tray; - - // The currently-open window, if any. We toggle on each - // "Show Aura" click: open if closed, close if open. - let mut current: Option> = None; - - // macOS: NSEvent global monitor flag. Accessory apps can't - // reliably set [NSApp mainWindow], which kills cx.active_window - // detection; the global monitor is the working alternative. - #[cfg(target_os = "macos")] - let outside_clicked = Arc::new(AtomicBool::new(false)); - #[cfg(target_os = "macos")] - let mut click_monitor: Option = None; - - // Grace-period counter: skip focus-loss checks for this many - // poll intervals after opening the modal so the platform - // can finish delivering focus / setting up the monitor before - // we start watching for losses. - let mut just_opened: u8 = 0; - - // When the tray never installed, the user has no way to ask - // for the window — so ask on their behalf, once. - if tray_missing { - current = toggle(cx, None, config.clone(), config_path.clone(), None).await; - if current.is_some() { - just_opened = 4; - } - } - - loop { - // Poll: ksni / tray-icon both expose blocking - // crossbeam channels under the hood, so we drain - // them between short sleeps. - cx.background_executor().timer(MENU_POLL_INTERVAL).await; - - // Apply any indicator state queued since the last tick. - // Must happen on this thread: AppKit refuses NSStatusItem - // mutation from anywhere but the main thread. - if let Some(tray) = tray.as_mut() { - tray.apply_pending_status(); - } - - // Write back the height the content settled at, so the next - // session's first open is placed correctly too (see - // `runtime::seed_modal_height`). Change-gated, and only real - // content is ever recorded (the auto-fit skips placeholder - // measurements), so this is one small write per open at most — - // not one per resize, and nothing at all while the modal sits - // open. Deliberately not deferred to the modal closing: a - // session that ends with the window still up would save - // nothing. Read-modify-write, so a profile the user picked in - // the modal isn't clobbered. - if let Some(height) = runtime::modal_height_to_persist(persisted_modal_height) { - let mut state = AppState::load().unwrap_or_default(); - state.modal_height = Some(height); - if let Err(e) = state.save() { - eprintln!("aura: could not save the modal height: {e}"); - } - // Either way, stop trying: a disk that refused once will - // refuse every 150ms, and the value still serves this - // session from memory. - persisted_modal_height = Some(height); - } - - // Escape, routed here from the keystroke observer in the - // run closure. Unconditional: unlike focus loss this is an - // explicit "close it" from the user, so neither - // `dismiss_on_focus_loss` nor an in-flight plugin action - // suppresses it. - if runtime::take_dismiss_request() { - #[cfg(target_os = "macos")] - if current.is_some() { - outside_clicked.store(false, Ordering::Relaxed); - if let Some(m) = click_monitor.take() { - platform::remove_click_outside_monitor(m); - } - if !runtime::show_in_app_switcher() { - platform::apply_app_switcher_policy(false); - } - } - if let Some(handle) = current.take() { - let _ = cx.update(|cx| { - let _ = handle.update(cx, |_view, window, _cx| window.remove_window()); - }); - } - } - - if runtime::dismiss_on_focus_loss() - && current.is_some() - && !runtime::plugin_action_inflight() - { - let lost_focus = if just_opened > 0 { - just_opened -= 1; - false - } else { - #[cfg(target_os = "macos")] - { - outside_clicked.load(Ordering::Relaxed) - } - #[cfg(not(target_os = "macos"))] - { - cx.update(|cx| cx.active_window().is_none()) - .unwrap_or(false) - } - }; - - if lost_focus { - #[cfg(target_os = "macos")] - { - outside_clicked.store(false, Ordering::Relaxed); - if let Some(m) = click_monitor.take() { - platform::remove_click_outside_monitor(m); - } - if !runtime::show_in_app_switcher() { - platform::apply_app_switcher_policy(false); - } - } - if let Some(handle) = current.take() { - let _ = cx.update(|cx| { - let _ = - handle.update(cx, |_view, window, _cx| window.remove_window()); - }); - } - } - } - - // A second `aura` launch (e.g. from the app-search - // launcher) lost the single-instance race and pinged us - // instead of silently exiting into nothing. Treat it as - // "show the window" — but don't toggle an already-open - // one closed the way a tray click would; just focus it. - if platform::try_recv_activation() { - if let Some(handle) = ¤t { - let _ = cx.update(|cx| { - let _ = - handle.update(cx, |_view, window, _cx| window.activate_window()); - }); - } else { - let fresh_config = AppConfig::load_with_discovery(&config_path) - .unwrap_or_else(|e| { - eprintln!( - "aura: config reload failed ({e}); using cached snapshot" - ); - config.clone() - }); - runtime::set_from_config(&fresh_config); - - current = toggle(cx, None, fresh_config, config_path.clone(), None).await; - - if current.is_some() { - just_opened = 4; // ~600 ms at 150 ms/poll - #[cfg(target_os = "macos")] - { - outside_clicked.store(false, Ordering::Relaxed); - click_monitor = Some(platform::install_click_outside_monitor( - Arc::clone(&outside_clicked), - )); - } - } - } - } - - while let Some(event) = tray::try_recv_event() { - match event { - TrayEvent::Show { anchor } => { - // Reload AppConfig from disk so edits made - // since the last open (whether via the - // settings panel, an external editor, or - // `aura plugin add`) take effect on this - // open. Fall back to the startup snapshot - // if the reload fails so a transient I/O - // error doesn't break the toggle. - let fresh_config = AppConfig::load_with_discovery(&config_path) - .unwrap_or_else(|e| { - eprintln!( - "aura: config reload failed ({e}); using cached snapshot" - ); - config.clone() - }); - runtime::set_from_config(&fresh_config); - - // If a window was open, tear down its monitor - // and demote the activation policy before the - // toggle (which closes it). - #[cfg(target_os = "macos")] - if current.is_some() { - if let Some(m) = click_monitor.take() { - platform::remove_click_outside_monitor(m); - } - if !runtime::show_in_app_switcher() { - platform::apply_app_switcher_policy(false); - } - } - - current = toggle( - cx, - current.take(), - fresh_config, - config_path.clone(), - anchor, - ) - .await; - - if current.is_some() { - just_opened = 4; // ~600 ms at 150 ms/poll - // macOS: install the click-outside - // monitor for the new window. - #[cfg(target_os = "macos")] - { - outside_clicked.store(false, Ordering::Relaxed); - click_monitor = Some(platform::install_click_outside_monitor( - Arc::clone(&outside_clicked), - )); - } - } - } - TrayEvent::OpenConfig => { - open_config_file(&config_path); - } - TrayEvent::OpenConfigTutorial => { - platform::open_url(app::CONFIG_TUTORIAL_URL); - } - TrayEvent::Quit => { - // Explicit user exit from the right-click - // menu. cx.quit() tears down the GPUI - // event loop; aura exits cleanly so - // systemd's Restart=on-failure won't - // respawn us. - let _ = cx.update(|cx| cx.quit()); - return; - } - } - } - } - }) - .detach(); - }); - - Ok(()) -} - -fn open_config_file(config_path: &std::path::Path) { - if !config_path.exists() { - if let Err(e) = AppConfig::load(config_path) { - eprintln!("aura: could not create config before opening it: {e}"); - return; - } - } - platform::open_path(config_path); -} - -/// Empty root view for the hidden keepalive window. The view is never -/// rendered to a screen — its only job is to satisfy `open_window`'s -/// `V: Render` bound so the window can exist in `state.windows`. -#[cfg(not(target_os = "linux"))] -struct KeepAliveView; - -#[cfg(not(target_os = "linux"))] -impl Render for KeepAliveView { - fn render( - &mut self, - _window: &mut gpui::Window, - _cx: &mut gpui::Context, - ) -> impl IntoElement { - div() - } -} - -/// Open the always-present keepalive window (macOS and Windows only). -/// See the call site for why GPUI needs it. Failures are non-fatal but -/// logged: if the keepalive can't open, aura will still work — just with -/// the old "process exits on last window close" behaviour. -/// -/// Linux doesn't build this at all. It used to, and the surface was a -/// steady source of trouble: GPUI's Wayland backend silently ignores -/// `show: false` (it creates an xdg_toplevel and commits the surface -/// unconditionally), so KWin treated the 1×1 keepalive as a real window -/// — decorating it, listing it, and, because `on_window_should_close` -/// refuses every close request, naming it under "The following -/// applications did not close" on the logout screen, where it blocked -/// shutdown for two minutes. Opting out of GPUI's quit-on-last-window -/// rule instead means there is no surface for the compositor to find. -/// -/// What's left here keeps that history in mind, since the same window is -/// still created on the other two platforms: -/// -/// * open it at `(-9999, -9999)` so even if the platform doesn't clamp -/// it back on-screen, the user can't accidentally focus or click it; -/// * `minimize_window()` it immediately (except on Windows, where -/// SW_MINIMIZE would force a hidden window visible); -/// * give it a distinct `app_id` ("aura-keepalive") so a task manager -/// doesn't group it under the main "Aura" entry; -/// * give it a human-readable title ("Aura"), because window lists and -/// session managers surface titles in places we don't control and an -/// untitled entry tells the user nothing. `WindowOptions::titlebar` -/// can't do this for us — setting it would also change the window's -/// decoration behaviour — so we call `set_window_title` after open, -/// which routes through the per-platform `set_title`; -/// * intercept every platform-level close request with -/// `on_window_should_close` returning `false`, so the tray can't be -/// killed by a stray click. Our own `toggle()` uses -/// `window.remove_window()`, which bypasses this guard (it's an -/// internal close, not a platform request). -#[cfg(not(target_os = "linux"))] -fn open_keepalive_window(cx: &mut gpui::App) -> Option> { - let opts = WindowOptions { - window_bounds: Some(WindowBounds::Windowed(Bounds::new( - gpui::point(px(-9999.), px(-9999.)), - size(px(1.), px(1.)), - ))), - titlebar: None, - focus: false, - show: false, - // On Windows use PopUp (WS_EX_TOOLWINDOW) so the hidden keepalive - // doesn't create a taskbar button. Normal (WS_EX_APPWINDOW) is fine - // on other platforms where the window is never surfaced to the user. - #[cfg(target_os = "windows")] - kind: WindowKind::PopUp, - #[cfg(not(target_os = "windows"))] - kind: WindowKind::Normal, - is_movable: false, - is_resizable: false, - is_minimizable: false, - app_id: Some("aura-keepalive".into()), - ..Default::default() - }; - - match cx.open_window(opts, |_window, cx| cx.new(|_| KeepAliveView)) { - Ok(handle) => { - // Best-effort hide + lock. The `update` returns Err only if - // the window vanished between open and now (shouldn't happen); - // either way we return the handle so the caller's reference - // keeps the keepalive alive. - let _ = handle.update(cx, |_view, window, cx| { - // Name the surface before anything else so compositors and - // session managers have it from the first commit. See the - // doc comment: an untitled keepalive shows up as a nameless - // entry in KDE's "these applications did not close" list. - window.set_window_title("Aura"); - window.on_window_should_close(cx, |_, _| false); - // On Wayland, `show: false` is ignored — the compositor - // creates a surface unconditionally. Minimize immediately so - // KDE places it in the taskbar overflow instead of the - // desktop. On Windows, SW_MINIMIZE on a hidden window would - // force it visible (minimized), so skip this call there. - #[cfg(not(target_os = "windows"))] - window.minimize_window(); - }); - Some(handle) - } - Err(e) => { - eprintln!("warning: failed to open keepalive window: {e}"); - None - } - } -} - -/// If `existing` is alive, close it and return `None`; otherwise open a -/// fresh window and return its handle. Called from both the tray "Show" -/// menu item and a primary-click on the tray icon — each click flips -/// modal visibility. -async fn toggle( - cx: &gpui::AsyncApp, - existing: Option>, - config: AppConfig, - config_path: std::path::PathBuf, - tray_anchor: Option, -) -> Option> { - cx.update(move |cx| toggle_window(cx, existing, config, config_path, tray_anchor)) - .ok() - .flatten() -} - -// Modal geometry (size + anchor math) lives in `placement.rs` — the single -// source of truth shared by `toggle_window` (open) and `app.rs`'s auto-fit -// reposition. See that module for the per-OS anchoring rules. - -fn toggle_window( - cx: &mut gpui::App, - existing: Option>, - config: AppConfig, - config_path: std::path::PathBuf, - tray_anchor: Option, -) -> Option> { - if let Some(handle) = existing { - // `update` returns Err if the window has already been removed; - // either way we're done with this handle. - let _ = handle.update(cx, |_view, window, _cx| window.remove_window()); - return None; - } - - // Reload AppState from disk so the active profile reflects what the user - // picked in any prior modal session. The process keeps running between - // modal open/close cycles (see the keepalive window), so a snapshot - // loaded once at startup would go stale on the first profile change. - let state = AppState::load().unwrap_or_else(|e| { - eprintln!("aura: could not reload state, using defaults: {e}"); - AppState::default() - }); - - // Same reasoning for the keymap: re-read `keybindings.toml` on every open - // so an edit applies without a restart. - let keymap = aura_core::keymap::Keymap::load(&aura_core::keymap::Keymap::default_path()); - keys::install(cx, &keymap, config.keybindings.enabled); - - let anchor = placement::Anchor::from_config(&config.window.anchor); - // `display_id` rides along to `AuraView` so the auto-fit callback caps the - // modal's height against the screen it actually opened on. Reading - // `primary_display()` there instead would measure the wrong taskbar the - // moment the tray lives on a secondary monitor. - // Open at the height the content settled at last time, so the auto-fit - // pass has nothing to correct and the window doesn't visibly jump one - // frame after it appears. `MODAL_H` on the first open of the process. - let open_h = runtime::last_modal_height().unwrap_or(placement::MODAL_H); - let (bounds, display_id) = placement::modal_bounds(cx, tray_anchor, anchor, open_h); - // `window.show_in_app_switcher` controls whether the modal appears in - // the OS's "where are my windows" surfaces — Cmd+Tab + Dock on macOS, - // Alt+Tab + taskbar on Windows, panel + window switcher on Linux. - // - // Linux / Windows: - // - true → WindowKind::Normal (xdg_toplevel / WS_EX_APPWINDOW). - // - false → WindowKind::PopUp (no taskbar entry, WS_EX_TOOLWINDOW). - // - // `WindowKind::PopUp` also strips chrome on every backend: Windows applies - // `WS_EX_TOOLWINDOW` + `WINDOW_STYLE(0x0)` (no caption, no resize frame), - // and X11 sets `_NET_WM_WINDOW_TYPE_NOTIFICATION` which tells the WM to - // drop decorations. So `window_chrome` has to override the kind too — - // otherwise the titlebar/is_resizable we set below are silently ignored. - // Side-effect: enabling chrome also puts the modal in the taskbar / - // alt-tab list, which is consistent with it being a "real" window. - // - // macOS: ALWAYS Normal. GPUI maps WindowKind::PopUp to NSPanel with - // NSWindowStyleMaskNonactivatingPanel, which deliberately prevents the - // window from becoming key. We need the window to be key so - // `cx.active_window()` can track focus (the focus-loss check below - // depends on this). What keeps Aura out of Cmd+Tab on macOS is the - // NSApplicationActivationPolicy — promoted to Regular only while the - // modal is open, demoted back to Accessory on close (see - // `platform::apply_app_switcher_policy` calls). - #[cfg(target_os = "macos")] - let kind = WindowKind::Normal; - #[cfg(not(target_os = "macos"))] - let kind = if config.window.show_in_app_switcher || config.window.chrome { - WindowKind::Normal - } else { - WindowKind::PopUp - }; - // `window.chrome` controls only the native title bar: - // false (default): chromeless tray-popup, fixed width. - // true: native OS chrome (title bar + min/max/close). window_decorations: - // Server asks Wayland compositors to draw SSD. - // Whether the modal auto-fits its content height is a separate axis, - // governed by `window.auto_resize` in app.rs (see on_children_prepainted) — - // independent of chrome, so the auto-fit works in both modes. - // - // window_decorations must be `Some(..)` in both branches, not `None` for - // the chromeless case: GPUI's `request_decorations` defaults a `None` to - // `WindowDecorations::Server` (see gpui `window.rs`). Both Linux backends - // (this branch runs on X11 and Wayland alike — no per-backend split here) - // override `request_decorations`, so that default actively asks for - // server-side chrome: on X11 it writes an explicit `_MOTIF_WM_HINTS` - // "show decorations" hint, on Wayland it requests SSD via the - // xdg-decoration protocol. Some WMs/compositors (KWin observed) honor - // that over the `_NET_WM_WINDOW_TYPE_NOTIFICATION` borderless hint from - // `WindowKind::PopUp`, so the titlebar reappears even though - // `window_chrome` is false. Requesting `Client` explicitly writes the - // "hide decorations" hint/request instead. (macOS and Windows don't - // override `request_decorations` at all — this field is a no-op there; - // chrome is controlled by `titlebar`/`kind` alone on those platforms.) - let (titlebar, is_resizable, window_decorations) = if config.window.chrome { - ( - Some(TitlebarOptions::default()), - true, - Some(WindowDecorations::Server), - ) - } else { - (None, false, Some(WindowDecorations::Client)) - }; - let opts = WindowOptions { - window_bounds: Some(WindowBounds::Windowed(bounds)), - titlebar, - is_resizable, - window_decorations, - // Set a stable Wayland app_id / X11 WM_CLASS so KWin window rules - // (see README "Modal placement on Wayland") can match this surface. - // Without this, KDE shows "Window class not available" when the - // user tries to Detect Window Properties on the modal. - app_id: Some("aura".into()), - // Target the display the tray icon lives on. Not optional on Windows: - // `open_window` validates the requested bounds against this display - // (the primary one when unset) and silently substitutes its centred - // default when they fall outside — which is what any secondary-monitor - // origin looks like. macOS also resolves the origin relative to this - // screen's frame; see `placement::to_window_origin`. - display_id, - kind, - // On macOS, GPUI creates the window with NSTitled|NSFullSizeContentView - // even when titlebar:None. The native title-bar drag zone covers our - // header; with is_movable:true the OS handles drags there, which can - // route mouse events outside GPUI's queue. Disabling movability tells - // AppKit to forward those clicks to the content view instead, so the - // header buttons behave like normal content. - is_movable: false, - ..Default::default() - }; - - // Cloak (Windows) hides the first-frame flash that only happens when the - // auto-fit step shrinks the window from its open-time MODAL_H to the - // content height. So it must track `auto_resize` (the same flag that gates - // the auto-fit callback / uncloak in app.rs) — NOT `window_chrome`. Tying - // it to chrome would leave a chromeless + fixed-size window (auto_resize = - // false) cloaked forever, since no uncloak step ever runs. - #[cfg(target_os = "windows")] - let cloak = config.window.auto_resize(); - - match cx.open_window(opts, |window, cx| { - cx.new(|cx| { - let view = AuraView::new( - config, - config_path, - state, - keymap, - display_id, - tray_anchor, - cx, - ); - // Key bindings dispatch from the focused element, and nothing - // else in the modal takes focus, so the root holds it for the - // window's lifetime (a click anywhere re-focuses it). - window.focus(&view.focus_handle); - view - }) - }) { - Ok(handle) => { - // On macOS, if we are running as NSApplicationActivationPolicyAccessory - // (background-only mode), the OS won't grant foreground focus to the - // window. Promote to Regular while the modal is open so activate() - // and active_window() work normally. We demote back to Accessory - // when the window is closed (see the focus-loss / remove_window paths). - #[cfg(target_os = "macos")] - if !runtime::show_in_app_switcher() { - platform::apply_app_switcher_policy(true); - } - - // Name the surface. Every window list we don't control reads - // this — alt-tab, task managers, session managers — and an - // untitled entry tells the user nothing about which app it - // belongs to. `titlebar` can't carry the name for us: the - // Wayland backend ignores `TitlebarOptions::title` (only - // `set_title` reaches `xdg_toplevel`), and with - // `window_chrome = false` there are no `TitlebarOptions` at all. - // `set_window_title` routes through the per-platform `set_title` - // in both configurations. With chrome on, this is also what the - // decorated title bar now shows. - let _ = handle.update(cx, |_, window, _| window.set_window_title("Aura")); - - // Re-assert the origin we asked `open_window` for. A window - // manager is free to ignore the position a client requests at map - // time unless the WM_NORMAL_HINTS carry `PPosition`, which GPUI - // does not set — KWin applies its own placement policy to the - // first window a process opens and centres it. The auto-fit pass - // corrects that a frame or two later, which is exactly the visible - // jump this call removes. Absolute coordinates: on X11 - // `placement::to_window_origin` is the identity, so `bounds` is - // already in the space `set_window_origin` wants. - #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] - { - let _ = handle.update(cx, |_, window, cx| { - // No frame shift: the window manager has not framed the - // window yet, so there is nothing to measure. The auto-fit - // pass corrects for it once `_NET_FRAME_EXTENTS` appears. - platform::set_window_origin(window, cx, bounds.origin, bounds.size, 0.0) - }); - } - - cx.activate(true); - - #[cfg(target_os = "windows")] - { - // Cloak immediately so the first frame (at the open height, - // before on_children_prepainted fits it to the content) is - // invisible. AuraView's on_children_prepainted uncloak fires - // on the second frame after the resize, showing the window at - // the correct size. - // - // Skipped when `auto_resize` is off (`cloak` is false): there - // is no auto-shrink step then, so cloaking would leave the - // window invisible forever. - let _ = handle.update(cx, |_, window, _| { - if cloak { - win32_set_cloak(window, true); - } - window.activate_window(); - }); - } - #[cfg(target_os = "macos")] - { - let _ = handle.update(cx, |_, window, _| { - // Raise above other apps' windows. GPUI's Normal kind sets - // NSNormalWindowLevel, so without this the modal opens - // behind whatever app the user was focused on. - platform::raise_window_to_floating(window); - window.activate_window(); - }); - } - #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] - { - let _ = handle.update(cx, |_, window, _| window.activate_window()); - } - - Some(handle) - } - Err(e) => { - eprintln!("aura: failed to open window: {e}"); - None - } + return aura_cli::dispatch(command); } + aura_ui::run(VERSION) } diff --git a/docs/cli.md b/docs/cli.md index 177b2c2..19cf698 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -4,7 +4,7 @@ status: current version: 0.1.0 last_updated: 2026-09-24 last_verified: 2026-09-24 -source_refs: ["crates/aura/src/cli/"] +source_refs: ["crates/aura-cli/src/"] owner: "@rfluid" tags: [cli, docs] --- diff --git a/docs/configuration.md b/docs/configuration.md index e1efeca..20f1371 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -10,10 +10,10 @@ source_refs: - crates/aura-core/src/keymap.rs - crates/aura-core/src/state.rs - crates/aura-core/src/sponsor.rs - - crates/aura/src/cli/config.rs - - crates/aura/src/runtime.rs - - crates/aura/src/main.rs - - crates/aura/src/app.rs + - crates/aura-cli/src/config.rs + - crates/aura-ui/src/runtime.rs + - crates/aura-ui/src/lib.rs + - crates/aura-ui/src/app.rs owner: "@rfluid" tags: [configuration, docs] --- @@ -123,7 +123,7 @@ Config flows through five layers, top (authoring) to bottom (consumption): ones without disturbing existing edits. See [Migrating an older config](#migrating-an-older-config). -5. **Runtime mirror** — `crates/aura/src/runtime.rs`. The tray poll loop in +5. **Runtime mirror** — `crates/aura-ui/src/runtime.rs`. The tray poll loop in `main.rs` and the modal's async refresh task in `app.rs` each reload the config independently. To stop them drifting, a handful of `[window]` fields are mirrored into process atomics via `runtime::set_from_config`, and any diff --git a/docs/engineering/ui-selectable-text.md b/docs/engineering/ui-selectable-text.md index 7761505..75fad04 100644 --- a/docs/engineering/ui-selectable-text.md +++ b/docs/engineering/ui-selectable-text.md @@ -4,9 +4,9 @@ status: active version: 0.1.0 last_updated: 2026-08-23 source_refs: - - crates/aura/Cargo.toml - - crates/aura/src/app.rs - - crates/aura/src/main.rs + - crates/aura-ui/Cargo.toml + - crates/aura-ui/src/app.rs + - crates/aura-ui/src/lib.rs - scripts/check-selectable-text.sh owner: "@rfluid" tags: [ui, selection, clipboard, gpui] @@ -32,7 +32,7 @@ hit-testing, gesture, and clipboard code in the application. ## The rule **This is the only sanctioned way to render copyable read-only text.** Do not -construct `gpui::StyledText` / `InteractiveText` anywhere in `crates/aura/src` +construct `gpui::StyledText` / `InteractiveText` anywhere in `crates/aura-ui/src` and do not repurpose a text input for display. `scripts/check-selectable-text.sh` grep-enforces this and runs in diff --git a/docs/keybindings.md b/docs/keybindings.md index 70ce2e1..a8e36ba 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -7,10 +7,10 @@ last_verified: 2026-09-23 source_refs: - crates/aura-core/src/keymap/mod.rs - crates/aura-core/src/keymap/file.rs - - crates/aura/src/keys.rs - - crates/aura/src/app.rs - - crates/aura/src/main.rs - - crates/aura/src/cli/keys.rs + - crates/aura-ui/src/keys.rs + - crates/aura-ui/src/app.rs + - crates/aura-ui/src/lib.rs + - crates/aura-cli/src/keys.rs owner: "@rfluid" tags: [keybindings, configuration, docs] --- diff --git a/docs/platform-tray-icon.md b/docs/platform-tray-icon.md index 7e69edf..ac1334a 100644 --- a/docs/platform-tray-icon.md +++ b/docs/platform-tray-icon.md @@ -5,12 +5,12 @@ version: 0.1.0 last_updated: 2026-09-13 last_verified: 2026-09-13 source_refs: - - crates/aura/src/tray.rs - - crates/aura/src/tray_status.rs - - crates/aura/src/placement.rs - - crates/aura/src/main.rs - - crates/aura/src/platform.rs - - crates/aura/src/work_area.rs + - crates/aura-ui/src/tray.rs + - crates/aura-ui/src/tray_status.rs + - crates/aura-ui/src/placement.rs + - crates/aura-ui/src/lib.rs + - crates/aura-ui/src/platform.rs + - crates/aura-ui/src/work_area.rs owner: "@rfluid" tags: [architecture, platform, docs] --- diff --git a/docs/troubleshooting/modal-stretches-on-resize-kde.md b/docs/troubleshooting/modal-stretches-on-resize-kde.md index eb31b4a..6de6b9a 100644 --- a/docs/troubleshooting/modal-stretches-on-resize-kde.md +++ b/docs/troubleshooting/modal-stretches-on-resize-kde.md @@ -5,9 +5,9 @@ version: 0.1.0 last_updated: 2026-05-29 last_verified: 2026-05-29 source_refs: - - crates/aura/src/app.rs - - crates/aura/src/platform.rs - - crates/aura/src/placement.rs + - crates/aura-ui/src/app.rs + - crates/aura-ui/src/platform.rs + - crates/aura-ui/src/placement.rs - vendor/gpui/src/platform/linux/x11/window.rs owner: "@rfluid" tags: [troubleshooting, linux, kde, docs] diff --git a/scripts/check-selectable-text.sh b/scripts/check-selectable-text.sh index 26e0b12..89fcff5 100755 --- a/scripts/check-selectable-text.sh +++ b/scripts/check-selectable-text.sh @@ -3,7 +3,7 @@ # # All copyable read-only text in the modal must go through the # `gpui-selectable-text` crate. Constructing `gpui::StyledText` / -# `InteractiveText` in `crates/aura/src` bypasses its selection behavior, so +# `InteractiveText` in `crates/aura-ui/src` bypasses its selection behavior, so # it's a build failure. # # See docs/engineering/ui-selectable-text.md. Wired into scripts/pre-pr.sh. @@ -12,7 +12,7 @@ set -uo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$REPO_ROOT" -SRC="crates/aura/src" +SRC="crates/aura-ui/src" # Match `StyledText` or `InteractiveText` as whole identifiers, in any .rs file # under $SRC. Comments/strings are close enough — application code should not