summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/WINDOWS-PORT.md33
-rw-r--r--packages/meshbay-client/src/main.js30
-rw-r--r--packages/meshbay-client/src/preload.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js21
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js16
-rw-r--r--packages/meshbay-hub/tests/test_desktop_shell.py78
6 files changed, 158 insertions, 25 deletions
diff --git a/docs/WINDOWS-PORT.md b/docs/WINDOWS-PORT.md
index e33b6f5..e1acc66 100644
--- a/docs/WINDOWS-PORT.md
+++ b/docs/WINDOWS-PORT.md
@@ -580,6 +580,39 @@ Linux-or-win32: the `tray` capability in `preload.js`, and the
`files`, no separate Windows list). `test_desktop_shell.py` now pins that
both gates name the same platform set — nothing did before.
+### 5.11 The tray is created at launch (2026-09-08)
+
+**Scope:** `src/main.js`, `src/preload.js`, `static/platform.js`,
+`static/app.js`, `test_desktop_shell.py`
+
+`ensureTray()` was reachable only from the `window:minimize-to-tray` handler,
+so the indicator did not exist until you had already hidden the window into
+it. That is backwards on both desktops — most of what a tray is for is finding
+an application that is *not* in front of you — and on Windows it read as the
+app having no tray presence at all.
+
+It is now created during `app.whenReady()`, after `registerBridge()` and
+before `createWindow()`. The order matters: `buildTrayMenu` reads the
+`nodeService` that `registerBridge` assigns, so creating the tray first would
+put the Start/Stop entry on the menu one five-second poll late.
+
+The menu's labels were the one thing that came *from* the minimise call, since
+the main process has no i18n. A new `tray:labels` IPC (`platform.setTrayLabels`)
+carries them instead, sent from the renderer's boot once `initLocale()` has a
+catalogue; a language change reloads the page, so the same call covers it. The
+window between launch and that first message shows `TRAY_FALLBACK`, in English.
+
+The two platform gates of §5.10 are now one — `trayOS()` in `main.js`, which
+every tray path calls — and `test_desktop_shell.py` pins the launch ordering,
+that no tray path tests `process.platform` inline instead of calling the gate,
+and (as before) that the gate and the `preload.js` capability name the same
+platform set.
+
+**Not fixed here, because it cannot be:** Windows puts a new tray icon in the
+hidden-icons overflow until the person drags it onto the taskbar. No API
+promotes it, and an installer that edits the registry to force it is the kind
+of thing this project does not do.
+
---
## 6. Execution order
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index 27c81b8..0a5723b 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -330,6 +330,12 @@ let mainWindow = null;
// ── System tray ──────────────────────────────────────────────────────────────
// Linux and Windows.
//
+// Created at launch, not on the first "minimise to tray". An indicator that
+// only appears once you have already hidden the window is one you cannot use
+// to find the application, which is most of what a tray is for -- and on
+// Windows it made the app look like it had no tray presence at all until you
+// went looking for one.
+//
// The context menu is not optional. Under libappindicator -- which is how GNOME
// shows a tray at all, via the AppIndicator extension -- `tray.on('click')`
// never fires: the indicator only opens its menu. A tray whose only affordance
@@ -342,6 +348,11 @@ let mainWindow = null;
let tray = null;
let trayLabels = null;
let trayTimer = null;
+
+// The same test preload.js publishes as `capabilities.tray`. macOS is excluded
+// deliberately: it has a menu bar rather than a tray, and the window controls
+// there already do what hiding to an indicator does elsewhere.
+const trayOS = () => process.platform === 'linux' || process.platform === 'win32';
let nodeService = null; // assigned by registerBridge()
const TRAY_FALLBACK = {
@@ -649,13 +660,24 @@ function registerBridge() {
// make "minimise to tray" mean "exit". A hidden window keeps the session, the
// transfers and the node connection exactly as they were.
ipcMain.handle('window:minimize-to-tray', (_e, labels) => {
- const trayOS = process.platform === 'linux' || process.platform === 'win32';
- if (!trayOS || !mainWindow) return false;
+ if (!trayOS() || !mainWindow) return false;
ensureTray(labels);
mainWindow.hide();
return true;
});
+ // The renderer sends these once it has a locale, and again after a language
+ // change (which reloads the page, so the same call covers both). Until then
+ // the tray created at launch shows TRAY_FALLBACK, in English, for the
+ // fraction of a second the catalogue takes to arrive -- the alternative,
+ // waiting for the renderer before creating it at all, is the behaviour this
+ // replaces.
+ ipcMain.handle('tray:labels', (_e, labels) => {
+ if (!trayOS()) return false;
+ ensureTray(labels);
+ return true;
+ });
+
ipcMain.handle('device:ensure', () => ensureDeviceKey());
ipcMain.handle('device:public', () => {
const key = deviceKey();
@@ -1660,7 +1682,11 @@ if (!app.requestSingleInstanceLock()) {
app.whenReady().then(() => {
registerUiProtocol();
+ // Before ensureTray: buildTrayMenu reads `nodeService`, which registerBridge
+ // assigns, so creating the tray after it means the Start/Stop entry is on
+ // the very first menu rather than appearing one poll later.
registerBridge();
+ if (trayOS()) ensureTray();
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js
index b390bfb..13d4f37 100644
--- a/packages/meshbay-client/src/preload.js
+++ b/packages/meshbay-client/src/preload.js
@@ -50,6 +50,11 @@ contextBridge.exposeInMainWorld('meshbay', {
// Labels are passed in because the main process has no i18n; see main.js.
minimizeToTray: (labels) => ipcRenderer.invoke('window:minimize-to-tray', labels),
+ // The tray now exists from launch, so its menu needs translating before
+ // anyone minimises into it. Sent once the interface has its catalogue, and
+ // again after a language change.
+ setTrayLabels: (labels) => ipcRenderer.invoke('tray:labels', labels),
+
// Ask the main process to call the hub. The renderer has an `app://` origin,
// which CORS refuses and which is not a credential anyway.
fetch: (url, init) => ipcRenderer.invoke('hub:fetch', url, init),
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 9f925c3..c2858f4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -245,9 +245,7 @@ function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount,
<div class="nav-right">
${user && html`<${TransferWidget} />`}
${platform.capabilities.tray && html`
- <button class="nav-tray" onClick=${() => platform.minimizeToTray({
- show: t('tray.show'), quit: t('tray.quit'),
- start_node: t('tray.start_node'), stop_node: t('tray.stop_node') })}
+ <button class="nav-tray" onClick=${() => platform.minimizeToTray(trayLabels())}
title=${t('nav.minimize_tray')} aria-label=${t('nav.minimize_tray')}>
<${Icon} name="tray" />
</button>
@@ -929,10 +927,25 @@ function App() {
// ── Boot ─────────────────────────────────────────────────────────────────────
+// The tray menu's four strings. The main process has no i18n (see
+// packages/meshbay-client/src/main.js), so they are translated here and sent
+// over the bridge — once at boot, because the app now creates its indicator at
+// launch rather than on the first minimise, and again from the nav button.
+const trayLabels = () => ({
+ show: t('tray.show'), quit: t('tray.quit'),
+ start_node: t('tray.start_node'), stop_node: t('tray.stop_node'),
+});
+
// Catalogues are fetched, so the first render waits for one: mounting earlier
// would paint the interface in English and then swap every string. initLocale()
// falls back to English rather than rejecting, so this cannot strand the page.
-const mount = () => render(html`<${App} />`, document.getElementById('app'));
+const mount = () => {
+ render(html`<${App} />`, document.getElementById('app'));
+ // After the catalogue, so the labels are in the right language. A no-op in a
+ // browser and on macOS. A language change reloads the page, which comes back
+ // through here, so nothing else has to watch for it.
+ platform.setTrayLabels(trayLabels()).catch(() => {});
+};
initLocale().then(mount, (err) => {
// Nothing in initLocale() is supposed to reject. If something does, an
// English interface is still an interface; an unhandled rejection here is a
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index b009ebe..e93ffb3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -470,9 +470,21 @@ export async function minimizeToTray(labels) {
return bridge.minimizeToTray(labels);
}
+/**
+ * Translate the tray menu the app created at launch.
+ *
+ * The main process has no i18n -- a second string table is how two of them
+ * start disagreeing -- so the labels come from here, once the catalogue has
+ * loaded. A no-op in a browser and on macOS, where there is no indicator.
+ */
+export async function setTrayLabels(labels) {
+ if (!bridge || !bridge.setTrayLabels) return false;
+ return bridge.setTrayLabels(labels);
+}
+
export default { isNative, hubBase, capabilities, secrets, nativeSave,
apiFetch, device, bridgeMessage, folder, rootPicker, node,
- cast, minimizeToTray };
+ cast, minimizeToTray, setTrayLabels };
// Also a global, because `transport.js` is loaded as a classic script — it
// predates the module graph and exposes `MeshBayTransport` the same way. The
@@ -482,5 +494,5 @@ if (typeof window !== 'undefined') {
window.MeshBayPlatform = { isNative, hubBase, capabilities, secrets,
nativeSave, apiFetch, device,
bridgeMessage, folder, rootPicker, node,
- cast, minimizeToTray };
+ cast, minimizeToTray, setTrayLabels };
}
diff --git a/packages/meshbay-hub/tests/test_desktop_shell.py b/packages/meshbay-hub/tests/test_desktop_shell.py
index 45e3ed4..c9d8683 100644
--- a/packages/meshbay-hub/tests/test_desktop_shell.py
+++ b/packages/meshbay-hub/tests/test_desktop_shell.py
@@ -380,19 +380,18 @@ def test_package_json_does_not_define_a_second_linux_package():
# ── System tray is cross-platform ────────────────────────────────────────────
-def test_tray_capability_and_ipc_handler_agree_on_which_platforms():
+def test_tray_capability_and_main_process_agree_on_which_platforms():
"""
- The tray (GNOME, then Windows) has exactly two platform gates: the
- `tray` capability preload.js exposes, which is all app.js's nav button
- is keyed on, and the `window:minimize-to-tray` IPC handler in main.js
- that actually creates it. Both must name the same platform set, or one
- of two broken states results: a button that renders but does nothing
- (handler stricter than the capability), or a hidden window with no way
- back (capability stricter than the handler, on a desktop with no tray
- to have hidden it into). Everything else the tray uses (`ensureTray`,
- `tray.on('click')`, the poll-refresh, `nodeService.status/stop/restart`)
- is unconditional Electron/abstraction code with no platform check of its
- own -- these two lines are the whole gate.
+ The tray (GNOME, then Windows) has exactly two platform gates: the `tray`
+ capability preload.js exposes, which is all app.js's nav button is keyed
+ on, and main.js's `trayOS()`, which every path that creates or uses the
+ indicator goes through. Both must name the same platform set, or one of
+ two broken states results: a button that renders but does nothing (main
+ stricter than the capability), or a hidden window with no way back
+ (capability stricter than main, on a desktop with no tray to have hidden
+ it into). Everything else the tray uses (`ensureTray`, `tray.on('click')`,
+ the poll-refresh, `nodeService.status/stop/restart`) is unconditional
+ Electron/abstraction code with no platform check of its own.
"""
preload = _preload()
cap_line = next(
@@ -400,11 +399,56 @@ def test_tray_capability_and_ipc_handler_agree_on_which_platforms():
and "process.platform" in line), None)
assert cap_line, "no platform-gated `tray:` capability found in preload.js"
- lines = _main().splitlines()
- handler_start = next(
- i for i, line in enumerate(lines) if "'window:minimize-to-tray'" in line)
- handler_block = "\n".join(lines[handler_start:handler_start + 5])
+ main = _main()
+ gate = next(
+ (line for line in main.splitlines()
+ if line.startswith("const trayOS =") and "process.platform" in line), None)
+ assert gate, "no `trayOS` platform gate found in main.js"
for plat in ("'linux'", "'win32'"):
assert plat in cap_line, f"tray capability must name {plat}"
- assert plat in handler_block, f"minimize-to-tray handler must name {plat}"
+ assert plat in gate, f"trayOS must name {plat}"
+
+
+def test_every_tray_path_goes_through_the_one_gate():
+ """
+ There is one platform test, not a copy per call site. A second inline
+ `process.platform === ...` beside a tray call is how the two drift, which
+ is the whole failure the test above exists to catch — it can only compare
+ what it can find.
+
+ Both entry points are named explicitly: the launch-time creation (the
+ indicator is there before anyone minimises, which is most of what a tray
+ is for) and the minimise handler.
+ """
+ lines = _main().splitlines()
+
+ # Both entry points still exist, so a rename cannot make this vacuous.
+ for marker in ("ensureTray()", "'window:minimize-to-tray'", "'tray:labels'"):
+ assert any(marker in line for line in lines), f"{marker} not found in main.js"
+
+ inline = [line for line in lines
+ if "process.platform" in line and "tray" in line.lower()
+ and not line.startswith("const trayOS =")]
+ assert not inline, (
+ "a tray path is testing process.platform itself instead of calling "
+ f"trayOS(): {inline}")
+
+
+def test_the_tray_is_created_at_launch():
+ """
+ Not on the first minimise. An indicator that only appears once you have
+ already hidden the window cannot be used to find the application.
+
+ `ensureTray` must be called after `registerBridge`, because buildTrayMenu
+ reads the `nodeService` registerBridge assigns: the other order puts the
+ Start/Stop entry on the menu one five-second poll late.
+ """
+ main = _main()
+ ready = main.index("app.whenReady()")
+ bridge = main.index("registerBridge();", ready)
+ launch = main.index("ensureTray()", ready)
+ window = main.index("createWindow();", ready)
+
+ assert bridge < launch, "ensureTray must come after registerBridge"
+ assert launch < window, "the tray is created as part of startup"