Skip to content

cli: Carry nsh.json window into Linux and Windows packages - #1419

Merged
madcodelife merged 1 commit into
longbridge:mainfrom
duo:package-window-options
Nov 10, 2026
Merged

madcodelife merged 1 commit into
longbridge:mainfrom
duo:package-window-options

Conversation

@duo

@duo duo commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Description

The release runners read the main window's options from the package's app.json before the app runs
(Bundle::read_trusted), and iOS, Android and macOS packages write nsh.json's window there (app_json). The Linux
and 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 run applies: title, defaultSize,
minSize, titleBar, closeOnPress, restoreBounds and the rest. A tray app whose window sets closeOnPress: false
to keep running quit when its window was closed.

Both packagers now write their app.json through one function, which adds window beside the name, id, version and
appearance; Linux keeps writing its schemes. The window written is the one nsh.json's reading checked, as for the
other packages.

Public API

  • nsh/app.json of Linux packages and nsh\app.json of Windows packages: optional "window", nsh.json's, which
    nsh-linux-app and nsh-windows-app read.

How to Test

  • nsh-cli: desktop_packages_carry_the_main_windows_options: the app.json written for an app with and without
    window, and the runner's MainWindowOptions::from_json reading the window written.
  • Kubuntu 26.04 (GTK 4 on X11): 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) wrote
    the example's window into nsh/app.json. The packaged app started without a window (defaultVisible: false); a
    tray 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.
  • Windows 11 24H2: nsh package examples/tray --target windows (with desktop: Let nsh.json start the main window hidden, as tray apps do #1420) wrote the same window into
    nsh\app.json. With real clicks on its tray icon: the packaged app started with its window hidden; a click showed
    it 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

  • I have read the CONTRIBUTING document and followed the guidelines.
  • Reviewed the changes in this PR and confirmed AI generated code (if any) is accurate.
  • Passed cargo test -p nsh-cli (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 core: Reuse retained rows when their order is unchanged #1407) and clippy (nsh-cli); no platform code changed.
  • Changes to what apps use keep what released versions shipped: a package gains a key its runner already reads; no
    compat case, as nsh-compat builds its apps with nsh build and runs no package.
  • Ran the related examples/gallery demos on iOS and Android (no mobile package changes).

@huacnlee huacnlee left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 adds schemes on top (package.rs:976). Nothing is dropped from the previous output.
  • Effect: on this head, crates/nsh-linux-app/src/lib.rs and crates/nsh-windows-app/src/lib.rs read only name/id/appearance/schemes from app.json, so window stays 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 schemes from app.json (crates/nsh-windows-app/src/lib.rs), but windows.rs never 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:13 runs 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).

🤖 Noop (reviewer) · AI agent acting for @huacnlee

@duo
duo force-pushed the package-window-options branch 2 times, most recently from bd7966f to f55950d Compare November 10, 2026 00:08
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.
@duo
duo force-pushed the package-window-options branch from f55950d to 650677c Compare November 10, 2026 00:44
@huacnlee
huacnlee enabled auto-merge (squash) November 10, 2026 01:03
@madcodelife
madcodelife disabled auto-merge November 10, 2026 02:19
@madcodelife
madcodelife merged commit 45b9c04 into longbridge:main Nov 10, 2026
8 of 11 checks passed
madcodelife pushed a commit that referenced this pull request Nov 10, 2026
…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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants