diff --git a/README.md b/README.md
index a0d7ec22d..9d98592fc 100644
--- a/README.md
+++ b/README.md
@@ -188,6 +188,8 @@ Want to build your own? Start from the [plugin template](https://github.com/Term
See the [docs](https://docs.termix.site/install) for install steps on every platform.
+On macOS, choose **Termix → Menu Bar Icon** to use a monochrome icon (the default), a color icon, or hide it. The choice is saved on this device. You can restore a hidden icon from the same menu after opening Termix from the Dock; closing the last window while the icon is hidden quits the app.
+
Sample Docker Compose file. Remote desktop (RDP, VNC and Telnet) is built in, so nothing else needs to run next to it:
```yaml
diff --git a/electron/desktop-settings.cjs b/electron/desktop-settings.cjs
index 144b40ed1..05e30438b 100644
--- a/electron/desktop-settings.cjs
+++ b/electron/desktop-settings.cjs
@@ -32,7 +32,11 @@ function writeJson(filePath, value) {
}
}
-const DEFAULTS = { defaultConnectionOrigin: "local", updateMode: "prompt" };
+const DEFAULTS = {
+ defaultConnectionOrigin: "local",
+ updateMode: "prompt",
+ macTrayIcon: "monochrome",
+};
function getDesktopSettings() {
return { ...DEFAULTS, ...readJson(dataPath("desktop-settings.json"), {}) };
diff --git a/electron/main.cjs b/electron/main.cjs
index 1cf643bff..166c1ae24 100644
--- a/electron/main.cjs
+++ b/electron/main.cjs
@@ -1233,7 +1233,21 @@ if (!gotTheLock) {
});
}
+function setMacTrayIcon(style) {
+ desktopSettings.saveDesktopSettings({ macTrayIcon: style });
+ if (tray && !tray.isDestroyed()) tray.destroy();
+ tray = null;
+ createTray();
+ createMenu();
+ if (style === "hidden" && mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.show();
+ mainWindow.focus();
+ }
+}
+
function createTray() {
+ const macStyle = desktopSettings.getDesktopSettings().macTrayIcon;
+ if (process.platform === "darwin" && macStyle === "hidden") return;
try {
const { nativeImage } = require("electron");
@@ -1245,10 +1259,14 @@ function createTray() {
let trayIcon;
if (process.platform === "darwin") {
- const iconPath = path.join(publicRoot, "icons", "16x16.png");
- // The logo is full color on a solid tile. As a template image macOS
- // keeps only its outline, which showed as an empty square.
+ const monochrome = macStyle !== "color";
+ const iconPath = path.join(
+ publicRoot,
+ "icons",
+ monochrome ? "trayTemplate.png" : "16x16.png",
+ );
trayIcon = nativeImage.createFromPath(iconPath);
+ trayIcon.setTemplateImage(monochrome);
} else if (process.platform === "win32") {
trayIcon = path.join(publicRoot, "icon.ico");
} else {
@@ -2000,6 +2018,20 @@ function createMenu() {
label: app.name,
submenu: [
{ role: "about" },
+ {
+ label: "Menu Bar Icon",
+ submenu: [
+ ["monochrome", "Monochrome"],
+ ["color", "Color"],
+ ["hidden", "Hidden"],
+ ].map(([style, label]) => ({
+ label,
+ type: "radio",
+ checked:
+ desktopSettings.getDesktopSettings().macTrayIcon === style,
+ click: () => setMacTrayIcon(style),
+ })),
+ },
{ type: "separator" },
{ role: "services" },
{ type: "separator" },
diff --git a/public/icons/trayTemplate.png b/public/icons/trayTemplate.png
new file mode 100644
index 000000000..b20065a0b
Binary files /dev/null and b/public/icons/trayTemplate.png differ
diff --git a/public/icons/trayTemplate.svg b/public/icons/trayTemplate.svg
new file mode 100644
index 000000000..8e4addc87
--- /dev/null
+++ b/public/icons/trayTemplate.svg
@@ -0,0 +1,6 @@
+
diff --git a/public/icons/trayTemplate@2x.png b/public/icons/trayTemplate@2x.png
new file mode 100644
index 000000000..05f4cffd6
Binary files /dev/null and b/public/icons/trayTemplate@2x.png differ
diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs
index 272049279..1537cb2ac 100644
--- a/scripts/generate-icons.mjs
+++ b/scripts/generate-icons.mjs
@@ -63,6 +63,15 @@ writeFileSync(
copyFileSync(join(publicDir, "icon.ico"), join(iconsDir, "icon.ico"));
copyFileSync(join(publicDir, "icon.icns"), join(iconsDir, "icon.icns"));
+// Transparent foreground from public/icon.svg; macOS colors this template.
+const traySvg = readFileSync(join(iconsDir, "trayTemplate.svg"));
+for (const scale of [1, 2]) {
+ await sharp(traySvg, { density: 72 * scale })
+ .resize(16 * scale, 16 * scale)
+ .png()
+ .toFile(join(iconsDir, `trayTemplate${scale === 2 ? "@2x" : ""}.png`));
+}
+
console.log("Icons generated from public/icon-square.svg");
function buildIco(pngBuffers, sizes) {
diff --git a/src/backend/tests/electron/mac-tray.test.ts b/src/backend/tests/electron/mac-tray.test.ts
new file mode 100644
index 000000000..326135c98
--- /dev/null
+++ b/src/backend/tests/electron/mac-tray.test.ts
@@ -0,0 +1,221 @@
+import { mkdtempSync, readFileSync, rmSync } from "node:fs";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import vm from "node:vm";
+import sharp from "sharp";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+const root = path.resolve(import.meta.dirname, "../../../..");
+const source = readFileSync(path.join(root, "electron/main.cjs"), "utf8");
+const temporary: string[] = [];
+afterEach(() =>
+ temporary
+ .splice(0)
+ .forEach((dir) => rmSync(dir, { recursive: true, force: true })),
+);
+
+type MenuItem = {
+ label?: string;
+ checked?: boolean;
+ role?: string;
+ submenu?: MenuItem[];
+ click?: () => void;
+};
+function harness(platform = "darwin", saved?: string) {
+ const directory = mkdtempSync(path.join(os.tmpdir(), "termix-tray-"));
+ temporary.push(directory);
+ const settingsModule = { exports: {} };
+ vm.runInNewContext(
+ readFileSync(path.join(root, "electron/desktop-settings.cjs"), "utf8"),
+ {
+ module: settingsModule,
+ process,
+ require: (id: string) =>
+ id === "electron"
+ ? { app: { getPath: () => directory } }
+ : id === "fs"
+ ? fs
+ : path,
+ },
+ );
+ const settings = settingsModule.exports as {
+ getDesktopSettings: () => { macTrayIcon: string };
+ saveDesktopSettings: (value: { macTrayIcon: string }) => void;
+ };
+ if (saved) settings.saveDesktopSettings({ macTrayIcon: saved });
+ const images: { path: string; setTemplateImage: ReturnType }[] =
+ [];
+ const trays: { destroy: ReturnType }[] = [];
+ const app = { name: "Termix", quit: vi.fn() };
+ const mainWindow = {
+ show: vi.fn(),
+ focus: vi.fn(),
+ isDestroyed: () => false,
+ };
+ const createWindow = vi.fn();
+ const callbacks = new Map void>();
+ const context = {
+ desktopSettings: settings,
+ process: { platform, env: {} },
+ path,
+ isDev: true,
+ appRoot: root,
+ tray: null,
+ mainWindow,
+ app,
+ require: () => ({
+ nativeImage: {
+ createFromPath: (p: string) => {
+ const image = { path: p, setTemplateImage: vi.fn() };
+ images.push(image);
+ return image;
+ },
+ },
+ }),
+ Tray: class {
+ destroyed = false;
+ destroy = vi.fn(() => {
+ this.destroyed = true;
+ });
+ constructor() {
+ trays.push(this);
+ }
+ isDestroyed() {
+ return this.destroyed;
+ }
+ setToolTip() {}
+ setContextMenu() {}
+ on() {}
+ },
+ Menu: {
+ buildFromTemplate: vi.fn((items: MenuItem[]) => items),
+ setApplicationMenu: vi.fn(),
+ },
+ logToFile: vi.fn(),
+ loadLinuxTrayIcon: vi.fn(() => "linux-icon"),
+ BrowserWindow: { getAllWindows: () => [] },
+ createWindow,
+ };
+ const functions =
+ source.slice(
+ source.indexOf("function setMacTrayIcon("),
+ source.indexOf("function loadLinuxTrayIcon("),
+ ) +
+ source.slice(
+ source.indexOf("function createMenu("),
+ source.indexOf("app.whenReady()"),
+ );
+ const runtime = vm.runInNewContext(
+ functions + "\n({ createTray, createMenu });",
+ context,
+ ) as { createTray: () => void; createMenu: () => void };
+ // Evaluate the real Dock activation and window-close policies, too.
+ const lifecycle = source.slice(
+ source.indexOf('app.on("window-all-closed"'),
+ source.indexOf('app.on("before-quit"'),
+ );
+ vm.runInNewContext(lifecycle, {
+ ...context,
+ app: {
+ ...app,
+ on: (event: string, fn: () => void) => callbacks.set(event, fn),
+ },
+ });
+ return { runtime, settings, images, trays, context, callbacks, createWindow };
+}
+
+describe("macOS menu bar icon", () => {
+ it("defaults to a transparent template, with an explicit color option", () => {
+ const h = harness();
+ h.runtime.createTray();
+ h.runtime.createMenu();
+ expect(h.images[0].path).toMatch(/trayTemplate\.png$/);
+ expect(h.images[0].setTemplateImage).toHaveBeenCalledWith(true);
+ const menu = h.context.Menu.setApplicationMenu.mock.calls.at(
+ -1,
+ )![0] as MenuItem[];
+ const options = menu[0].submenu!.find(
+ (item) => item.label === "Menu Bar Icon",
+ )!.submenu!;
+ expect(options[0].checked).toBe(true);
+ options[1].click!();
+ expect(h.trays[0].destroy).toHaveBeenCalledOnce();
+ expect(h.settings.getDesktopSettings().macTrayIcon).toBe("color");
+ expect(h.images.at(-1)!.path).toMatch(/16x16\.png$/);
+ expect(h.images.at(-1)!.setTemplateImage).toHaveBeenCalledWith(false);
+ });
+
+ it("hides immediately, persists the choice, and keeps the app menu available to restore it", () => {
+ const h = harness();
+ h.runtime.createTray();
+ h.runtime.createMenu();
+ const options = (
+ h.context.Menu.setApplicationMenu.mock.calls.at(-1)![0] as MenuItem[]
+ )[0].submenu![1].submenu!;
+ options[2].click!();
+ expect(h.trays[0].destroy).toHaveBeenCalledOnce();
+ expect(h.trays).toHaveLength(1);
+ expect(h.settings.getDesktopSettings().macTrayIcon).toBe("hidden");
+ expect(h.context.mainWindow.show).toHaveBeenCalledOnce();
+ const updated = (
+ h.context.Menu.setApplicationMenu.mock.calls.at(-1)![0] as MenuItem[]
+ )[0].submenu![1].submenu!;
+ expect(updated[2].checked).toBe(true);
+ updated[0].click!();
+ expect(h.trays).toHaveLength(2);
+ expect(h.images.at(-1)!.setTemplateImage).toHaveBeenCalledWith(true);
+ });
+
+ it("honors hidden on launch while preserving Dock activation and quitting after the last window closes", () => {
+ const h = harness("darwin", "hidden");
+ h.runtime.createTray();
+ h.runtime.createMenu();
+ expect(h.trays).toHaveLength(0);
+ h.callbacks.get("activate")!();
+ expect(h.createWindow).toHaveBeenCalledOnce();
+ h.callbacks.get("window-all-closed")!();
+ expect(h.context.app.quit).toHaveBeenCalledOnce();
+ expect(
+ (
+ h.context.Menu.setApplicationMenu.mock.calls[0][0] as MenuItem[]
+ )[0].submenu!.some((item) => item.role === "quit"),
+ ).toBe(true);
+ });
+
+ it.each(["win32", "linux"])(
+ "does not hide or add macOS options on %s",
+ (platform) => {
+ const h = harness(platform, "hidden");
+ h.runtime.createTray();
+ h.runtime.createMenu();
+ expect(h.trays).toHaveLength(1);
+ expect(h.context.Menu.setApplicationMenu).not.toHaveBeenCalled();
+ },
+ );
+
+ it.each([1, 2])(
+ "ships a transparent, black template at %sx",
+ async (scale) => {
+ const file = path.join(
+ root,
+ `public/icons/trayTemplate${scale === 2 ? "@2x" : ""}.png`,
+ );
+ const { data, info } = await sharp(file)
+ .ensureAlpha()
+ .raw()
+ .toBuffer({ resolveWithObject: true });
+ expect([info.width, info.height]).toEqual([16 * scale, 16 * scale]);
+ const alpha = [];
+ for (let i = 0; i < data.length; i += 4) {
+ expect([data[i], data[i + 1], data[i + 2]]).toEqual([0, 0, 0]);
+ alpha.push(data[i + 3]);
+ }
+ expect(alpha).toContain(0);
+ expect(alpha.some((value) => value > 0)).toBe(true);
+ expect(alpha.filter((value) => value > 0).length).toBeLessThan(
+ alpha.length * 0.8,
+ );
+ },
+ );
+});