Files
NetMesh/electron/bridges/globalShortcutBridge.cjs

1374 lines
40 KiB
JavaScript
Raw Normal View History

/**
* Global Shortcut Bridge - Handles global keyboard shortcuts and system tray
* Implements the "Quake mode" / drop-down terminal feature
*/
const path = require("node:path");
const fs = require("node:fs");
const {
TRAY_PANEL_WIDTH,
TRAY_PANEL_HEIGHT,
resolveTrayAnchor,
resolveTrayDisplayPoint,
placeTrayPanel,
} = require("./trayPanelBounds.cjs");
let electronModule = null;
let ensureMainWindow = null;
let sendWhenRendererReady = null;
let getSystemMenuMainWindow = null;
let tray = null;
let closeToTray = false;
let currentHotkey = null;
let hotkeyEnabled = false;
// True while a hidden auto-launch cold start has no visible window yet.
// Keeps the tray alive even if the user's separate close-to-tray preference
// is off, so a --hidden login-item launch is never a windowless, trayless
// zombie process. Released once the main window is actually shown.
let hiddenLaunchTrayPinned = false;
const STATUS_TEXT = {
session: {
connected: "Connected",
connecting: "Connecting",
disconnected: "Disconnected",
},
portForward: {
active: "Active",
connecting: "Connecting",
inactive: "Inactive",
error: "Error",
},
};
// Dynamic tray menu data (synced from renderer)
let trayMenuData = {
sessions: [], // { id, label, hostLabel, status }
portForwardRules: [], // { id, label, type, localPort, remoteHost, remotePort, status, hostId, canStop }
hosts: [], // { id, label, hostname, group, pinned, lastConnectedAt }
};
let trayPanelWindow = null;
let appLockController = null;
/** @type {null | (() => void)} */
let unsubscribeAppLockRuntime = null;
/** Queued tray port-forward toggles deferred while the runtime is locked. */
let pendingPortForwardToggles = [];
/** Dock host connections deferred until App Lock is unlocked. */
let pendingHostConnections = [];
/** True after the tray panel renderer finishes its first load. */
let trayPanelReady = false;
let trayPanelShowWhenReady = false;
let trayPanelRefreshTimer = null;
// Watchdog: if `leave-full-screen` never arrives (edge case / stuck transition)
// we eventually give up and force a hide attempt. Better a visible window than
// a hung close-to-tray path.
const FULLSCREEN_LEAVE_WATCHDOG_MS = 5000;
// After `leave-full-screen` fires, macOS emits a trailing `show` event while
// the native space transition finishes. Calling `win.hide()` before that show
// causes the window to pop back on screen. We wait for the trailing show, or
// fall back on this timeout — whichever comes first.
const FULLSCREEN_TRAILING_SHOW_FALLBACK_MS = 300;
const pendingFullscreenHideByWindow = new WeakMap();
function notifyAppLockReopen(win) {
try {
if (!win || win.isDestroyed?.()) return;
win.webContents?.send?.("netcatty:app-lock:reopen");
} catch {
// ignore
}
}
function lockAppForBackground() {
try {
appLockController?.setLocked?.("background");
} catch {
// ignore
}
}
function isAppRuntimeLocked() {
try {
return appLockController?.getRuntimeState?.()?.locked === true;
} catch {
return false;
}
}
/** Redact session/port-forward details while locked (Codex P2). */
function getTrayMenuDataForDisplay() {
if (isAppRuntimeLocked()) {
return { sessions: [], portForwardRules: [] };
}
return trayMenuData;
}
function pushTrayMenuDataToPanel(win = trayPanelWindow) {
try {
if (!win || win.isDestroyed?.()) return;
win.webContents?.send?.("netcatty:trayPanel:setMenuData", getTrayMenuDataForDisplay());
} catch {
// ignore
}
}
/**
* Deliver a tray port-forward toggle, or queue it until the runtime unlocks.
* Showing the window + reopen still happens at the click site so the user can
* authenticate; tunnel start/stop must wait for unlock.
*/
function sendOrQueuePortForwardToggle(win, ruleId, start) {
if (!win) return;
if (isAppRuntimeLocked()) {
pendingPortForwardToggles = pendingPortForwardToggles.filter(
(item) => item.ruleId !== ruleId,
);
pendingPortForwardToggles.push({ win, ruleId, start: Boolean(start) });
return;
}
try {
win.webContents?.send?.("netcatty:tray:togglePortForward", ruleId, Boolean(start));
} catch {
// ignore
}
}
function flushPendingPortForwardToggles() {
if (isAppRuntimeLocked()) return;
const pending = pendingPortForwardToggles.splice(0);
for (const item of pending) {
try {
const win = item.win;
if (!win || win.isDestroyed?.()) continue;
win.webContents?.send?.("netcatty:tray:togglePortForward", item.ruleId, item.start);
} catch {
// ignore
}
}
}
function flushPendingHostConnections() {
if (isAppRuntimeLocked()) return;
const pending = pendingHostConnections.splice(0);
for (const hostId of pending) {
void sendToMainWindow("netcatty:trayPanel:connectToHost", hostId, { focus: false });
}
}
function bindAppLockRuntimeSubscription() {
if (typeof unsubscribeAppLockRuntime === "function") {
try {
unsubscribeAppLockRuntime();
} catch {
// ignore
}
unsubscribeAppLockRuntime = null;
}
if (!appLockController || typeof appLockController.subscribe !== "function") {
return;
}
try {
unsubscribeAppLockRuntime = appLockController.subscribe((state) => {
if (state?.locked === false) {
flushPendingPortForwardToggles();
flushPendingHostConnections();
pushTrayMenuDataToPanel();
updateTrayMenu();
updateDockMenu();
} else if (state?.locked === true) {
// Clear cached details from the tray panel/menu while locked.
pushTrayMenuDataToPanel();
updateTrayMenu();
updateDockMenu();
}
});
} catch {
unsubscribeAppLockRuntime = null;
}
}
function clearPendingFullscreenHide(win) {
if (!win || typeof win !== "object") return;
const pending = pendingFullscreenHideByWindow.get(win);
if (!pending) return;
if (pending.watchdogTimer) {
clearTimeout(pending.watchdogTimer);
pending.watchdogTimer = null;
}
if (pending.trailingShowTimer) {
clearTimeout(pending.trailingShowTimer);
pending.trailingShowTimer = null;
}
try {
if (pending.onLeaveFullScreen) {
win.removeListener?.("leave-full-screen", pending.onLeaveFullScreen);
}
if (pending.onClosed) {
win.removeListener?.("closed", pending.onClosed);
}
if (pending.onTrailingShow) {
win.removeListener?.("show", pending.onTrailingShow);
}
} catch {
// ignore
}
pendingFullscreenHideByWindow.delete(win);
}
function performPendingFullscreenHide(win) {
const pending = pendingFullscreenHideByWindow.get(win);
if (!pending) return "cancelled";
if (!win || win.isDestroyed?.()) {
clearPendingFullscreenHide(win);
return "cancelled";
}
clearPendingFullscreenHide(win);
try {
const windowManager = require("./windowManager.cjs");
windowManager.notifyWindowWillHide?.(win);
win.hide();
lockAppForBackground();
return "hidden";
} catch (err) {
console.warn("[GlobalShortcut] Error hiding window after leaving fullscreen:", err);
return "failed";
}
}
function handleLeaveFullScreenForPendingHide(win) {
const pending = pendingFullscreenHideByWindow.get(win);
if (!pending) return;
if (!win || win.isDestroyed?.()) {
clearPendingFullscreenHide(win);
return;
}
pending.leaveFullScreenFired = true;
if (pending.watchdogTimer) {
clearTimeout(pending.watchdogTimer);
pending.watchdogTimer = null;
}
// Wait for the trailing `show` that macOS emits as the space transition
// finishes, then hide on top of it. If it never fires within the fallback
// window, hide anyway.
pending.onTrailingShow = () => {
pending.onTrailingShow = null;
if (pending.trailingShowTimer) {
clearTimeout(pending.trailingShowTimer);
pending.trailingShowTimer = null;
}
performPendingFullscreenHide(win);
};
try {
win.once?.("show", pending.onTrailingShow);
} catch {
// ignore
}
pending.trailingShowTimer = setTimeout(() => {
pending.trailingShowTimer = null;
if (pending.onTrailingShow) {
try {
win.removeListener?.("show", pending.onTrailingShow);
} catch {
// ignore
}
pending.onTrailingShow = null;
}
performPendingFullscreenHide(win);
}, FULLSCREEN_TRAILING_SHOW_FALLBACK_MS);
}
function startPendingFullscreenHideWatchdog(win) {
const pending = pendingFullscreenHideByWindow.get(win);
if (!pending) return;
pending.watchdogTimer = setTimeout(() => {
pending.watchdogTimer = null;
if (!pendingFullscreenHideByWindow.has(win)) return;
if (!win || win.isDestroyed?.()) {
clearPendingFullscreenHide(win);
return;
}
if (pending.leaveFullScreenFired) return;
console.warn("[GlobalShortcut] Timed out waiting for leave-full-screen before hiding to tray; forcing hide");
// Give up and hide anyway. Simulate the leave path so the trailing-show
// wait still applies (defence in depth against spurious show events).
handleLeaveFullScreenForPendingHide(win);
}, FULLSCREEN_LEAVE_WATCHDOG_MS);
}
function bringMainWindowToForeground(win) {
if (!win || win.isDestroyed?.()) return false;
clearPendingFullscreenHide(win);
const windowManager = require("./windowManager.cjs");
const focused = windowManager.showAndFocusMainWindow?.(win) ?? false;
notifyAppLockReopen(win);
try {
electronModule?.app?.focus?.({ steal: true });
} catch {
// ignore
}
return focused;
}
function openMainWindow() {
bringMainWindowToForeground(getMainWindow());
}
function getTrackedMainWindow() {
if (typeof getSystemMenuMainWindow === "function") {
const win = getSystemMenuMainWindow();
if (win && !win.isDestroyed?.()) return win;
}
try {
const windowManager = require("./windowManager.cjs");
const tracked = windowManager.getMainWindow?.();
if (tracked && !tracked.isDestroyed?.()) return tracked;
} catch {
// ignore
}
return null;
}
async function getOrCreateMainWindow() {
const tracked = getTrackedMainWindow();
if (tracked) {
return { win: tracked, created: false };
}
if (typeof ensureMainWindow === "function") {
const win = await ensureMainWindow();
return { win, created: true };
}
return { win: null, created: false };
}
async function openMainWindowReady() {
const { win } = await getOrCreateMainWindow();
bringMainWindowToForeground(win);
return win;
}
async function sendToMainWindow(channel, payload, { focus = true, createIfMissing = true } = {}) {
const { win } = createIfMissing
? await getOrCreateMainWindow()
: { win: getTrackedMainWindow() };
if (!win) return false;
if (focus) {
bringMainWindowToForeground(win);
}
try {
if (typeof sendWhenRendererReady === "function") {
const result = await sendWhenRendererReady(win, channel, payload, { timeoutMs: 8000 });
if (!result?.success) {
console.warn(
`[GlobalShortcut] Failed to deliver ${channel} to main window:`,
result?.error || result?.reason || "unknown",
);
}
return result?.success === true;
}
win.webContents?.send(channel, payload);
return true;
} catch {
return false;
}
}
async function connectToHostFromSystemMenu(hostId) {
if (!hostId) return;
if (isAppRuntimeLocked()) {
pendingHostConnections = pendingHostConnections.filter((id) => id !== hostId);
pendingHostConnections.push(hostId);
await openMainWindowReady();
return;
}
await sendToMainWindow("netcatty:trayPanel:connectToHost", hostId);
}
function getTrayPanelUrl() {
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
if (devServerUrl) {
return `${devServerUrl.replace(/\/$/, "")}/#/tray`;
}
return "app://netcatty/index.html#/tray";
}
function ensureTrayPanelWindow() {
const { BrowserWindow } = electronModule;
if (trayPanelWindow && !trayPanelWindow.isDestroyed()) return trayPanelWindow;
const {
windowsCssRoundedOverlayChromeOptions,
} = require("./windowManager/windowsWindowChrome.cjs");
trayPanelReady = false;
trayPanelShowWhenReady = false;
trayPanelWindow = new BrowserWindow({
width: TRAY_PANEL_WIDTH,
height: TRAY_PANEL_HEIGHT,
show: false,
frame: false,
resizable: false,
movable: false,
fullscreenable: false,
minimizable: false,
maximizable: false,
skipTaskbar: true,
alwaysOnTop: true,
// Native shadow only. CSS box-shadow on this transparent overlay
// double-composites on macOS as an extra outline under the card.
hasShadow: true,
// Transparent host + clear backdrop so CSS rounded-lg corners are truly
// see-through. On Windows, disable OS rounding so it does not stack under
// the CSS radius (#2505 / Electron #46468).
...windowsCssRoundedOverlayChromeOptions(),
webPreferences: {
preload: path.join(__dirname, "../preload.cjs"),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
spellcheck: false,
// Tray must not inherit Chromium page-zoom from the main window origin.
zoomFactor: 1,
},
});
trayPanelWindow.webContents.on("console-message", (_event, level, message) => {
// Forward renderer logs to main process output for easy debugging.
console.log(`[TrayPanel:renderer:${level}] ${message}`);
});
trayPanelWindow.on("blur", () => {
try {
trayPanelWindow?.hide();
} catch {
// ignore
}
});
const url = getTrayPanelUrl();
console.log("[TrayPanel] loadURL", url);
void trayPanelWindow.loadURL(url);
trayPanelWindow.webContents.on("did-finish-load", () => {
trayPanelReady = true;
try {
pushTrayMenuDataToPanel(trayPanelWindow);
} catch {
// ignore
}
if (trayPanelShowWhenReady && trayPanelWindow && !trayPanelWindow.isDestroyed()) {
trayPanelShowWhenReady = false;
try {
trayPanelWindow.show();
trayPanelWindow.focus();
} catch {
// ignore
}
notifyAppLockReopen(trayPanelWindow);
}
});
return trayPanelWindow;
}
function readTrayBounds() {
try {
return tray?.getBounds?.() ?? null;
} catch {
return null;
}
}
function readCursorPoint() {
try {
return electronModule?.screen?.getCursorScreenPoint?.() ?? null;
} catch {
return null;
}
}
function showTrayPanel(eventBounds) {
if (!tray) return;
const { screen } = electronModule;
const win = ensureTrayPanelWindow();
const cursorPoint = readCursorPoint();
const trayBounds = readTrayBounds();
// Event bounds choose the monitor. Cursor is only the fallback so a
// Windows getBounds() y=0 lie cannot pick the wrong screen; keyboard /
// accessibility activation must not follow an unrelated pointer.
const display = screen.getDisplayNearestPoint(
resolveTrayDisplayPoint({ eventBounds, trayBounds, cursorPoint }),
);
const workArea = display.workArea;
const panelBounds = placeTrayPanel({
anchor: resolveTrayAnchor({ eventBounds, trayBounds, cursorPoint, workArea }),
workArea,
width: TRAY_PANEL_WIDTH,
height: TRAY_PANEL_HEIGHT,
});
win.setBounds(panelBounds, false);
// Wait for first paint/load so the opaque main-app splash cannot flash as a
// square underlay before the tray route clears it (#2505).
if (!trayPanelReady) {
trayPanelShowWhenReady = true;
} else {
win.show();
win.focus();
// Background-locked tray panel sits behind AppLockOverlay, which suppresses
// auto system-unlock until reopenSignal > 0. Emit reopen when the panel is
// shown so Touch ID/Hello can auto-prompt (Codex P3 on ffb25f81).
notifyAppLockReopen(win);
}
pushTrayMenuDataToPanel();
if (trayPanelRefreshTimer) clearInterval(trayPanelRefreshTimer);
trayPanelRefreshTimer = setInterval(() => {
try {
if (!trayPanelWindow || trayPanelWindow.isDestroyed() || !trayPanelWindow.isVisible()) return;
trayPanelWindow.webContents?.send("netcatty:trayPanel:refresh");
} catch {
// ignore
}
}, 1000);
}
function hideTrayPanel() {
trayPanelShowWhenReady = false;
if (trayPanelWindow && !trayPanelWindow.isDestroyed()) {
trayPanelWindow.hide();
}
if (trayPanelRefreshTimer) {
clearInterval(trayPanelRefreshTimer);
trayPanelRefreshTimer = null;
}
}
function toggleTrayPanel(eventBounds) {
// A pending first-load show counts as "open" so a second click cancels it
// instead of leaving did-finish-load to pop the panel open later.
const isOpenOrPending =
trayPanelShowWhenReady
|| (trayPanelWindow && !trayPanelWindow.isDestroyed() && trayPanelWindow.isVisible());
if (isOpenOrPending) {
hideTrayPanel();
} else {
showTrayPanel(eventBounds);
}
}
function resolveTrayIconPath() {
const { app } = electronModule;
// Platform-specific tray source:
// - macOS: template image (black + transparent, system handles tint)
// - Windows: multi-size .ico so the shell can pick the right pixel size
// per DPI scale (avoids blur at 125/150/175/250 % scale)
// - Linux: colored PNG (with an @2x representation attached at load time)
let iconName;
if (process.platform === "darwin") {
iconName = "tray-iconTemplate.png";
} else if (process.platform === "win32") {
iconName = "tray-icon.ico";
} else {
iconName = "tray-icon.png";
}
// Security: Only use known packaged icon locations, ignore renderer-provided paths
const candidates = [
path.join(app.getAppPath(), "dist", iconName),
path.join(app.getAppPath(), "public", iconName),
path.join(__dirname, "../../public", iconName),
path.join(__dirname, "../../dist", iconName),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
// Dev fallback: use the elf SVG directly (Electron can parse SVG buffers).
const svgCandidates = [
path.join(app.getAppPath(), "public", "senmesh-elf-logo.svg"),
path.join(__dirname, "../../public", "senmesh-elf-logo.svg"),
];
for (const candidate of svgCandidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
/**
* Initialize the bridge with dependencies
*/
function init(deps) {
electronModule = deps.electronModule;
appLockController = deps.getAppLockController?.() ?? null;
bindAppLockRuntimeSubscription();
ensureMainWindow = deps.ensureMainWindow || null;
sendWhenRendererReady = deps.sendWhenRendererReady || null;
getSystemMenuMainWindow = deps.getMainWindow || null;
updateDockMenu();
}
/**
* Get the main window reference
* Uses windowManager's tracked mainWindow for reliability
*/
function getMainWindow() {
// Prefer the explicitly tracked main window from windowManager
const windowManager = require("./windowManager.cjs");
const tracked = windowManager.getMainWindow?.();
if (tracked && !tracked.isDestroyed?.()) {
return tracked;
}
// Fallback: filter out tray panel window from all windows
const { BrowserWindow } = electronModule;
const wins = BrowserWindow.getAllWindows();
const mainWins = wins.filter((w) => w !== trayPanelWindow && !w.isDestroyed?.());
return mainWins && mainWins.length ? mainWins[0] : null;
}
function hideWindowRespectingMacFullscreen(win) {
if (!win || win.isDestroyed?.()) return false;
clearPendingFullscreenHide(win);
if (process.platform === "darwin" && win.isFullScreen?.()) {
// Close-to-tray on a native-fullscreen window on macOS has two traps:
//
// 1. `isFullScreen()` can flip to false BEFORE the exit animation
// completes. Polling it and calling `win.hide()` at that moment
// hides the window mid-transition, which macOS then undoes when
// the animation finishes.
// 2. Right after the real `leave-full-screen` event, macOS emits an
// internal `show` event as part of finalizing the space transition
// — this show undoes any earlier hide.
//
// Strategy: wait for `leave-full-screen`, then wait for the trailing
// `show` that follows it (or a short timeout), and only then hide.
// All legitimate "bring the window back" entry points (openMainWindow,
// toggleWindowVisibility, setCloseToTray(false), app.on("activate"),
// closed) explicitly call clearPendingFullscreenHide so we never race
// with genuine user intent.
const pending = {
watchdogTimer: null,
trailingShowTimer: null,
leaveFullScreenFired: false,
onLeaveFullScreen: null,
onClosed: null,
onTrailingShow: null,
};
pending.onLeaveFullScreen = () => {
handleLeaveFullScreenForPendingHide(win);
};
pending.onClosed = () => {
clearPendingFullscreenHide(win);
};
try {
pendingFullscreenHideByWindow.set(win, pending);
win.once?.("leave-full-screen", pending.onLeaveFullScreen);
win.once?.("closed", pending.onClosed);
startPendingFullscreenHideWatchdog(win);
win.setFullScreen(false);
return true;
} catch (err) {
clearPendingFullscreenHide(win);
console.warn("[GlobalShortcut] Error leaving fullscreen before hiding window:", err);
}
}
try {
const windowManager = require("./windowManager.cjs");
windowManager.notifyWindowWillHide?.(win);
win.hide();
lockAppForBackground();
return true;
} catch (err) {
console.warn("[GlobalShortcut] Error hiding window:", err);
return false;
}
}
/**
* Convert a hotkey string from frontend format to Electron accelerator format
* e.g., "⌘ + Space" -> "CommandOrControl+Space"
* "Ctrl + `" -> "CommandOrControl+`"
* "Alt + Space" -> "Alt+Space"
*/
function toElectronAccelerator(hotkeyStr) {
if (!hotkeyStr || hotkeyStr === "Disabled" || hotkeyStr === "") {
return null;
}
// Parse the hotkey string
const parts = hotkeyStr.split("+").map((p) => p.trim());
// Convert each part to Electron accelerator format
const acceleratorParts = parts.map((part) => {
// Mac symbols to Electron format
if (part === "⌘" || part === "Cmd" || part === "Command") {
return "CommandOrControl";
}
if (part === "⌃" || part === "Ctrl" || part === "Control") {
return "Control";
}
if (part === "⌥" || part === "Alt" || part === "Option") {
return "Alt";
}
if (part === "Shift") {
return "Shift";
}
if (part === "Win" || part === "Super" || part === "Meta") {
return "Super";
}
// Arrow symbols
if (part === "↑") return "Up";
if (part === "↓") return "Down";
if (part === "←") return "Left";
if (part === "→") return "Right";
// Special keys
if (part === "↵" || part === "Enter" || part === "Return") return "Return";
if (part === "⇥" || part === "Tab") return "Tab";
if (part === "⌫" || part === "Backspace") return "Backspace";
if (part === "Del" || part === "Delete") return "Delete";
if (part === "Esc" || part === "Escape") return "Escape";
if (part === "Space") return "Space";
// Backtick/grave accent
if (part === "`" || part === "~") return "`";
// Function keys
if (/^F\d+$/i.test(part)) return part.toUpperCase();
// Single character - keep as-is
return part;
});
return acceleratorParts.join("+");
}
/**
* Toggle the main window visibility
*/
function toggleWindowVisibility() {
const win = getMainWindow();
if (!win) return;
try {
// Check if window is minimized first - minimized windows may still report isVisible() = true
if (win.isMinimized()) {
bringMainWindowToForeground(win);
} else if (win.isVisible()) {
if (win.isFocused()) {
// Window is visible and focused - hide it
hideWindowRespectingMacFullscreen(win);
} else {
// Window is visible but not focused - focus it
bringMainWindowToForeground(win);
}
} else {
// Window is hidden - show and focus it
bringMainWindowToForeground(win);
}
} catch (err) {
console.warn("[GlobalShortcut] Error toggling window visibility:", err);
}
}
/**
* Register the global toggle hotkey
*/
function registerGlobalHotkey(hotkeyStr) {
const { globalShortcut } = electronModule;
// Unregister existing hotkey first
unregisterGlobalHotkey();
if (!hotkeyStr || hotkeyStr === "Disabled" || hotkeyStr === "") {
hotkeyEnabled = false;
currentHotkey = null;
return { success: true, enabled: false };
}
const accelerator = toElectronAccelerator(hotkeyStr);
if (!accelerator) {
hotkeyEnabled = false;
currentHotkey = null;
return { success: false, error: "Invalid hotkey format" };
}
try {
const registered = globalShortcut.register(accelerator, toggleWindowVisibility);
if (registered) {
hotkeyEnabled = true;
currentHotkey = hotkeyStr;
console.log(`[GlobalShortcut] Registered hotkey: ${accelerator}`);
return { success: true, enabled: true, accelerator };
} else {
console.warn(`[GlobalShortcut] Failed to register hotkey: ${accelerator}`);
return { success: false, error: "Hotkey may be in use by another application" };
}
} catch (err) {
console.error("[GlobalShortcut] Error registering hotkey:", err);
return { success: false, error: err.message };
}
}
/**
* Unregister the global toggle hotkey
*/
function unregisterGlobalHotkey() {
if (!hotkeyEnabled || !currentHotkey) return;
const { globalShortcut } = electronModule;
const accelerator = toElectronAccelerator(currentHotkey);
if (accelerator) {
try {
globalShortcut.unregister(accelerator);
console.log(`[GlobalShortcut] Unregistered hotkey: ${accelerator}`);
} catch (err) {
console.warn("[GlobalShortcut] Error unregistering hotkey:", err);
}
}
hotkeyEnabled = false;
currentHotkey = null;
}
/**
* Create the system tray icon
*/
function createTray() {
const { Tray, Menu, app, nativeImage } = electronModule;
if (tray) {
// Tray already exists
return;
}
try {
// Load the tray icon
let trayIcon;
const resolvedIconPath = resolveTrayIconPath();
if (resolvedIconPath) {
// SVG files need createFromBuffer; createFromPath works for PNG/ICO
// but Windows Tray sometimes refuses to render SVG loaded via path.
if (/\.svg$/i.test(resolvedIconPath)) {
const svgBuffer = fs.readFileSync(resolvedIconPath);
trayIcon = nativeImage.createFromBuffer(svgBuffer);
// Resize to a Windows-friendly tray size; Electron/Chromium rasterizes SVG.
if (!trayIcon.isEmpty()) {
trayIcon = trayIcon.resize({ width: 32, height: 32 });
}
} else {
trayIcon = nativeImage.createFromPath(resolvedIconPath);
}
if (process.platform === "darwin") {
trayIcon = trayIcon.resize({ width: 16, height: 16 });
trayIcon.setTemplateImage(true);
} else if (process.platform === "win32") {
// The .ico already carries 16/20/24/32/40/48/64 — Windows picks the
// right size per DPI scale on its own. Do not resize.
} else {
// Linux: attach the @2x representation so the shell can pick the
// right pixel size on HiDPI. Leaving the base at its native size
// (no force resize) keeps it crisp at 100 % too.
const hiDpiPath = resolvedIconPath.replace(/\.png$/i, "@2x.png");
if (fs.existsSync(hiDpiPath)) {
trayIcon.addRepresentation({
scaleFactor: 2,
buffer: fs.readFileSync(hiDpiPath),
});
}
}
}
tray = new Tray(trayIcon || nativeImage.createEmpty());
tray.setToolTip("NetMesh");
// Build and set initial context menu
updateTrayMenu();
// Click on tray icon behaviors depending on platform conventions
if (process.platform === "win32") {
// Windows: Left-click opens/focuses main window, Right-click toggles custom tray panel
tray.on("click", () => {
openMainWindow();
});
tray.on("right-click", (_event, bounds) => {
toggleTrayPanel(bounds);
});
} else if (process.platform === "linux") {
// Linux: GtkStatusIcon left-click can toggle the custom panel; StatusNotifier
// activation shows the native context menu set via setContextMenu() (there is
// no right-click / popUpContextMenu API on Linux — see Electron Tray docs).
tray.on("click", (_event, bounds) => {
toggleTrayPanel(bounds);
});
} else {
// macOS: Click toggles custom tray panel
tray.on("click", (_event, bounds) => {
toggleTrayPanel(bounds);
});
}
console.log("[GlobalShortcut] System tray created");
} catch (err) {
console.error("[GlobalShortcut] Error creating tray:", err);
}
}
/**
* Build the tray context menu with dynamic content
*/
function buildTrayMenuTemplate() {
const { app } = electronModule;
const menuTemplate = [];
// Open Main Window
menuTemplate.push({
label: "Open Main Window",
click: () => {
openMainWindow();
},
});
menuTemplate.push({ type: "separator" });
// Active Sessions
const displayTrayData = getTrayMenuDataForDisplay();
if (displayTrayData.sessions && displayTrayData.sessions.length > 0) {
menuTemplate.push({
label: "Sessions",
enabled: false,
});
for (const session of displayTrayData.sessions) {
const statusText =
session.status === "connected"
? STATUS_TEXT.session.connected
: session.status === "connecting"
? STATUS_TEXT.session.connecting
: STATUS_TEXT.session.disconnected;
menuTemplate.push({
label: ` ${session.hostLabel || session.label} (${statusText})`,
click: () => {
// AI silent sessions open a terminal popup from the renderer and must
// not be force-focused into a tab-less main-window surface.
void sendToMainWindow("netcatty:tray:focusSession", session.id, {
focus: session.aiHidden !== true,
});
},
});
}
menuTemplate.push({ type: "separator" });
}
// Port Forwarding Rules
if (displayTrayData.portForwardRules && displayTrayData.portForwardRules.length > 0) {
menuTemplate.push({
label: "Port Forwarding",
enabled: false,
});
for (const rule of displayTrayData.portForwardRules) {
const isActive = rule.status === "active";
const isConnecting = rule.status === "connecting";
const isStoppable = isActive || isConnecting || rule.canStop === true;
const statusText =
rule.status === "active"
? STATUS_TEXT.portForward.active
: rule.status === "connecting"
? STATUS_TEXT.portForward.connecting
: rule.status === "error"
? STATUS_TEXT.portForward.error
: STATUS_TEXT.portForward.inactive;
const typeLabel = rule.type === "local" ? "L" : rule.type === "remote" ? "R" : "D";
const portInfo = rule.type === "dynamic"
? `${rule.localPort}`
: `${rule.localPort}${rule.remoteHost}:${rule.remotePort}`;
menuTemplate.push({
label: ` [${typeLabel}] ${rule.label || portInfo} (${statusText})`,
enabled: !isConnecting,
click: () => {
const win = getMainWindow();
if (win) {
clearPendingFullscreenHide(win);
if (win.isMinimized()) win.restore();
win.show();
win.focus();
notifyAppLockReopen(win);
// Defer tunnel start/stop until unlock when the runtime is locked
// (e.g. background lock after hide-to-tray). Still surface the
// window so the user can authenticate.
sendOrQueuePortForwardToggle(win, rule.id, !isStoppable);
}
},
});
}
menuTemplate.push({ type: "separator" });
}
// Quit
menuTemplate.push({
label: "Quit",
click: () => {
closeToTray = false;
app.quit();
},
});
return menuTemplate;
}
function getDockHostLabel(host) {
const label = typeof host?.label === "string" ? host.label.trim() : "";
if (label) return label;
const hostname = typeof host?.hostname === "string" ? host.hostname.trim() : "";
return hostname || "Untitled Host";
}
function getDockHostLastConnectedAt(host) {
const value = Number(host?.lastConnectedAt);
return Number.isFinite(value) ? value : 0;
}
function getDockMenuHosts() {
return (Array.isArray(trayMenuData.hosts) ? trayMenuData.hosts : [])
.filter((host) => host && typeof host.id === "string" && host.id.length > 0)
.slice()
.sort((a, b) => {
if (Boolean(a.pinned) !== Boolean(b.pinned)) return a.pinned ? -1 : 1;
const recentDiff = getDockHostLastConnectedAt(b) - getDockHostLastConnectedAt(a);
if (recentDiff !== 0) return recentDiff;
return getDockHostLabel(a).localeCompare(getDockHostLabel(b), undefined, { sensitivity: "base" });
});
}
function buildDockMenuTemplate() {
const hostItems = (isAppRuntimeLocked() ? [] : getDockMenuHosts()).map((host) => ({
label: getDockHostLabel(host),
click: async () => {
await connectToHostFromSystemMenu(host.id);
},
}));
return [
{
label: "Open Main Window",
click: async () => {
await openMainWindowReady();
},
},
{ type: "separator" },
{
label: "New Connection",
enabled: hostItems.length > 0,
submenu: hostItems.length > 0
? hostItems
: [{ label: "No Saved Hosts", enabled: false }],
},
];
}
function updateDockMenu() {
if (!electronModule || process.platform !== "darwin") return;
const { Menu, app } = electronModule;
if (!Menu || !app?.dock?.setMenu) return;
try {
app.dock.setMenu(Menu.buildFromTemplate(buildDockMenuTemplate()));
} catch {
// ignore
}
}
/**
* Update the tray context menu
*/
function updateTrayMenu() {
if (!tray) return;
try {
if (process.platform === "linux") {
const { Menu } = electronModule;
const menu = Menu.buildFromTemplate(buildTrayMenuTemplate());
tray.setContextMenu(menu);
} else {
// Avoid showing a context menu on left-click; we toggle our custom panel instead.
// On macOS, right-click may still show a menu if one is set, so we don't set any.
tray.setContextMenu(null);
}
} catch {
// ignore
}
}
/**
* Update tray menu data from renderer
*/
function setTrayMenuData(data) {
if (data.sessions !== undefined) {
trayMenuData.sessions = data.sessions;
}
if (data.portForwardRules !== undefined) {
trayMenuData.portForwardRules = data.portForwardRules;
}
if (data.hosts !== undefined) {
trayMenuData.hosts = data.hosts;
}
// Rebuild menu with new data
updateTrayMenu();
updateDockMenu();
pushTrayMenuDataToPanel();
}
/**
* Destroy the system tray icon
*/
function destroyTray() {
if (tray) {
try {
tray.destroy();
tray = null;
console.log("[GlobalShortcut] System tray destroyed");
} catch (err) {
console.warn("[GlobalShortcut] Error destroying tray:", err);
}
}
}
/**
* Set close-to-tray behavior
*/
function setCloseToTray(enabled) {
closeToTray = !!enabled;
if (closeToTray) {
// Create tray if it doesn't exist
if (!tray) {
createTray();
}
} else {
clearPendingFullscreenHide(getMainWindow());
// A hidden auto-launch cold start pins the tray regardless of this
// preference until its window is actually shown once — otherwise a user
// with close-to-tray off would get a windowless, trayless zombie process.
if (!hiddenLaunchTrayPinned) {
destroyTray();
}
}
return { success: true, enabled: closeToTray };
}
/**
* Force-create the tray for a hidden auto-launch cold start and keep it
* alive even if close-to-tray is later disabled, until the pin is released.
*/
function pinTrayForHiddenLaunch() {
hiddenLaunchTrayPinned = true;
if (!tray) {
createTray();
}
}
/**
* Release the hidden-launch tray pin once its window has been shown. If the
* user's close-to-tray preference is off, the tray is destroyed now instead
* of lingering until the next close-to-tray toggle.
*/
function releaseHiddenLaunchTrayPin() {
if (!hiddenLaunchTrayPinned) return;
hiddenLaunchTrayPinned = false;
if (!closeToTray) {
destroyTray();
}
}
/**
* Check if close-to-tray is enabled
*/
function isCloseToTrayEnabled() {
return closeToTray;
}
/**
* Get current hotkey status
*/
function getHotkeyStatus() {
return {
enabled: hotkeyEnabled,
hotkey: currentHotkey,
};
}
/**
* Handle window close event - hide to tray instead of closing
*/
function handleWindowClose(event, win) {
if (closeToTray && tray) {
event.preventDefault();
hideWindowRespectingMacFullscreen(win);
return true; // Prevented close
}
return false; // Allow close
}
/**
* Register IPC handlers
*/
function registerHandlers(ipcMain) {
// Register global toggle hotkey
ipcMain.handle("netcatty:globalHotkey:register", async (_event, { hotkey }) => {
return registerGlobalHotkey(hotkey);
});
// Unregister global toggle hotkey
ipcMain.handle("netcatty:globalHotkey:unregister", async () => {
unregisterGlobalHotkey();
return { success: true };
});
// Get current hotkey status
ipcMain.handle("netcatty:globalHotkey:status", async () => {
return getHotkeyStatus();
});
// Set close-to-tray behavior
ipcMain.handle("netcatty:tray:setCloseToTray", async (_event, { enabled }) => {
return setCloseToTray(enabled);
});
// Get close-to-tray status
ipcMain.handle("netcatty:tray:isCloseToTray", async () => {
return { enabled: closeToTray };
});
// Update tray menu data
ipcMain.handle("netcatty:tray:updateMenuData", async (_event, data) => {
setTrayMenuData(data);
return { success: true };
});
ipcMain.handle("netcatty:trayPanel:hide", async () => {
hideTrayPanel();
return { success: true };
});
ipcMain.handle("netcatty:trayPanel:openMainWindow", async () => {
await openMainWindowReady();
return { success: true };
});
ipcMain.handle("netcatty:trayPanel:jumpToSession", async (_event, sessionId) => {
// Do not force-focus the main window here. Visible sessions open/focus it
// from the renderer; AI silent sessions open a terminal popup instead and
// should not steal focus into a tab-less main-window surface.
await sendToMainWindow("netcatty:trayPanel:jumpToSession", sessionId, {
focus: false,
});
return { success: true };
});
ipcMain.handle("netcatty:trayPanel:connectToHost", async (_event, hostId) => {
await connectToHostFromSystemMenu(hostId);
return { success: true };
});
ipcMain.handle("netcatty:trayPanel:closeSession", async (_event, sessionId) => {
const delivered = await sendToMainWindow("netcatty:trayPanel:closeSession", sessionId, {
focus: false,
createIfMissing: false,
});
return delivered
? { success: true }
: { success: false, error: "Main window is not available" };
});
ipcMain.handle("netcatty:trayPanel:quitApp", async () => {
const { app } = electronModule;
closeToTray = false;
app.quit();
return { success: true };
});
console.log("[GlobalShortcut] IPC handlers registered");
}
/**
* Cleanup on app quit
*/
function cleanup() {
unregisterGlobalHotkey();
destroyTray();
pendingPortForwardToggles = [];
pendingHostConnections = [];
if (typeof unsubscribeAppLockRuntime === "function") {
try {
unsubscribeAppLockRuntime();
} catch {
// ignore
}
unsubscribeAppLockRuntime = null;
}
if (electronModule?.app?.dock?.setMenu) {
try {
electronModule.app.dock.setMenu(null);
} catch {
// ignore
}
}
if (trayPanelRefreshTimer) {
clearInterval(trayPanelRefreshTimer);
trayPanelRefreshTimer = null;
}
if (trayPanelWindow && !trayPanelWindow.isDestroyed()) {
try {
trayPanelWindow.destroy();
} catch {
// ignore
}
trayPanelWindow = null;
}
trayPanelReady = false;
trayPanelShowWhenReady = false;
}
module.exports = {
init,
registerHandlers,
handleWindowClose,
clearPendingFullscreenHide,
cleanup,
createTray,
pinTrayForHiddenLaunch,
releaseHiddenLaunchTrayPin,
getTray: () => tray,
getTrayPanelWindow: () => trayPanelWindow,
// Test helpers
__flushPendingPortForwardTogglesForTests: flushPendingPortForwardToggles,
__isAppRuntimeLockedForTests: isAppRuntimeLocked,
__getPendingPortForwardTogglesForTests: () => pendingPortForwardToggles.slice(),
};