Skip to content

Commit

Permalink
feat: tray icon to allow NN to run in background (#520)
Browse files Browse the repository at this point in the history
* feat: tray icon to allow NN to run in background

* feat: tray icon shows node list with status and open app option

* fix: invert tray icons. may fix ubuntu

* feat: windows tray minimal compatibility
  • Loading branch information
jgresham authored Mar 27, 2024
1 parent 8c512f9 commit db1c629
Show file tree
Hide file tree
Showing 13 changed files with 244 additions and 12 deletions.
Binary file added assets/icons/tray/NNIconAlertInvertedTemplate.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/icons/tray/[email protected]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/icons/tray/NNIconAlertTemplate.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/icons/tray/[email protected]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/icons/tray/NNIconDefaultInvertedTemplate.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/icons/tray/[email protected]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/icons/tray/NNIconDefaultTemplate.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/icons/tray/[email protected]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
49 changes: 39 additions & 10 deletions src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* When running `npm run build` or `npm run build:main`, this file is compiled to
* `./src/main.js` using webpack. This gives us some performance wins.
*/
import path from 'path';
import path from 'node:path';
import { app, BrowserWindow, shell } from 'electron';
import * as Sentry from '@sentry/electron/main';

Expand All @@ -34,6 +34,7 @@ import * as updater from './updater';
import * as monitor from './monitor';
import * as cronJobs from './cronJobs';
import * as i18nMain from './i18nMain';
import * as tray from './tray';

if (process.env.NODE_ENV === 'development') {
require('dotenv').config();
Expand Down Expand Up @@ -73,7 +74,15 @@ if (isDevelopment) {
require('electron-debug')();
}

const createWindow = async () => {
const RESOURCES_PATH = app.isPackaged
? path.join(process.resourcesPath, 'assets')
: path.join(__dirname, '../../assets');

const getAssetPath = (...paths: string[]): string => {
return path.join(RESOURCES_PATH, ...paths);
};

export const createWindow = async () => {
if (isDevelopment) {
// https://github.com/MarshallOfSound/electron-devtools-installer/issues/238
await installExtension(REACT_DEVELOPER_TOOLS, {
Expand All @@ -83,14 +92,6 @@ const createWindow = async () => {
});
}

const RESOURCES_PATH = app.isPackaged
? path.join(process.resourcesPath, 'assets')
: path.join(__dirname, '../../assets');

const getAssetPath = (...paths: string[]): string => {
return path.join(RESOURCES_PATH, ...paths);
};

mainWindow = new BrowserWindow({
titleBarOverlay: true,
titleBarStyle: 'hiddenInset',
Expand Down Expand Up @@ -159,6 +160,33 @@ app.on('window-all-closed', () => {
}
});

// This is called by the tray icon "Quit" menu item
let isFullQuit = false;
export const fullQuit = () => {
isFullQuit = true;
app.quit();
};

// Emitted on app.quit() after all windows have been closed
app.on('will-quit', (e) => {
// Remove dev env check to test background. This is to prevent
// multiple instances of the app staying open in dev env where we
// regularly quit the app.
if (isFullQuit || process.env.NODE_ENV === 'development') {
console.log('quitting app from background');
app.quit();
} else {
console.log('quitting app from foreground');
// This allows NN to run in the background. The purpose is to keep a tray icon updated,
// monitor node's statuses and alert the user when a node is down, and to continuously
// track node usage.
e.preventDefault();
if (process.platform === 'darwin' && app.dock) {
app.dock.hide(); // app appears "quitted" in the dock
}
}
});

app
.whenReady()
.then(() => {
Expand Down Expand Up @@ -191,6 +219,7 @@ const initialize = () => {
monitor.initialize();
cronJobs.initialize();
i18nMain.initialize();
tray.initialize(getAssetPath);
console.log('app locale: ', app.getLocale());
console.log('app LocaleCountryCode: ', app.getLocaleCountryCode());
};
Expand Down
23 changes: 21 additions & 2 deletions src/main/podman/machine.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable no-restricted-syntax */
import logger from '../logger';
import { runCommand } from './podman';
import { MachineJSON } from './types';
import type { MachineJSON } from './types';

export const NICENODE_MACHINE_NAME = 'nicenode-machine';

Expand All @@ -14,7 +14,7 @@ export const getNiceNodeMachine = async (): Promise<
try {
const result = await runCommand(`machine list --format json`);
if (!result) {
// logger.error(`Podman machine ls result returned: ${result}`);
logger.error(`Podman machine ls result returned: ${result}`);
return undefined;
}

Expand Down Expand Up @@ -64,6 +64,25 @@ export const startMachineIfCreated = async (): Promise<boolean> => {
return false;
};

/**
* @returns false if the machine hasn't been stopped. true if the machine
* is stopped and stop command was sent or is already Stopping
*/
export const stopMachineIfCreated = async (): Promise<boolean> => {
try {
const nnMachine = await getNiceNodeMachine();
if (nnMachine) {
await runCommand(`machine stop ${NICENODE_MACHINE_NAME}`);
// todo: validate machine stopped properly
// consider: removing the machine here if the machine is stuck
return true;
}
} catch (err) {
logger.error('Error getting the machine.');
}
return false;
};

/**
*
* @returns an error message if it fails. undefined if successful.
Expand Down
1 change: 1 addition & 0 deletions src/main/podman/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type MachineJSON = {
Port: number;
RemoteUsername: string;
IdentityPath: string;
UserModeNetworking: boolean;
};

/**
Expand Down
2 changes: 2 additions & 0 deletions src/main/state/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ nativeTheme.on('updated', () => {
console.log("nativeTheme.on('updated')");
const settings = getSettings();

// bug: nativeTheme.shouldUseDarkColors stays true when OS theme changes to light and
// NN is set to dark mode
console.log(
'nativeTheme shouldUseDarkColors vs settings.osIsDarkMode',
nativeTheme.shouldUseDarkColors,
Expand Down
181 changes: 181 additions & 0 deletions src/main/tray.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { Menu, Tray, MenuItem } from 'electron';
import logger from './logger';
import { createWindow, fullQuit, getMainWindow } from './main';
import { getUserNodePackages } from './state/nodePackages';
import { isLinux, isWindows } from './platform';
import {
getNiceNodeMachine,
startMachineIfCreated,
stopMachineIfCreated,
} from './podman/machine';

// Can't import from main because of circular dependency
// eslint-disable-next-line no-underscore-dangle
let _getAssetPath: (...paths: string[]) => string;

let tray: Tray;

// Can get asyncronously updated separately
let nodePackageTrayMenu: { label: string; click: () => void }[] = [];
let podmanTrayMenu: MenuItem[] = [];
let openNiceNodeMenu: { label: string; click: () => void }[] = [];

// todo: define when to use alert icon. For notifications? For errors?
export const setTrayIcon = (style: 'Default' | 'Alert') => {
if (_getAssetPath) {
if (isWindows()) {
// Windows icon docs: https://learn.microsoft.com/en-us/windows/apps/design/style/iconography/app-icon-construction#icon-scaling
tray.setImage(_getAssetPath('icon.ico'));
} else {
tray.setImage(
_getAssetPath('icons', 'tray', `NNIcon${style}InvertedTemplate.png`),
);
}
}
};

export const setTrayMenu = () => {
const menuTemplate = [
// todo: change icon if there are any status errors
...nodePackageTrayMenu,
{ type: 'separator' },
// todo: show in developer mode

// todo: add podman status with start, stop, and delete?
...openNiceNodeMenu,
{
label: 'Full Quit',
click: () => {
fullQuit(); // app no longer runs in the background
},
},
];
if (process.env.NODE_ENV === 'development') {
menuTemplate.push(...podmanTrayMenu, { type: 'separator' });
}
const contextMenu = Menu.buildFromTemplate(menuTemplate as MenuItem[]);

if (tray) {
tray.setContextMenu(contextMenu);
}
};

const getOpenNiceNodeMenu = () => {
if (getMainWindow() === null) {
openNiceNodeMenu = [
{
label: 'Open NiceNode',
click: () => {
createWindow(); // app no longer runs in the background
},
},
];
} else {
openNiceNodeMenu = [];
}
setTrayMenu();
};

const getNodePackageListMenu = () => {
const userNodes = getUserNodePackages();
let isAlert = false;
nodePackageTrayMenu = userNodes.nodeIds.map((nodeId) => {
const nodePackage = userNodes.nodes[nodeId];
if (nodePackage.status.toLowerCase().includes('error')) {
isAlert = true;
}
return {
label: `${nodePackage.spec.displayName} Node ${nodePackage.status}`,
click: () => {
logger.info(`clicked on ${nodePackage.spec.displayName}`);
},
};
});
setTrayMenu();
if (isAlert) {
setTrayIcon('Alert');
} else {
setTrayIcon('Default');
}
};

const getPodmanMenuItem = async () => {
if (isLinux()) {
podmanTrayMenu = [];
return;
}
let status = 'Loading...';
try {
const podmanMachine = await getNiceNodeMachine();
if (podmanMachine) {
status = podmanMachine.Running ? 'Running' : 'Stopped';
if (podmanMachine.Starting === true) {
status = 'Starting';
}
}
} catch (e) {
console.error('tray podmanMachine error: ', e);
status = 'Not found';
}
podmanTrayMenu = [
new MenuItem({
label: `Podman ${status}`,
click: () => {
logger.info('clicked on podman machine');
// stop or start?
if (status === 'Running' || status === 'Starting') {
// stop
stopMachineIfCreated();
} else {
// try to start if any other start
startMachineIfCreated();
}
},
type: 'checkbox',
checked: status === 'Running',
}),
];
setTrayMenu();
};

export const updateTrayMenu = () => {
getPodmanMenuItem();
getNodePackageListMenu();
getOpenNiceNodeMenu();
};

export const initialize = (getAssetPath: (...paths: string[]) => string) => {
logger.info('tray initializing...');
_getAssetPath = getAssetPath;
let icon = getAssetPath('icons', 'tray', 'NNIconDefaultInvertedTemplate.png');
if (isWindows()) {
icon = getAssetPath('icon.ico');
}

tray = new Tray(icon);
// on windows, show a colored icon, 64x64 default icon seems ok
updateTrayMenu();
// Update the status of everything in the tray when it is opened
tray.on('click', () => {
// on windows, default is open/show window on click
// on mac, default is open menu on click (no code needed)
// on linux?
updateTrayMenu();
if (isWindows()) {
const window = getMainWindow();
if (window) {
// brings window to the foreground and/or maximizes
window.show();
} else {
// and if no window open yet
createWindow();
}
}
});
// on windows, the menu opens with a right click (no code needed)
// also, the 'right-click' event is not triggered on windows
logger.info('tray initialized');
};

// todo: handle a node status change
// this could include a new node, removed node, or just a status change

0 comments on commit db1c629

Please sign in to comment.