diff options
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/meshbay-client/src/main.js | 30 | ||||
| -rw-r--r-- | packages/meshbay-client/src/preload.js | 5 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 21 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/platform.js | 16 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_desktop_shell.py | 78 |
5 files changed, 125 insertions, 25 deletions
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" |