aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-04 18:10:39 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-05 13:32:26 +0200
commit89ed51e9b7ea38bd4475ce3a234f8c783b3d9b09 (patch)
treea6982fe43a43d31771beb124befac61627afb654
parentf808cf0ecaa3c6db023b1496f4b09cca5ca934f9 (diff)
downloadmeshbay-89ed51e9b7ea38bd4475ce3a234f8c783b3d9b09.tar.gz
feat(client): minimise to a system tray indicator (GNOME)
A dedicated monochrome button in the nav, immediately left of the notification bell, hides the window to a tray indicator. Linux only for now; Windows is being done on that OS, and the capability is declared per platform so the button never appears where the desktop shows no indicator -- there it would hide the window for good. Hides, never closes: `window-all-closed` quits the app, so closing here would make "minimise" mean "exit" and drop the session, the transfers and the node connection. `second-instance` now calls the same restore path, since focusing a hidden window does nothing visible. The context menu is not decoration. Under libappindicator -- 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 sole affordance was a click would be inert on the one desktop this targets. The click handler is kept for desktops that do send it. Menu labels come from the renderer with the IPC call: the locale files are the interface's, the main process has no i18n, and a second string table is how two of them start disagreeing. English fallbacks if none arrive. The icon lives in src/, not build/: package.json `files` packages only `src/**` and `ui/**`, so an icon under build/ is present in a dev run and missing from every installed one. Monochrome, stroked, matching the nav glyph. Verified on this host: Ubuntu GNOME with ubuntu-appindicators@ubuntu.com and libayatana-appindicator3 present, so the indicator has somewhere to appear. Not yet run end to end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtfG7z6wHWj8RKHCvxQtY1
-rw-r--r--packages/meshbay-client/src/main.js68
-rw-r--r--packages/meshbay-client/src/preload.js7
-rw-r--r--packages/meshbay-client/src/tray-icon.pngbin0 -> 415 bytes
-rw-r--r--packages/meshbay-client/src/tray-icon@2x.pngbin0 -> 755 bytes
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/icon.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js20
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css25
18 files changed, 152 insertions, 7 deletions
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index e996829..71b56be 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -20,7 +20,8 @@
'use strict';
-const { app, BrowserWindow, dialog, ipcMain, protocol, safeStorage, shell } =
+const { app, BrowserWindow, dialog, ipcMain, Menu, protocol, safeStorage,
+ shell, Tray } =
require('electron');
const { execFile, spawn } = require('node:child_process');
const crypto = require('node:crypto');
@@ -326,6 +327,56 @@ const HUB_FETCH_TIMEOUT_MS = 30000;
let mainWindow = null;
+// ── System tray ──────────────────────────────────────────────────────────────
+// Linux only for now; Windows is being done on that OS.
+//
+// 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
+// was a click would be inert on the one desktop this targets.
+//
+// Labels arrive from the renderer rather than being translated here. The locale
+// files are the interface's, the main process has no i18n, and a second string
+// table is how two of them start disagreeing.
+let tray = null;
+
+function showFromTray() {
+ if (!mainWindow) return;
+ if (!mainWindow.isVisible()) mainWindow.show();
+ if (mainWindow.isMinimized()) mainWindow.restore();
+ mainWindow.focus();
+}
+
+function ensureTray(labels) {
+ const text = {
+ show: (labels && labels.show) || 'Show MeshBay',
+ quit: (labels && labels.quit) || 'Quit',
+ };
+ if (tray) {
+ // Relabel in place: the person may have changed language since it was made.
+ tray.setContextMenu(Menu.buildFromTemplate([
+ { label: text.show, click: showFromTray },
+ { type: 'separator' },
+ { label: text.quit, click: () => app.quit() },
+ ]));
+ return tray;
+ }
+ // In src/, not build/: electron-builder packages only `src/**` and `ui/**`
+ // (package.json `files`), so an icon under build/ exists in a dev run and is
+ // missing from every installed one.
+ tray = new Tray(path.join(__dirname, 'tray-icon.png'));
+ tray.setToolTip('MeshBay');
+ tray.setContextMenu(Menu.buildFromTemplate([
+ { label: text.show, click: showFromTray },
+ { type: 'separator' },
+ { label: text.quit, click: () => app.quit() },
+ ]));
+ // Harmless where it does nothing (GNOME), useful on desktops that do send it.
+ tray.on('click', showFromTray);
+ return tray;
+}
+
+
function createWindow() {
const bounds = (config.window && config.window.width) ? config.window : {
width: 1200, height: 800,
@@ -541,6 +592,16 @@ function registerBridge() {
} catch { return null; }
});
+ // Hide, never close: `window-all-closed` quits the app, and closing here would
+ // 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) => {
+ if (process.platform !== 'linux' || !mainWindow) return false;
+ ensureTray(labels);
+ mainWindow.hide();
+ return true;
+ });
+
ipcMain.handle('device:ensure', () => ensureDeviceKey());
ipcMain.handle('device:public', () => {
const key = deviceKey();
@@ -1477,10 +1538,7 @@ if (!app.requestSingleInstanceLock()) {
app.quit();
} else {
app.on('second-instance', () => {
- if (mainWindow) {
- if (mainWindow.isMinimized()) mainWindow.restore();
- mainWindow.focus();
- }
+ showFromTray();
});
app.whenReady().then(() => {
diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js
index 97a5063..1aa16e5 100644
--- a/packages/meshbay-client/src/preload.js
+++ b/packages/meshbay-client/src/preload.js
@@ -41,8 +41,15 @@ contextBridge.exposeInMainWorld('meshbay', {
localFolders: true,
nativeSave: true,
lanCast: true,
+ // Linux only for now -- Windows is being done on that OS. Declared per
+ // platform rather than always: a button that hides the window somewhere the
+ // desktop shows no indicator would hide it for good.
+ tray: process.platform === 'linux',
},
+ // Labels are passed in because the main process has no i18n; see main.js.
+ minimizeToTray: (labels) => ipcRenderer.invoke('window:minimize-to-tray', 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-client/src/tray-icon.png b/packages/meshbay-client/src/tray-icon.png
new file mode 100644
index 0000000..394604d
--- /dev/null
+++ b/packages/meshbay-client/src/tray-icon.png
Binary files differ
diff --git a/packages/meshbay-client/src/tray-icon@2x.png b/packages/meshbay-client/src/tray-icon@2x.png
new file mode 100644
index 0000000..3e533fb
--- /dev/null
+++ b/packages/meshbay-client/src/tray-icon@2x.png
Binary files differ
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 5311403..5857821 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -243,6 +243,13 @@ function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount,
</div>
<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') })}
+ title=${t('nav.minimize_tray')} aria-label=${t('nav.minimize_tray')}>
+ <${Icon} name="tray" />
+ </button>
+ `}
${user && html`
<a class="nav-notif" href="#/" title=${t('notif.title')}>
<${Icon} name="bell" />${unreadCount > 0
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/icon.js b/packages/meshbay-hub/src/meshbay_hub/static/icon.js
index cc28110..79a3a08 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/icon.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/icon.js
@@ -13,6 +13,8 @@ import { html } from './vendor/htm-preact.js';
const ICON_PATHS = {
menu: ['M4 7h16M4 12h16M4 17h16'],
+ tray: ['M12 3.5v9', 'M8.5 9l3.5 3.5L15.5 9',
+ 'M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3'],
bell: ['M18 9a6 6 0 1 0-12 0c0 6-2.5 7.5-2.5 7.5h17S18 15 18 9',
'M10.3 20a2 2 0 0 0 3.4 0'],
shield: ['M12 3l7.5 3v5.2c0 4.6-3.1 8.6-7.5 10.3-4.4-1.7-7.5-5.7-7.5-10.3V6z'],
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 49b5f9e..24ff315 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -14,6 +14,9 @@ export default {
'nav.dark_mode': 'Dunkles Design',
'nav.logout': 'Abmelden',
'nav.login': 'Anmelden',
+ 'nav.minimize_tray': 'In den Infobereich minimieren',
+ 'tray.show': 'MeshBay anzeigen',
+ 'tray.quit': 'Beenden',
// Sidebar
'sidebar.my_groups': 'Meine Gruppen',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 7ea8989..ce08f1f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -17,6 +17,9 @@ export default {
'nav.dark_mode': 'Dark mode',
'nav.logout': 'Logout',
'nav.login': 'Login',
+ 'nav.minimize_tray': 'Minimize to tray',
+ 'tray.show': 'Show MeshBay',
+ 'tray.quit': 'Quit',
// Sidebar
'sidebar.my_groups': 'My Groups',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 6109d4f..7aa1d98 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -13,6 +13,9 @@ export default {
'nav.dark_mode': 'Tema oscuro',
'nav.logout': 'Cerrar sesión',
'nav.login': 'Iniciar sesión',
+ 'nav.minimize_tray': 'Minimizar en la bandeja',
+ 'tray.show': 'Mostrar MeshBay',
+ 'tray.quit': 'Salir',
// Sidebar
'sidebar.my_groups': 'Mis grupos',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 926f64a..b87cb81 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -13,6 +13,9 @@ export default {
'nav.dark_mode': 'Thème sombre',
'nav.logout': 'Déconnexion',
'nav.login': 'Connexion',
+ 'nav.minimize_tray': 'Réduire dans la zone de notification',
+ 'tray.show': 'Afficher MeshBay',
+ 'tray.quit': 'Quitter',
// Sidebar
'sidebar.my_groups': 'Mes groupes',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index f7011ec..242bdb4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -14,6 +14,9 @@ export default {
'nav.dark_mode': 'Tema scuro',
'nav.logout': 'Esci',
'nav.login': 'Accedi',
+ 'nav.minimize_tray': 'Riduci nella barra di sistema',
+ 'tray.show': 'Mostra MeshBay',
+ 'tray.quit': 'Esci',
// Sidebar
'sidebar.my_groups': 'I miei gruppi',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 592f939..8c8f654 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -14,6 +14,9 @@ export default {
'nav.dark_mode': 'ダークテーマ',
'nav.logout': 'ログアウト',
'nav.login': 'ログイン',
+ 'nav.minimize_tray': 'トレイに最小化',
+ 'tray.show': 'MeshBay を表示',
+ 'tray.quit': '終了',
// Sidebar
'sidebar.my_groups': 'マイグループ',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index eba234e..566677d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -14,6 +14,9 @@ export default {
'nav.dark_mode': 'Donker thema',
'nav.logout': 'Afmelden',
'nav.login': 'Aanmelden',
+ 'nav.minimize_tray': 'Minimaliseren naar systeemvak',
+ 'tray.show': 'MeshBay tonen',
+ 'tray.quit': 'Afsluiten',
// Sidebar
'sidebar.my_groups': 'Mijn groepen',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 999e689..4e75670 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -17,6 +17,9 @@ export default {
'nav.dark_mode': 'Motyw ciemny',
'nav.logout': 'Wyloguj',
'nav.login': 'Zaloguj',
+ 'nav.minimize_tray': 'Zminimalizuj do zasobnika',
+ 'tray.show': 'Pokaż MeshBay',
+ 'tray.quit': 'Zakończ',
// Sidebar
'sidebar.my_groups': 'Moje grupy',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 79d2079..a9fd638 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -15,6 +15,9 @@ export default {
'nav.dark_mode': 'Tema escuro',
'nav.logout': 'Sair',
'nav.login': 'Entrar',
+ 'nav.minimize_tray': 'Minimizar na bandeja',
+ 'tray.show': 'Mostrar o MeshBay',
+ 'tray.quit': 'Sair',
// Sidebar
'sidebar.my_groups': 'Meus grupos',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index d67c34b..ca39837 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -14,6 +14,9 @@ export default {
'nav.dark_mode': '深色主题',
'nav.logout': '退出登录',
'nav.login': '登录',
+ 'nav.minimize_tray': '最小化到托盘',
+ 'tray.show': '显示 MeshBay',
+ 'tray.quit': '退出',
// Sidebar
'sidebar.my_groups': '我的群组',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index 8f5d484..b009ebe 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -48,6 +48,10 @@ export const capabilities = {
nativeSave: Boolean(bridge && bridge.capabilities && bridge.capabilities.nativeSave),
// Cast decrypted video to a device on the same LAN via a local HTTP relay.
lanCast: Boolean(bridge && bridge.capabilities && bridge.capabilities.lanCast),
+ // Hide the window to a system tray indicator. Declared per-OS by the app, not
+ // by this file: a tray is only worth offering where the desktop actually shows
+ // one, so the browser and any platform without support simply never see it.
+ tray: Boolean(bridge && bridge.capabilities && bridge.capabilities.tray),
};
/**
@@ -454,9 +458,21 @@ export const cast = {
},
};
+/**
+ * Hide the window to the system tray indicator.
+ *
+ * A no-op without the bridge, so the same nav renders in a browser without a
+ * guard at the call site -- though `capabilities.tray` keeps the button itself
+ * out of a browser, where there is no tray to minimise into.
+ */
+export async function minimizeToTray(labels) {
+ if (!bridge || !bridge.minimizeToTray) return false;
+ return bridge.minimizeToTray(labels);
+}
+
export default { isNative, hubBase, capabilities, secrets, nativeSave,
apiFetch, device, bridgeMessage, folder, rootPicker, node,
- cast };
+ cast, minimizeToTray };
// Also a global, because `transport.js` is loaded as a classic script — it
// predates the module graph and exposes `MeshBayTransport` the same way. The
@@ -466,5 +482,5 @@ if (typeof window !== 'undefined') {
window.MeshBayPlatform = { isNative, hubBase, capabilities, secrets,
nativeSave, apiFetch, device,
bridgeMessage, folder, rootPicker, node,
- cast };
+ cast, minimizeToTray };
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 3d9206d..2770dd7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -2075,6 +2075,31 @@ a.transfer-name {
}
}
+/* ── Minimise to tray ────────────────────────────────────────────────────── */
+
+/* Monochrome on purpose: it sits next to the bell, and the bell's badge is the
+ only thing in this corner allowed to pull the eye. Inherits --nav-text like
+ every other nav control, so it follows the theme rather than carrying its own
+ colour, and the glyph is a stroked `currentColor` icon with nothing to tint. */
+.nav-tray {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: none;
+ border: 0;
+ padding: 4px;
+ margin-right: 2px;
+ color: var(--nav-text);
+ cursor: pointer;
+ border-radius: 6px;
+ opacity: 0.75;
+}
+.nav-tray:hover,
+.nav-tray:focus-visible {
+ opacity: 1;
+ background: rgba(255, 255, 255, 0.1);
+}
+
/* ── Notification bell ───────────────────────────────────────────────────── */
.nav-notif {