mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-07 02:55:24 +00:00
feat: add macOS menu bar icon options (#1779)
This commit is contained in:
1 parent
3630d6c157
commit
803a52c339
8 files changed
+278
-4
No files matched your search
@@ -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.
|
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:
|
Sample Docker Compose file. Remote desktop (RDP, VNC and Telnet) is built in, so nothing else needs to run next to it:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ function writeJson(filePath, value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULTS = { defaultConnectionOrigin: "local", updateMode: "prompt" };
|
const DEFAULTS = {
|
||||||
|
defaultConnectionOrigin: "local",
|
||||||
|
updateMode: "prompt",
|
||||||
|
macTrayIcon: "monochrome",
|
||||||
|
};
|
||||||
|
|
||||||
function getDesktopSettings() {
|
function getDesktopSettings() {
|
||||||
return { ...DEFAULTS, ...readJson(dataPath("desktop-settings.json"), {}) };
|
return { ...DEFAULTS, ...readJson(dataPath("desktop-settings.json"), {}) };
|
||||||
|
|||||||
+35
-3
@@ -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() {
|
function createTray() {
|
||||||
|
const macStyle = desktopSettings.getDesktopSettings().macTrayIcon;
|
||||||
|
if (process.platform === "darwin" && macStyle === "hidden") return;
|
||||||
try {
|
try {
|
||||||
const { nativeImage } = require("electron");
|
const { nativeImage } = require("electron");
|
||||||
|
|
||||||
@@ -1245,10 +1259,14 @@ function createTray() {
|
|||||||
|
|
||||||
let trayIcon;
|
let trayIcon;
|
||||||
if (process.platform === "darwin") {
|
if (process.platform === "darwin") {
|
||||||
const iconPath = path.join(publicRoot, "icons", "16x16.png");
|
const monochrome = macStyle !== "color";
|
||||||
// The logo is full color on a solid tile. As a template image macOS
|
const iconPath = path.join(
|
||||||
// keeps only its outline, which showed as an empty square.
|
publicRoot,
|
||||||
|
"icons",
|
||||||
|
monochrome ? "trayTemplate.png" : "16x16.png",
|
||||||
|
);
|
||||||
trayIcon = nativeImage.createFromPath(iconPath);
|
trayIcon = nativeImage.createFromPath(iconPath);
|
||||||
|
trayIcon.setTemplateImage(monochrome);
|
||||||
} else if (process.platform === "win32") {
|
} else if (process.platform === "win32") {
|
||||||
trayIcon = path.join(publicRoot, "icon.ico");
|
trayIcon = path.join(publicRoot, "icon.ico");
|
||||||
} else {
|
} else {
|
||||||
@@ -2000,6 +2018,20 @@ function createMenu() {
|
|||||||
label: app.name,
|
label: app.name,
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "about" },
|
{ 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" },
|
{ type: "separator" },
|
||||||
{ role: "services" },
|
{ role: "services" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 269 B |
@@ -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 |
@@ -63,6 +63,15 @@ writeFileSync(
|
|||||||
copyFileSync(join(publicDir, "icon.ico"), join(iconsDir, "icon.ico"));
|
copyFileSync(join(publicDir, "icon.ico"), join(iconsDir, "icon.ico"));
|
||||||
copyFileSync(join(publicDir, "icon.icns"), join(iconsDir, "icon.icns"));
|
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");
|
console.log("Icons generated from public/icon-square.svg");
|
||||||
|
|
||||||
function buildIco(pngBuffers, sizes) {
|
function buildIco(pngBuffers, sizes) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user