summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-08 03:14:34 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-08 03:14:34 +0200
commit8b0f4ba3bc5fc146bad4730b1ccdcd5be55f192d (patch)
tree49d7d169115a9b7c87d46d251abaf8590133191e /packages/meshbay-hub
parent6a5997655d9f2fa583bf707a5611bbb0c0f109fc (diff)
downloadmeshbay-8b0f4ba3bc5fc146bad4730b1ccdcd5be55f192d.tar.gz
feat(client): create the system tray at launch, not on first minimise
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. Created during app.whenReady(), after registerBridge() and before createWindow(). The order matters: buildTrayMenu reads the nodeService that registerBridge assigns, so the other way round puts 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. §5.10's two platform gates become one — trayOS() in main.js, which every tray path calls. test_desktop_shell.py's existing test is rewritten against it and two are added: the launch ordering, and that no tray path tests process.platform inline instead of calling the gate. Windows still files a new tray icon under hidden icons until the person drags it onto the taskbar. No API promotes it; documented in WINDOWS-PORT.md §5.11 rather than worked around. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8EDjk6pkYZrCbo63m2x87
Diffstat (limited to 'packages/meshbay-hub')
-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
3 files changed, 92 insertions, 23 deletions
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"