Repository navigation
cli: Carry nsh.json window into Linux and Windows packages - #1419
Conversation
huacnlee
left a comment
There was a problem hiding this comment.
✅ LGTM: Linux/Windows packages now carry nsh.json window, matching macOS
API verdict: no public API or nsh.json schema change. desktop_app_json is pub(crate), and window is the existing nsh.json key that the macOS package already writes. This only adds the key to the Linux/Windows app.json and removes the duplicated JSON building. The naming is consistent.
Findings:
crates/nsh-cli/src/package.rs:253: the helper is correct, and Linux still addsschemeson top (package.rs:976). Nothing is dropped from the previous output.- Effect: on this head,
crates/nsh-linux-app/src/lib.rsandcrates/nsh-windows-app/src/lib.rsread onlyname/id/appearance/schemesfromapp.json, sowindowstays inert until #1420 makes the runners read it (Bundle::read_trusted→MainWindowOptions). That's fine as a separate step, but #1419 should land before or together with #1420. - Side note (pre-existing, not from this PR): the Windows runner reads
schemesfromapp.json(crates/nsh-windows-app/src/lib.rs), butwindows.rsnever writes it, unlike Linux. Worth a follow-up if Windows URL schemes are expected to work from packages. - Nit: the reflowed line in
docs/dev/embedding.md:13runs past the file's wrap width.
Title and description match the diff, and the docs (en + zh-CN) are updated.
Ran: cargo test -p nsh-cli --bin nsh desktop_packages (pass), cargo clippy -p nsh-cli --all-targets (clean).
CI: Compat/Linux check/web fail with the core/touch.rs:392 panic ("the manual clock cannot move"), which is unrelated to this diff. Compat also fails on main (fc8058e).
bd7966f to
f55950d
Compare
The runners read the main window's options from the package's app.json before the app runs (`Bundle::read_trusted`), and the iOS, Android and macOS packages write nsh.json `window` there. The Linux and Windows packages wrote only the name, id, version and appearance, so a packaged app on those two ignored its window's title, size, policies and the rest, which `nsh run` applies: a window whose `closeOnPress: false` keeps a tray app running closed the app, and its `defaultSize` gave way to the runner's. Both write their app.json through one function now, which carries the options as the other packages do.
f55950d to
650677c
Compare
…1420) ## Description A tray app starts with only its tray icon, and the user opens its window from there. Every desktop runner showed the main window as it launched the app, and hiding it in the app's first render comes too late: the window has been on screen, and on macOS and Windows it took the keyboard from the app the user was in. This adds `nsh.json`'s `window.defaultVisible`. With `false`, the runner makes the main window and runs the app in it, unseen and unfocused, until the app shows it (`show()`, `focus()`), a link of one of its URL schemes opens it, or the user opens it again on macOS (its Dock icon, the Finder). Only the main window takes the key: the app opens its other windows when it wants them. Every path that starts a standalone desktop app reads it before the app's code runs: - The release runners read it from the package's `app.json`, and a Rust app's `main` from its `MainWindowOptions`. macOS packages carry `nsh.json`'s `window` already. Linux and Windows packages carry none of it yet, so no window option reaches a packaged app there; #1419 fixes that separately. - The development hosts (`nsh run`, `nsh test --target macos|windows|linux|gpui`) hold their first show until the first app arrives. Then they show the window unless its `defaultVisible` says hidden, which they log (`the main window starts hidden (nsh.json window.defaultVisible: false)`); GPUI shows a window as it opens on Linux, so there the GPUI host's window shows. They open the window as they do for every app, as before: the Windows host places it from a session's first bundle as a runner does (its size, place and the mode it opens in), the macOS, Linux and GPUI hosts at their own size in the normal mode. An error shows the window with its overlay while it is hidden, the app's own hiding included, so a tray app whose startup throws shows why; a reload leaves the window as it is. - The headless driver (`nsh test`) starts the main window hidden: its views are off screen and take no input until the app shows it. The bundler reads `window` beside the entry (`Bundle::main_window`), where `app.json`, `nsh run` and `nsh test`'s real-window hosts read it, though since #1409 it reads the rest of `nsh.json` from the app's directory: an app a test opens below its app's root (`tests/MenuApp.tsx`) starts alike headless and in a real window, and not hidden because its app is. Where a runner places the window, as the Windows development host does too, the first show is then the window's launch: - macOS: the mode it opens in (maximized, full screen) waits on the surface for the first `show()` / `focus()`, a link (`application:openURLs:`) or the Dock's reopen (`window::main_shown`). - Windows: the first `show()` / `focus()`, or a link from a later launch, shows it as `AppView::show` does (`app::show_main`): cloaked until its background and a frame are drawn, so no white frame, then in the mode it opens in. - Linux: GTK sizes a window only as it shows it, so the app is laid out at the window's default size while it is hidden (`window::launch_hidden`); GTK keeps the mode asked for until the first map. - GPUI: the window opens with GPUI's `show` and `focus` off and the app is not activated; the main window's state reads its visibility from the native window. GPUI applies a window's mode as it opens it, which would show it (full screen on macOS), so a hidden window opens in the normal mode and takes the mode it opens in at its first `show()` / `focus()` (`Chrome::opening_mode`), unless the app sets a mode or placement of its own first. GPUI shows a window as it opens on Linux, so there the main window shows. On macOS a panel or dialog of the hidden main window shows it too (AppKit orders a child window's parent in with it). State reports the feature relies on were wrong for a window that hides and shows again; this fixes them: - Windows: a window hidden while it had the keyboard stays the foreground window (`GetForegroundWindow`) until another window takes it, so its state read it focused. It now reads focused only while shown. - Linux: a window maps, and may take the focus, a frame before GTK gives it a size, and showing it again at the size it had reallocates nothing, so a main window shown again kept reporting itself hidden. Its state is now reported once it has a size, at every map (`window::report_once_sized`). - Linux: GTK drops a hidden window's size, and on X11 the window manager drops its maximized state (GTK then shows it again unmaximized), so GTK has no state to read for a hidden main window. It now reads hidden where it was left: its state when GTK last sized it (`MainWindow::seen`, kept across a development host's reloads), until the app sets its mode or size while it is hidden, which GTK holds for its next show (`window::hidden`). With `restoreBounds`, a window maximized, hidden and quit opens maximized again. With `restoreBounds`, a main window hidden since launch keeps the last run's placement until it first shows (`Core::keep_window_placement`): until then its reports are not where the user left it (it has yet to take the mode it opens in, and GTK has yet to size it), so a tray app quit without showing its window opens where the user left it the next time. Once it has shown, every report is kept as before, hidden ones too: a window hidden right after a resize reports its new size only then. A development host's reload starts a new engine, which counts such a window as not shown yet until it shows again, so a placement the app sets while it stays hidden after a reload is not kept. `examples/tray` is a tray app built on it: a focus timer whose tray icon shows the time left. It starts with only the icon; a click shows its window, the close button hides it (`closeOnPress: false`, `hide()` on `closePress`), and the tray's menu shows or hides the window, starts or pauses the timer and quits. Third of the menu bar app series, after #1355 (the tray click's bounds) and #1390 (macOS's activation policy). ## Public API - `nsh.json` `window.defaultVisible?: boolean` (default `true`). Whether the main window shows when the app starts; `false` starts a tray app with only its icon. The main window's only, read at launch. - `nsh_core::desktop::MainWindowOptions::default_visible: Option<bool>`. The same for Rust apps (`None`: shown). The rest is plumbing between the bundler, the CLI and the runners: public for them and `#[doc(hidden)]`, so the API docs show the two items above only. - `nsh_core::desktop::MainWindowOptions::visible_at_launch(&self) -> bool`. Whether a runner shows the main window as it launches the app. - `nsh_bundler::Bundle::main_window: Option<String>`. The entry's `nsh.json` `window` as JSON, which `nsh test` gives its runs as a package's `app.json` gives a runner. - `nsh_bundler::main_window(dir: &Path) -> Result<Option<String>, BundleError>`. Reads that from the `nsh.json` in `dir` (the bundler passes the entry's directory): `None` without one, or when it is not valid. - `nsh_testing::Reply::Js::main_window: Option<String>` (`#[serde(default)]`). The same `window`, which `nsh test` hands a Rust app's headless runner with its bytecode. ## Breaking Changes Two public Rust types gain a field (`#[doc(hidden)]`, as above), so code that builds them field by field names it. Neither is what apps use, and the runner protocol's JSON takes `main_window` as optional (`#[serde(default)]`), so a line without it still reads. ```diff pub struct Bundle { // ... pub url_schemes: Vec<String>, + #[doc(hidden)] + pub main_window: Option<String>, pub elapsed_ms: f64, } pub enum Reply { Js { bytecode: PathBuf, source_map: Option<PathBuf>, capabilities: Option<String>, url_schemes: Vec<String>, + #[doc(hidden)] + main_window: Option<String>, }, // ... } ``` ## How to Test Tests: - `nsh-core`: `the_main_window_may_start_hidden`: shown by default and with `true`, hidden with `false`, a value of another form refused. `hidden_main_windows_keep_the_placement_the_user_left`: a main window's reports from before it first shows leave the last run's placement; once it has shown, its reports are kept, hidden ones too. - `nsh-bundler`: `an_entry_takes_the_window_beside_it`: the app's entry carries its `window`, an entry below its root (`tests/MenuApp.tsx`) none. - `nsh-platform-linux`: `a_hidden_main_window_reads_where_it_was_left`: a main window not shown yet reads as GTK holds it, one hidden after it has shown where it was left, also after a reload, and as GTK holds it once the app sets its mode. - `nsh-testing`: the `hidden-main` app, with its own `nsh.json`: the main window starts hidden and unfocused with its views off screen, and the app shows, hides and focuses it. The test fails with `defaultVisible: true`. Its check that a hidden window's views are off screen is the headless model's: a real platform's test agent finds a hidden window's views too, so the runs in real windows below take its other steps. `layout_nodes_of_a_hidden_main_window_hold_its_app_windows_in_the_root`: a hidden main window's views are left out of `Screen::layout_nodes` and a window of the app's own they hold is listed in the root, so every node's `parent` stays in the list, as #1281 has it. - `examples/tray`: the window is made, hidden. - No compat case: nsh-compat runs its apps on an engine without windows and reads only `id` from their `app.json`, so its runs cannot observe a key read at launch (as with `macos.activationPolicy`, #1390). The tests above pin its reading and behavior. On real windows, the tray runs with this change and #1419 on main as of 826654b. The rebases since changed only where the bundler reads `window` (onto #1409: it stays beside the entry, the tray example's entry included) and the parents of the headless model's layout nodes (onto d3a8592, for #1281), after which the Windows and Linux crates' tests and clippy and the Linux real-window steps ran again; the latest (onto 208fd83) left this change's code as it was (#1443 added a test where this one adds its own, and both stay): - macOS 27.2 (AppKit and GPUI): the `hidden-main` steps in real windows (`--target macos`, `--target gpui`). The tray example under `nsh run --target macos`: no window at launch and the keyboard stayed with the app in front; a tray press (Accessibility) showed the window and made the app active, its close button hid it, the Dock's reopen showed it, and Cmd+Q quit. Packaged: the same; opened with `open -g`, the app stayed inactive while its window was hidden. A runner (`nsh-macos-host --bundle`, which reads `app.json` as a package does) of an app with `defaultMode: "maximized"` that shows its window after 2.5 s: hidden at its normal size until then, then filling the display's visible frame; packaged, the same app opened maximized at its first show and again after closing and reopening. On GPUI, that app: hidden at its normal size and the app in front kept the keyboard until then, then maximized; with `setMode("normal")` before `show()`, at its normal size. Under `nsh run` (AppKit and GPUI), an app that starts hidden and throws 4 s after it started: its window stayed off screen until the error, then showed with it. - Windows 11 24H2 (Win32): the `hidden-main` steps in real windows. The tray example under `nsh test --target windows` and packaged (its `app.json` with `window`, as #1419 writes it), with real clicks on its tray icon: hidden at launch; a click showed it with the keyboard focus, its content drawn within about 50 ms of the click (in dark mode its share of bright pixels went straight to its content's: no white frame); its close button hid it; a second click showed it focused again; packaged, Quit in the tray menu ended the app. A runner (`nsh-windows-host --bundle`) of the `defaultMode: "maximized"` app above: hidden and normal until its `show()`, then maximized (`IsZoomed`, over the display's work area; the app read `mode: "maximized"`). Under `nsh run`, an app that starts hidden and throws 4 s after it started (no input sent): its window stayed hidden until the error, then showed with it and took the foreground. - Kubuntu 26.04 (GTK 4 on X11, an Xwayland server without a window manager): the `hidden-main` steps in real windows. The tray example under `nsh test --target linux`, with tray clicks over D-Bus (`StatusNotifierItem.Activate`): hidden at launch, shown and focused, hidden by its close button, shown again; the tray menu's label followed the window (Show Focus, Hide Focus). Under `nsh run` and packaged (`app.json` as #1419 writes it), Quit in the tray menu ended the app. Under `nsh run`, an app that starts hidden and throws 4 s after it started: its window stayed unmapped until the error, then showed with it. A window started hidden, shown, resized and hidden again reports its new size hidden. Under KWin, on Wayland and on X11 (the Plasma session's Xwayland): a main window maximized and hidden reads hidden and maximized; set to normal, or maximized, while hidden it reads so and shows again so. A runner (`nsh-linux-host --bundle`) of the `defaultMode: "maximized"` app above, under KWin on X11 and on Wayland: hidden at its normal size (400 × 300) until its `show()`, then maximized over the work area (the app read `mode: "maximized"` and 1920 × 1118). Not run: GPUI on Windows and Linux; on Linux under a Wayland compositor, the tray example and the focus (there a hidden window's state and the runner's first show only, while the Plasma session was locked and gave no window the focus). ## Checklist - [x] I have read the [CONTRIBUTING](../CONTRIBUTING.md) document and followed the guidelines. - [x] Reviewed the changes in this PR and confirmed AI generated code (if any) is accurate. - [x] Passed `cargo test --workspace` (all but `runtime::gpui_uses_its_workspaces_cargo_output_instead_of_a_stale_cli_sibling`, which fails on main on this Mac too: its `$TMPDIR` is a symlink), `cargo +nightly fmt --all` (it changes only `crates/nsh-core/src/core/list.rs`, as on main since #1407) and clippy (the workspace, the macOS crates with and without their `dev` feature, as the Rust application workflow builds them, GPUI's workspace with its tests), all on this commit (on 208fd83). Before the latest rebase, on d3a8592: Catalyst's clippy; on Windows the Windows crates' tests (all but `transparent_window_is_borderless_and_composed`, which needs the desktop session) and clippy; on Linux the Linux crates' tests (all but `text::fallback_tests::fallback_fonts_keep_the_line_height`, a font fallback test this change does not touch, which fails the same way on the base commit there) and clippy. - [ ] Changes to what apps use keep what released versions shipped and come with a case in `tests/compat/` (`cargo xtask compat`): no case, see How to Test. CI's Compat check passed on this change merged with main as of 1894697, before the latest rebase. Before #1430 and nsh-compat#43 fixed main's two failures, `cargo xtask compat` ran here on d3a8592 app by app (`NSH_COMPAT_APP`, each in an empty build directory): every app passed its script and its claims but `base` (#1415's panic in `Core::cancel_press`) and `browser` (step 9), as on main then. The `desktop` case domain and `examples/desktop` share a name, which builds them into one directory at once, so they ran under different names (the domain renamed for the run, not committed). - [x] Ran the tray example on macOS, Windows and Linux (no `examples/gallery` demo starts hidden: it is the app's, at launch).
Description
The release runners read the main window's options from the package's
app.jsonbefore the app runs(
Bundle::read_trusted), and iOS, Android and macOS packages writensh.json'swindowthere (app_json). The Linuxand Windows packagers wrote only the name, id, version and appearance (and Linux the URL schemes), so a packaged app on
those two opened its main window with none of its options, all of which
nsh runapplies:title,defaultSize,minSize,titleBar,closeOnPress,restoreBoundsand the rest. A tray app whose window setscloseOnPress: falseto keep running quit when its window was closed.
Both packagers now write their
app.jsonthrough one function, which addswindowbeside the name, id, version andappearance; Linux keeps writing its
schemes. Thewindowwritten is the onensh.json's reading checked, as for theother packages.
Public API
nsh/app.jsonof Linux packages andnsh\app.jsonof Windows packages: optional"window",nsh.json's, whichnsh-linux-appandnsh-windows-appread.How to Test
nsh-cli:desktop_packages_carry_the_main_windows_options: theapp.jsonwritten for an app with and withoutwindow, and the runner'sMainWindowOptions::from_jsonreading thewindowwritten.nsh package examples/tray --target linux(the tray example of desktop: Let nsh.json start the main window hidden, as tray apps do #1420, with it) wrotethe example's
windowintonsh/app.json. The packaged app started without a window (defaultVisible: false); atray click showed its 300 × 280 window with the focus; its close button hid it and the app kept running
(
closeOnPress: false); a second click showed it again; Quit in the tray menu ended it.nsh package examples/tray --target windows(with desktop: Let nsh.json start the main window hidden, as tray apps do #1420) wrote the samewindowintonsh\app.json. With real clicks on its tray icon: the packaged app started with its window hidden; a click showedit in front with the keyboard; its close button hid it and the app kept running; a second click showed it again;
Quit in the tray menu ended it.
Checklist
cargo test -p nsh-cli(all butruntime::gpui_uses_its_workspaces_cargo_output_instead_of_a_stale_cli_sibling, which fails on main on this Mac too: its$TMPDIRis a symlink),cargo +nightly fmt --all(it changes onlycrates/nsh-core/src/core/list.rs, as on main since core: Reuse retained rows when their order is unchanged #1407) and clippy (nsh-cli); no platform code changed.compat case, as nsh-compat builds its apps with
nsh buildand runs no package.examples/gallerydemos on iOS and Android (no mobile package changes).