feat: add macOS menu bar icon options (#1779)

This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-11-06 20:46:38 -06:00
1 parent 3630d6c157
commit 803a52c339
8 files changed
+278 -4

No files matched your search

+2
View File
@@ -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
+5 -1
View File
@@ -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"), {}) };
+35 -3
View File
@@ -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" },
Binary file not shown.

After

Width:  |  Height:  |  Size: 269 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="207 224 610 620">
<rect x="227" y="244" width="570" height="92" rx="46" fill="#000000"/>
<path d="M474 336H550V786A38 38 0 0 1 474 786V572.2L542 545V447L474 419.8ZM498 647.5V761.5H550V647.5Z" fill="#000000" fill-rule="evenodd"/>
<path d="M306 387.2L510 468.8V523.2L306 604.8V547.5L435.5 496L306 444.5Z" fill="#000000"/>
<rect x="530.5" y="679.5" width="213" height="50" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 474 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 B

+9
View File
@@ -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) {
+221
View File
@@ -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<typeof vi.fn> }[] =
[];
const trays: { destroy: ReturnType<typeof vi.fn> }[] = [];
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<string, () => 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,
);
},
);
});