Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/apps/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import './issues/virtual-drive';
import './device/handlers';
import './../../backend/features/usage/handlers/handlers';
import './realtime';
import './tray/tray';
import './tray/handlers';
import './fordwardToWindows';
import './analytics/handlers';
Expand All @@ -42,7 +41,7 @@ import { getIsLoggedIn } from './auth/handlers';
import { getOrCreateWidged, getWidget, setBoundsOfWidgetByPath } from './windows/widget';
import { createAuthWindow, getAuthWindow } from './windows/auth';
import configStore from './config';
import { getTray, setTrayStatus } from './tray/tray';
import { getTray, setTrayStatus, setupTrayIcon } from './tray/tray-setup';
import { broadcastToWindows } from './windows';
import { openOnboardingWindow } from './windows/onboarding';
import { setupThemeListener, getTheme } from '../../core/theme';
Expand Down Expand Up @@ -117,6 +116,7 @@ app
*/
// await installNautilusExtension();
setupThemeListener();
setupTrayIcon();

eventBus.emit('APP_IS_READY');
const isLoggedIn = getIsLoggedIn();
Expand Down
2 changes: 1 addition & 1 deletion src/apps/main/tray/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { MainProcessSyncEngineIPC } from '../MainProcessSyncEngineIPC';
import { setTrayStatus } from './tray';
import { setTrayStatus } from './tray-setup';

MainProcessSyncEngineIPC.on('FOLDER_CREATING', () => {
setTrayStatus('SYNCING');
Expand Down
121 changes: 121 additions & 0 deletions src/apps/main/tray/tray-menu.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import PackageJson from '../../../../package.json';

const { trayHandlers, trayInstance, buildFromTemplateMock, createFromPathMock, TrayMock } = vi.hoisted(() => {
const trayHandlers = new Map<string, (...args: unknown[]) => unknown>();
const trayInstance = {
getBounds: vi.fn(() => ({ x: 1, y: 2, width: 3, height: 4 })),
setIgnoreDoubleClickEvents: vi.fn(),
on: vi.fn((event: string, handler: (...args: unknown[]) => unknown) => {
trayHandlers.set(event, handler);
}),
setContextMenu: vi.fn(),
setImage: vi.fn(),
setToolTip: vi.fn(),
destroy: vi.fn(),
};

const buildFromTemplateMock = vi.fn((template) => ({ template }));
const createFromPathMock = vi.fn((imagePath: string) => ({ imagePath }));
const TrayMock = vi.fn(() => trayInstance);

return {
trayHandlers,
trayInstance,
buildFromTemplateMock,
createFromPathMock,
TrayMock,
};
});

vi.mock('electron', () => ({
Menu: {
buildFromTemplate: buildFromTemplateMock,
},
nativeImage: {
createFromPath: createFromPathMock,
},
Tray: TrayMock,
}));

import { TrayMenu } from './tray-menu';

describe('tray-menu', () => {
beforeEach(() => {
trayHandlers.clear();
});

it('should initialize the tray in loading state', () => {
// Given
const onClick = vi.fn();
const onQuit = vi.fn();

// When
new TrayMenu('/icons', onClick, onQuit);

// Then
expect(TrayMock).toBeCalledWith('/icons/loading.png');
expect(trayInstance.setIgnoreDoubleClickEvents).toBeCalledWith(true);
expect(createFromPathMock).toBeCalledWith('/icons/loading.png');
expect(trayInstance.setImage).toBeCalledWith({ imagePath: '/icons/loading.png' });
expect(trayInstance.setToolTip).toBeCalledWith('Loading Internxt...');
});

it('should invoke onClick and clear the context menu on tray click', async () => {
// Given
const onClick = vi.fn().mockResolvedValue(undefined);
const onQuit = vi.fn();
new TrayMenu('/icons', onClick, onQuit);

// When
await trayHandlers.get('click')?.();

// Then
expect(onClick).toBeCalled();
expect(trayInstance.setContextMenu).toBeCalledWith(null);
});

it('should build and set the tray context menu', () => {
// Given
const onClick = vi.fn().mockResolvedValue(undefined);
const onQuit = vi.fn();
const trayMenu = new TrayMenu('/icons', onClick, onQuit);

// When
trayMenu.updateContextMenu();

// Then
expect(buildFromTemplateMock).toBeCalledWith([
{
label: 'Show/Hide',
click: expect.any(Function),
},
{
label: 'Quit',
click: onQuit,
},
]);
expect(trayInstance.setContextMenu).toBeCalledWith({
template: [
{
label: 'Show/Hide',
click: expect.any(Function),
},
{
label: 'Quit',
click: onQuit,
},
],
});
});

it('should update the tooltip for idle state', () => {
// Given
const trayMenu = new TrayMenu('/icons', vi.fn().mockResolvedValue(undefined), vi.fn());

// When
trayMenu.setState('IDLE');

// Then
expect(trayInstance.setToolTip).toBeCalledWith(`Internxt ${PackageJson.version}`);
});
});
88 changes: 88 additions & 0 deletions src/apps/main/tray/tray-menu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { Menu, nativeImage, Tray } from 'electron';
import path from 'node:path';
import PackageJson from '../../../../package.json';
import { TrayMenuState } from './types';

export class TrayMenu {
private readonly tray: Tray;

get bounds() {
return this.tray.getBounds();
}

constructor(
private readonly iconsPath: string,
private readonly onClick: () => Promise<void>,
private readonly onQuit: () => void,
) {
const trayIcon = this.getIconPath('LOADING');

this.tray = new Tray(trayIcon);

this.setState('LOADING');

this.tray.setIgnoreDoubleClickEvents(true);

this.tray.on('click', async () => {
await this.onClick();
this.tray.setContextMenu(null);
});
}

getIconPath(state: TrayMenuState) {
return path.join(this.iconsPath, `${state.toLowerCase()}.png`);
}

generateContextMenu() {
const contextMenuTemplate: Electron.MenuItemConstructorOptions[] = [];
contextMenuTemplate.push(
{
label: 'Show/Hide',
click: () => {
this.onClick();
},
},
{
label: 'Quit',
click: this.onQuit,
},
);

return Menu.buildFromTemplate(contextMenuTemplate);
}

updateContextMenu() {
const ctxMenu = this.generateContextMenu();
this.tray.setContextMenu(ctxMenu);
}

setState(state: TrayMenuState) {
const iconPath = this.getIconPath(state);
this.setImage(iconPath);

this.setTooltip(state);
}

setImage(imagePath: string) {
const image = nativeImage.createFromPath(imagePath);
this.tray.setImage(image);
}

setTooltip(state: TrayMenuState) {
const messages: Record<TrayMenuState, string> = {
SYNCING: 'Sync in process',
IDLE: `Internxt ${PackageJson.version}`,
ALERT: 'There are some issues with your sync',
LOADING: 'Loading Internxt...',
};

const message = messages[state];
this.tray.setToolTip(message);
}

destroy() {
if (this.tray) {
this.tray.destroy();
}
}
}
149 changes: 149 additions & 0 deletions src/apps/main/tray/tray-setup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { call, calls } from 'tests/vitest/utils.helper';

const {
mockApp,
mockGetIsLoggedIn,
mockGetOrCreateWidged,
mockSetBoundsOfWidgetByPath,
mockToggleWidgetVisibility,
mockShowAuthWindow,
mockGetAuthWindow,
trayMenuInstance,
TrayMenuMock,
} = vi.hoisted(() => {
const mockApp = {
isPackaged: false,
quit: vi.fn(),
};

const mockGetIsLoggedIn = vi.fn();
const mockGetOrCreateWidged = vi.fn();
const mockSetBoundsOfWidgetByPath = vi.fn();
const mockToggleWidgetVisibility = vi.fn();
const mockShowAuthWindow = vi.fn();
const mockGetAuthWindow = vi.fn(() => ({ show: mockShowAuthWindow }));

const trayMenuInstance = {
setState: vi.fn(),
bounds: { x: 1, y: 2, width: 3, height: 4 },
};

const TrayMenuMock = vi.fn(() => trayMenuInstance);

return {
mockApp,
mockGetIsLoggedIn,
mockGetOrCreateWidged,
mockSetBoundsOfWidgetByPath,
mockToggleWidgetVisibility,
mockShowAuthWindow,
mockGetAuthWindow,
trayMenuInstance,
TrayMenuMock,
};
});

vi.mock('./tray-menu', () => ({
TrayMenu: TrayMenuMock,
}));

vi.mock('../windows/widget', () => ({
getOrCreateWidged: mockGetOrCreateWidged,
setBoundsOfWidgetByPath: mockSetBoundsOfWidgetByPath,
toggleWidgetVisibility: mockToggleWidgetVisibility,
}));

vi.mock('../auth/handlers', () => ({
getIsLoggedIn: mockGetIsLoggedIn,
}));

vi.mock('../windows/auth', () => ({
getAuthWindow: mockGetAuthWindow,
}));

describe('tray-setup', () => {
beforeEach(() => {
vi.resetModules();
mockApp.isPackaged = false;
mockGetAuthWindow.mockReturnValue({ show: mockShowAuthWindow });
});

async function importTraySetup() {
return import('./tray-setup');
}

function getTrayClickHandler() {
const firstCall = TrayMenuMock.mock.calls[0] as unknown[] | undefined;

if (!firstCall) {
throw new Error('TrayMenu was not created');
}

const onClick = firstCall[1];

if (typeof onClick !== 'function') {
throw new Error('TrayMenu onClick handler was not registered');
}

return onClick as () => Promise<void>;
}

it('should create the tray only once', async () => {
// Given
const traySetup = await importTraySetup();

// When
const firstTray = traySetup.setupTrayIcon();
const secondTray = traySetup.setupTrayIcon();

// Then
calls(TrayMenuMock).toHaveLength(1);
expect(secondTray).toBe(firstTray);
expect(traySetup.getTray()).toBe(firstTray);
});

it('should update tray status through the singleton instance', async () => {
// Given
const traySetup = await importTraySetup();
traySetup.setupTrayIcon();

// When
traySetup.setTrayStatus('SYNCING');

// Then
call(trayMenuInstance.setState).toBe('SYNCING');
});

it('should show auth window when clicking the tray while logged out', async () => {
// Given
mockGetIsLoggedIn.mockReturnValue(false);
const traySetup = await importTraySetup();
traySetup.setupTrayIcon();
const onClick = getTrayClickHandler();

// When
await onClick();

// Then
calls(mockShowAuthWindow).toHaveLength(1);
calls(mockGetOrCreateWidged).toHaveLength(0);
calls(mockToggleWidgetVisibility).toHaveLength(0);
});

it('should align and toggle the widget when clicking the tray while logged in', async () => {
// Given
const widgetWindow = { id: 'widget' };
mockGetIsLoggedIn.mockReturnValue(true);
mockGetOrCreateWidged.mockResolvedValue(widgetWindow);
const traySetup = await importTraySetup();
const tray = traySetup.setupTrayIcon();
const onClick = getTrayClickHandler();

// When
await onClick();

// Then
call(mockSetBoundsOfWidgetByPath).toStrictEqual([widgetWindow, tray]);
calls(mockToggleWidgetVisibility).toHaveLength(1);
});
});
Loading
Loading