From e19841dd1d3ff769e0cc667109e428acd5e99bed Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Wed, 27 Aug 2025 11:02:57 -0400 Subject: [PATCH 01/16] feat: context bridge Signed-off-by: Adam Setch --- config/webpack.config.preload.base.ts | 27 ++ config/webpack.config.preload.prod.ts | 18 ++ config/webpack.config.renderer.base.ts | 25 +- config/webpack.paths.ts | 5 + package.json | 8 +- src/main/events.test.ts | 58 +++++ src/main/events.ts | 42 +++ src/main/index.ts | 105 +++++--- src/main/utils.ts | 6 +- src/preload/index.test.ts | 174 +++++++++++++ src/preload/index.ts | 132 ++++++++++ src/preload/preload.d.ts | 7 + src/preload/types.ts | 3 + src/preload/utils.test.ts | 69 +++++ src/preload/utils.ts | 35 +++ src/renderer/__mocks__/partial-mocks.ts | 2 +- src/renderer/__mocks__/state-mocks.ts | 2 +- src/renderer/components/AllRead.tsx | 2 +- src/renderer/components/Sidebar.tsx | 2 +- .../components/settings/SystemSettings.tsx | 3 +- src/renderer/{utils => }/constants.ts | 12 +- src/renderer/context/App.test.tsx | 8 +- src/renderer/context/App.tsx | 14 +- src/renderer/routes/LoginWithOAuthApp.tsx | 2 +- .../routes/LoginWithPersonalAccessToken.tsx | 2 +- src/renderer/utils/api/utils.ts | 2 +- src/renderer/utils/auth/utils.test.ts | 8 +- src/renderer/utils/auth/utils.ts | 22 +- src/renderer/utils/comms.test.ts | 246 ++++++++---------- src/renderer/utils/comms.ts | 69 ++--- src/renderer/utils/emojis.test.ts | 8 - src/renderer/utils/emojis.ts | 28 +- src/renderer/utils/helpers.ts | 2 +- src/renderer/utils/links.test.ts | 2 +- src/renderer/utils/links.ts | 6 +- src/renderer/utils/notifications/native.ts | 44 +--- src/renderer/utils/storage.test.ts | 2 +- src/renderer/utils/storage.ts | 2 +- src/shared/constants.ts | 4 + src/shared/events.ts | 48 +++- src/shared/theme.ts | 8 + 41 files changed, 918 insertions(+), 346 deletions(-) create mode 100644 config/webpack.config.preload.base.ts create mode 100644 config/webpack.config.preload.prod.ts create mode 100644 src/main/events.test.ts create mode 100644 src/main/events.ts create mode 100644 src/preload/index.test.ts create mode 100644 src/preload/index.ts create mode 100644 src/preload/preload.d.ts create mode 100644 src/preload/types.ts create mode 100644 src/preload/utils.test.ts create mode 100644 src/preload/utils.ts rename src/renderer/{utils => }/constants.ts (80%) delete mode 100644 src/renderer/utils/emojis.test.ts create mode 100644 src/shared/theme.ts diff --git a/config/webpack.config.preload.base.ts b/config/webpack.config.preload.base.ts new file mode 100644 index 000000000..a7dce5c5d --- /dev/null +++ b/config/webpack.config.preload.base.ts @@ -0,0 +1,27 @@ +import path from 'node:path'; + +import type webpack from 'webpack'; +import { merge } from 'webpack-merge'; + +import baseConfig from './webpack.config.common'; +import webpackPaths from './webpack.paths'; + +const configuration: webpack.Configuration = { + devtool: 'inline-source-map', + + mode: 'development', + + target: 'electron-preload', + + entry: [path.join(webpackPaths.srcPreloadPath, 'index.ts')], + + output: { + path: webpackPaths.buildPath, + filename: 'preload.js', + library: { + type: 'umd', + }, + }, +}; + +export default merge(baseConfig, configuration); diff --git a/config/webpack.config.preload.prod.ts b/config/webpack.config.preload.prod.ts new file mode 100644 index 000000000..60b91419d --- /dev/null +++ b/config/webpack.config.preload.prod.ts @@ -0,0 +1,18 @@ +import TerserPlugin from 'terser-webpack-plugin'; +import type webpack from 'webpack'; +import { merge } from 'webpack-merge'; + +import baseConfig from './webpack.config.preload.base'; + +const configuration: webpack.Configuration = { + devtool: 'source-map', + + mode: 'production', + + optimization: { + minimize: true, + minimizer: [new TerserPlugin()], + }, +}; + +export default merge(baseConfig, configuration); diff --git a/config/webpack.config.renderer.base.ts b/config/webpack.config.renderer.base.ts index bb70c7d96..a1e0b5535 100644 --- a/config/webpack.config.renderer.base.ts +++ b/config/webpack.config.renderer.base.ts @@ -1,15 +1,31 @@ import path from 'node:path'; +import twemoji from '@discordapp/twemoji'; import CopyWebpackPlugin from 'copy-webpack-plugin'; import HtmlWebpackPlugin from 'html-webpack-plugin'; import MiniCssExtractPlugin from 'mini-css-extract-plugin'; import webpack from 'webpack'; import { merge } from 'webpack-merge'; -import { ALL_EMOJI_SVG_FILENAMES } from '../src/renderer/utils/emojis'; +import { Constants } from '../src/renderer/constants'; +import { Errors } from '../src/renderer/utils/errors'; import baseConfig from './webpack.config.common'; import webpackPaths from './webpack.paths'; +const ALL_EMOJIS = [ + ...Constants.ALL_READ_EMOJIS, + ...Errors.BAD_CREDENTIALS.emojis, + ...Errors.MISSING_SCOPES.emojis, + ...Errors.NETWORK.emojis, + ...Errors.RATE_LIMITED.emojis, + ...Errors.UNKNOWN.emojis, +]; + +export const ALL_EMOJI_SVG_FILENAMES = ALL_EMOJIS.map((emoji) => { + const imgHtml = twemoji.parse(emoji, { folder: 'svg', ext: '.svg' }); + return extractSvgFilename(imgHtml); +}); + const configuration: webpack.Configuration = { devtool: 'inline-source-map', @@ -87,4 +103,11 @@ const configuration: webpack.Configuration = { ], }; +function extractSvgFilename(imgHtml: string) { + const srcMatch = /src="(.*)"/.exec(imgHtml); + const src = srcMatch ? srcMatch[1] : ''; + const filename = src.split('/').pop(); // Get the last part after splitting by "/" + return filename; +} + export default merge(baseConfig, configuration); diff --git a/config/webpack.paths.ts b/config/webpack.paths.ts index 50686e8a0..c878e42ba 100644 --- a/config/webpack.paths.ts +++ b/config/webpack.paths.ts @@ -5,7 +5,11 @@ const rootPath = path.join(__dirname, '..'); const nodeModulesPath = path.join(rootPath, 'node_modules'); const srcPath = path.join(rootPath, 'src'); + const srcMainPath = path.join(srcPath, 'main'); + +const srcPreloadPath = path.join(srcPath, 'preload'); + const srcRendererPath = path.join(srcPath, 'renderer'); const buildPath = path.join(rootPath, 'build'); @@ -17,6 +21,7 @@ export default { nodeModulesPath, srcPath, srcMainPath, + srcPreloadPath, srcRendererPath, buildPath, distPath, diff --git a/package.json b/package.json index d1e774d53..b0ba5a192 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,13 @@ "main": "build/main.js", "scripts": { "clean": "rimraf build coverage dist node_modules", - "build": "concurrently --names \"main,renderer\" --prefix-colors \"blue,green\" \"pnpm build:main\" \"pnpm build:renderer\"", + "build": "concurrently --names \"main,preload,renderer\" --prefix-colors \"blue,magenta,green\" \"pnpm build:main\" \"pnpm build:preload\" \"pnpm build:renderer\"", "build:main": "webpack --config ./config/webpack.config.main.prod.ts", + "build:preload": "webpack --config ./config/webpack.config.preload.prod.ts", "build:renderer": "webpack --config ./config/webpack.config.renderer.prod.ts", - "watch": "concurrently --names \"main,renderer\" --prefix-colors \"blue,green\" \"pnpm watch:main\" \"pnpm watch:renderer\"", + "watch": "concurrently --names \"main,preload,renderer\" --prefix-colors \"blue,magenta,green\" \"pnpm watch:main\" \"pnpm watch:preload\" \"pnpm watch:renderer\"", "watch:main": "webpack --watch --config ./config/webpack.config.main.base.ts", + "watch:preload": "webpack --watch --config ./config/webpack.config.preload.base.ts", "watch:renderer": "webpack --watch --config ./config/webpack.config.renderer.base.ts", "prepare:remove-source-maps": "ts-node ./scripts/delete-source-maps.ts", "package:linux": "electron-builder --linux --config ./config/electron-builder.js", @@ -139,4 +141,4 @@ "*": "biome check --fix --no-errors-on-unmatched", "*.{js,ts,tsx}": "pnpm test --findRelatedTests --passWithNoTests -u" } -} +} \ No newline at end of file diff --git a/src/main/events.test.ts b/src/main/events.test.ts new file mode 100644 index 000000000..7960e8767 --- /dev/null +++ b/src/main/events.test.ts @@ -0,0 +1,58 @@ +import { EVENTS } from '../shared/events'; + +const onMock = jest.fn(); +const handleMock = jest.fn(); + +jest.mock('electron', () => ({ + ipcMain: { + on: (...args: unknown[]) => onMock(...args), + handle: (...args: unknown[]) => handleMock(...args), + }, +})); + +import type { Menubar } from 'menubar'; + +import { handleMainEvent, onMainEvent, sendRendererEvent } from './events'; + +type MockMenubar = { window: { webContents: { send: jest.Mock } } }; + +describe('main/events', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('onMainEvent registers ipcMain.on listener', () => { + const listener = jest.fn(); + onMainEvent( + EVENTS.WINDOW_SHOW, + listener as unknown as (e: Electron.IpcMainEvent, d: unknown) => void, + ); + expect(onMock).toHaveBeenCalledWith(EVENTS.WINDOW_SHOW, listener); + }); + + it('handleMainEvent registers ipcMain.handle listener', () => { + const listener = jest.fn(); + handleMainEvent( + EVENTS.VERSION, + listener as unknown as ( + e: Electron.IpcMainInvokeEvent, + d: unknown, + ) => void, + ); + expect(handleMock).toHaveBeenCalledWith(EVENTS.VERSION, listener); + }); + + it('sendRendererEvent forwards event to webContents with data', () => { + const send = jest.fn(); + const mb: MockMenubar = { window: { webContents: { send } } }; + sendRendererEvent(mb as unknown as Menubar, EVENTS.UPDATE_TITLE, 'title'); + expect(send).toHaveBeenCalledWith(EVENTS.UPDATE_TITLE, 'title'); + }); + + it('sendRendererEvent forwards event without data', () => { + const send = jest.fn(); + const mb: MockMenubar = { window: { webContents: { send } } }; + sendRendererEvent(mb as unknown as Menubar, EVENTS.RESET_APP); + expect(send).toHaveBeenCalledWith(EVENTS.RESET_APP, undefined); + }); +}); diff --git a/src/main/events.ts b/src/main/events.ts new file mode 100644 index 000000000..35273c4fc --- /dev/null +++ b/src/main/events.ts @@ -0,0 +1,42 @@ +import { ipcMain } from 'electron'; +import type { Menubar } from 'menubar'; + +import type { EventData, EventType } from '../shared/events'; + +/** + * Handle main event without expecting a response + * @param event + * @param listener + */ +export function onMainEvent( + event: EventType, + listener: (event: Electron.IpcMainEvent, args: EventData) => void, +) { + ipcMain.on(event, listener); +} + +/** + * Handle main event and return a response + * @param event + * @param listener + */ +export function handleMainEvent( + event: EventType, + listener: (event: Electron.IpcMainInvokeEvent, data: EventData) => void, +) { + ipcMain.handle(event, listener); +} + +/** + * Send main event to renderer + * @param mb the menubar instance + * @param event the type of event to send + * @param data the data to send with the event + */ +export function sendRendererEvent( + mb: Menubar, + event: EventType, + data?: string, +) { + mb.window.webContents.send(event, data); +} diff --git a/src/main/index.ts b/src/main/index.ts index 265b5f060..5c3de8ec5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,12 +1,26 @@ -import { app, globalShortcut, ipcMain as ipc, safeStorage } from 'electron'; +import path from 'path'; + +import { + app, + type BrowserWindowConstructorOptions, + globalShortcut, + safeStorage, + shell, +} from 'electron'; import log from 'electron-log'; import { menubar } from 'menubar'; import { APPLICATION } from '../shared/constants'; -import { namespacedEvent } from '../shared/events'; +import { + EVENTS, + type IAutoLaunch, + type IKeyboardShortcut, + type IOpenExternal, +} from '../shared/events'; import { logInfo, logWarn } from '../shared/logger'; import { isLinux, isWindows } from '../shared/platform'; +import { handleMainEvent, onMainEvent, sendRendererEvent } from './events'; import { onFirstRunMaybe } from './first-run'; import { TrayIcons } from './icons'; import MenuBuilder from './menu'; @@ -14,23 +28,37 @@ import AppUpdater from './updater'; log.initialize(); -const browserWindowOpts = { +/** + * File paths + */ +const preloadFilePath = path.join(__dirname, 'preload.js'); +const indexHtmlFilePath = `file://${__dirname}/index.html`; +const notificationSoundFilePath = path.join( + __dirname, + '..', + 'assets', + 'sounds', + APPLICATION.NOTIFICATION_SOUND, +); +const twemojiDirPath = path.join(__dirname, 'images', 'twemoji'); + +const browserWindowOpts: BrowserWindowConstructorOptions = { width: 500, height: 400, minWidth: 500, minHeight: 400, resizable: false, skipTaskbar: true, // Hide the app from the Windows taskbar - // TODO #700 refactor to use preload script with a context bridge webPreferences: { - nodeIntegration: true, - contextIsolation: false, + preload: preloadFilePath, + contextIsolation: true, + nodeIntegration: false, }, }; const mb = menubar({ icon: TrayIcons.idle, - index: `file://${__dirname}/index.html`, + index: indexHtmlFilePath, browserWindow: browserWindowOpts, preloadWindow: true, showDockIcon: false, // Hide the app from the macOS dock @@ -113,30 +141,32 @@ app.whenReady().then(async () => { } /** - * Gitify custom IPC events + * Gitify custom IPC events - no response expected */ - ipc.handle(namespacedEvent('version'), () => app.getVersion()); + onMainEvent(EVENTS.WINDOW_SHOW, () => mb.showWindow()); - ipc.on(namespacedEvent('window-show'), () => mb.showWindow()); + onMainEvent(EVENTS.WINDOW_HIDE, () => mb.hideWindow()); - ipc.on(namespacedEvent('window-hide'), () => mb.hideWindow()); + onMainEvent(EVENTS.QUIT, () => mb.app.quit()); - ipc.on(namespacedEvent('quit'), () => mb.app.quit()); + onMainEvent(EVENTS.OPEN_EXTERNAL, (_, { url, activate }: IOpenExternal) => + shell.openExternal(url, { activate: activate }), + ); - ipc.on( - namespacedEvent('use-alternate-idle-icon'), - (_, useAlternateIdleIcon) => { + onMainEvent( + EVENTS.USE_ALTERNATE_IDLE_ICON, + (_, useAlternateIdleIcon: boolean) => { shouldUseAlternateIdleIcon = useAlternateIdleIcon; }, ); - ipc.on(namespacedEvent('icon-error'), () => { + onMainEvent(EVENTS.ICON_ERROR, () => { if (!mb.tray.isDestroyed()) { mb.tray.setImage(TrayIcons.error); } }); - ipc.on(namespacedEvent('icon-active'), () => { + onMainEvent(EVENTS.ICON_ACTIVE, () => { if (!mb.tray.isDestroyed()) { mb.tray.setImage( menuBuilder.isUpdateAvailable() @@ -146,7 +176,7 @@ app.whenReady().then(async () => { } }); - ipc.on(namespacedEvent('icon-idle'), () => { + onMainEvent(EVENTS.ICON_IDLE, () => { if (!mb.tray.isDestroyed()) { if (shouldUseAlternateIdleIcon) { mb.tray.setImage( @@ -164,15 +194,15 @@ app.whenReady().then(async () => { } }); - ipc.on(namespacedEvent('update-title'), (_, title) => { + onMainEvent(EVENTS.UPDATE_TITLE, (_, title: string) => { if (!mb.tray.isDestroyed()) { mb.tray.setTitle(title); } }); - ipc.on( - namespacedEvent('update-keyboard-shortcut'), - (_, { enabled, keyboardShortcut }) => { + onMainEvent( + EVENTS.UPDATE_KEYBOARD_SHORTCUT, + (_, { enabled, keyboardShortcut }: IKeyboardShortcut) => { if (!enabled) { globalShortcut.unregister(keyboardShortcut); return; @@ -188,18 +218,31 @@ app.whenReady().then(async () => { }, ); - ipc.on(namespacedEvent('update-auto-launch'), (_, settings) => { + onMainEvent(EVENTS.UPDATE_AUTO_LAUNCH, (_, settings: IAutoLaunch) => { app.setLoginItemSettings(settings); }); -}); -// Safe Storage -ipc.handle(namespacedEvent('safe-storage-encrypt'), (_, settings) => { - return safeStorage.encryptString(settings).toString('base64'); -}); + /** + * Gitify custom IPC events - response expected + */ + + handleMainEvent(EVENTS.VERSION, () => app.getVersion()); -ipc.handle(namespacedEvent('safe-storage-decrypt'), (_, settings) => { - return safeStorage.decryptString(Buffer.from(settings, 'base64')); + handleMainEvent(EVENTS.NOTIFICATION_SOUND_PATH, () => { + return notificationSoundFilePath; + }); + + handleMainEvent(EVENTS.TWEMOJI_DIRECTORY, () => { + return twemojiDirPath; + }); + + handleMainEvent(EVENTS.SAFE_STORAGE_ENCRYPT, (_, value: string) => { + return safeStorage.encryptString(value).toString('base64'); + }); + + handleMainEvent(EVENTS.SAFE_STORAGE_DECRYPT, (_, value: string) => { + return safeStorage.decryptString(Buffer.from(value, 'base64')); + }); }); // Handle gitify:// custom protocol URL events for OAuth 2.0 callback @@ -212,6 +255,6 @@ app.on('open-url', (event, url) => { const handleURL = (url: string) => { if (url.startsWith(`${protocol}://`)) { logInfo('main:handleUrl', `forwarding URL ${url} to renderer process`); - mb.window.webContents.send(namespacedEvent('auth-callback'), url); + sendRendererEvent(mb, EVENTS.AUTH_CALLBACK, url); } }; diff --git a/src/main/utils.ts b/src/main/utils.ts index 624a5ad9d..10754bfc1 100644 --- a/src/main/utils.ts +++ b/src/main/utils.ts @@ -7,9 +7,11 @@ import log from 'electron-log'; import type { Menubar } from 'menubar'; import { APPLICATION } from '../shared/constants'; -import { namespacedEvent } from '../shared/events'; +import { EVENTS } from '../shared/events'; import { logError, logInfo } from '../shared/logger'; +import { sendRendererEvent } from './events'; + export function takeScreenshot(mb: Menubar) { const date = new Date(); const dateStr = date.toISOString().replace(/:/g, '-'); @@ -39,7 +41,7 @@ export function resetApp(mb: Menubar) { }); if (response === resetButtonId) { - mb.window.webContents.send(namespacedEvent('reset-app')); + sendRendererEvent(mb, EVENTS.RESET_APP); mb.app.quit(); } } diff --git a/src/preload/index.test.ts b/src/preload/index.test.ts new file mode 100644 index 000000000..89a01dbe0 --- /dev/null +++ b/src/preload/index.test.ts @@ -0,0 +1,174 @@ +import { EVENTS } from '../shared/events'; + +// Mocks shared modules used inside preload +const sendMainEvent = jest.fn(); +const invokeMainEvent = jest.fn(); +const onRendererEvent = jest.fn(); +const logError = jest.fn(); + +jest.mock('./utils', () => ({ + sendMainEvent: (...args: unknown[]) => sendMainEvent(...args), + invokeMainEvent: (...args: unknown[]) => invokeMainEvent(...args), + onRendererEvent: (...args: unknown[]) => onRendererEvent(...args), +})); + +jest.mock('../shared/logger', () => ({ + logError: (...args: unknown[]) => logError(...args), +})); + +// We'll reconfigure the electron mock per context isolation scenario. +const exposeInMainWorld = jest.fn(); +const getZoomLevel = jest.fn(() => 1); +const setZoomLevel = jest.fn((_level: number) => undefined); + +jest.mock('electron', () => ({ + contextBridge: { + exposeInMainWorld: (key: string, value: unknown) => + exposeInMainWorld(key, value), + }, + webFrame: { + getZoomLevel: () => getZoomLevel(), + setZoomLevel: (level: number) => setZoomLevel(level), + }, +})); + +// Simple Notification stub +class MockNotification { + static instances: MockNotification[] = []; + public onclick: (() => void) | null = null; + constructor( + public title: string, + public options: { body: string; silent: boolean }, + ) { + MockNotification.instances.push(this); + } + triggerClick() { + this.onclick?.(); + } +} + +// Attach to global before importing preload +(global as unknown as { Notification: unknown }).Notification = + MockNotification; + +interface TestApi { + tray: { updateIcon: (n?: number) => void }; + openExternalLink: (u: string, f: boolean) => void; + app: { version: () => Promise; show?: () => void; hide?: () => void }; + onSystemThemeUpdate: (cb: (t: string) => void) => void; + raiseNativeNotification: (t: string, b: string, u?: string) => unknown; +} + +describe('preload/index', () => { + beforeEach(() => { + jest.clearAllMocks(); + // default to non-isolated environment for most tests + (process as unknown as { contextIsolated?: boolean }).contextIsolated = + false; + }); + + const importPreload = async () => { + // Ensure a fresh module instance each time + jest.resetModules(); + return await import('./index'); + }; + + it('exposes api on window when context isolation disabled', async () => { + await importPreload(); + const w = window as unknown as { gitify: Record }; + expect(w.gitify).toBeDefined(); + expect(exposeInMainWorld).not.toHaveBeenCalled(); + }); + + it('exposes api via contextBridge when context isolation enabled', async () => { + (process as unknown as { contextIsolated?: boolean }).contextIsolated = + true; + await importPreload(); + expect(exposeInMainWorld).toHaveBeenCalledTimes(1); + const [key, api] = exposeInMainWorld.mock.calls[0]; + expect(key).toBe('gitify'); + expect(api).toHaveProperty('openExternalLink'); + }); + + it('tray.updateIcon sends correct events', async () => { + await importPreload(); + const api = (window as unknown as { gitify: TestApi }).gitify; // casting only in test boundary + api.tray.updateIcon(-1); + api.tray.updateIcon(5); + api.tray.updateIcon(0); + expect(sendMainEvent).toHaveBeenNthCalledWith(1, EVENTS.ICON_ERROR); + expect(sendMainEvent).toHaveBeenNthCalledWith(2, EVENTS.ICON_ACTIVE); + expect(sendMainEvent).toHaveBeenNthCalledWith(3, EVENTS.ICON_IDLE); + }); + + it('openExternalLink sends event with payload', async () => { + await importPreload(); + const api = (window as unknown as { gitify: TestApi }).gitify; + api.openExternalLink('https://example.com', true); + expect(sendMainEvent).toHaveBeenCalledWith(EVENTS.OPEN_EXTERNAL, { + url: 'https://example.com', + activate: true, + }); + }); + + it('app.version returns dev in development', async () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + await importPreload(); + const api = (window as unknown as { gitify: TestApi }).gitify; + await expect(api.app.version()).resolves.toBe('dev'); + process.env.NODE_ENV = originalEnv; + }); + + it('app.version prefixes production version', async () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + invokeMainEvent.mockResolvedValueOnce('1.2.3'); + await importPreload(); + const api = (window as unknown as { gitify: TestApi }).gitify; + await expect(api.app.version()).resolves.toBe('v1.2.3'); + process.env.NODE_ENV = originalEnv; + }); + + it('onSystemThemeUpdate registers listener', async () => { + await importPreload(); + const api = (window as unknown as { gitify: TestApi }).gitify; + const callback = jest.fn(); + api.onSystemThemeUpdate(callback); + expect(onRendererEvent).toHaveBeenCalledWith( + EVENTS.UPDATE_THEME, + expect.any(Function), + ); + // Simulate event + const listener = onRendererEvent.mock.calls[0][1]; + listener({}, 'dark'); + expect(callback).toHaveBeenCalledWith('dark'); + }); + + it('raiseNativeNotification without url calls app.show', async () => { + await importPreload(); + const api = (window as unknown as { gitify: TestApi }).gitify; + api.app.show = jest.fn(); + const notification = api.raiseNativeNotification( + 'Title', + 'Body', + ) as MockNotification; + notification.triggerClick(); + expect(api.app.show).toHaveBeenCalled(); + }); + + it('raiseNativeNotification with url hides app then opens link', async () => { + await importPreload(); + const api = (window as unknown as { gitify: TestApi }).gitify; + api.app.hide = jest.fn(); + api.openExternalLink = jest.fn(); + const notification = api.raiseNativeNotification( + 'Title', + 'Body', + 'https://x', + ) as MockNotification; + notification.triggerClick(); + expect(api.app.hide).toHaveBeenCalled(); + expect(api.openExternalLink).toHaveBeenCalledWith('https://x', true); + }); +}); diff --git a/src/preload/index.ts b/src/preload/index.ts new file mode 100644 index 000000000..5948885f7 --- /dev/null +++ b/src/preload/index.ts @@ -0,0 +1,132 @@ +import { contextBridge, webFrame } from 'electron'; + +import { APPLICATION } from '../shared/constants'; +import { EVENTS } from '../shared/events'; +import { logError } from '../shared/logger'; +import { isLinux, isMacOS, isWindows } from '../shared/platform'; + +import { invokeMainEvent, onRendererEvent, sendMainEvent } from './utils'; + +export const api = { + openExternalLink: (url: string, openInForeground: boolean) => { + sendMainEvent(EVENTS.OPEN_EXTERNAL, { + url: url, + activate: openInForeground, + }); + }, + + encryptValue: (value: string) => + invokeMainEvent(EVENTS.SAFE_STORAGE_ENCRYPT, value), + + decryptValue: (value: string) => + invokeMainEvent(EVENTS.SAFE_STORAGE_DECRYPT, value), + + setAutoLaunch: (value: boolean) => + sendMainEvent(EVENTS.UPDATE_AUTO_LAUNCH, { + openAtLogin: value, + openAsHidden: value, + }), + + setKeyboardShortcut: (keyboardShortcut: boolean) => { + sendMainEvent(EVENTS.UPDATE_KEYBOARD_SHORTCUT, { + enabled: keyboardShortcut, + keyboardShortcut: APPLICATION.DEFAULT_KEYBOARD_SHORTCUT, + }); + }, + + tray: { + updateIcon: (notificationsLength = 0) => { + if (notificationsLength < 0) { + sendMainEvent(EVENTS.ICON_ERROR); + return; + } + + if (notificationsLength > 0) { + sendMainEvent(EVENTS.ICON_ACTIVE); + return; + } + + sendMainEvent(EVENTS.ICON_IDLE); + }, + + updateTitle: (title = '') => sendMainEvent(EVENTS.UPDATE_TITLE, title), + + useAlternateIdleIcon: (value: boolean) => + sendMainEvent(EVENTS.USE_ALTERNATE_IDLE_ICON, value), + }, + + notificationSoundPath: () => invokeMainEvent(EVENTS.NOTIFICATION_SOUND_PATH), + + twemojiDirectory: () => invokeMainEvent(EVENTS.TWEMOJI_DIRECTORY), + + platform: { + isLinux: () => isLinux(), + + isMacOS: () => isMacOS(), + + isWindows: () => isWindows(), + }, + + app: { + hide: () => sendMainEvent(EVENTS.WINDOW_HIDE), + + show: () => sendMainEvent(EVENTS.WINDOW_SHOW), + + quit: () => sendMainEvent(EVENTS.QUIT), + + version: async () => { + if (process.env.NODE_ENV === 'development') { + return 'dev'; + } + + const version = await invokeMainEvent(EVENTS.VERSION); + + return `v${version}`; + }, + }, + + zoom: { + getLevel: () => webFrame.getZoomLevel(), + + setLevel: (zoomLevel: number) => webFrame.setZoomLevel(zoomLevel), + }, + + onResetApp: (callback: () => void) => { + onRendererEvent(EVENTS.RESET_APP, () => callback()); + }, + + onAuthCallback: (callback: (url: string) => void) => { + onRendererEvent(EVENTS.AUTH_CALLBACK, (_, url) => callback(url)); + }, + + onSystemThemeUpdate: (callback: (theme: string) => void) => { + onRendererEvent(EVENTS.UPDATE_THEME, (_, theme) => callback(theme)); + }, + + raiseNativeNotification: (title: string, body: string, url?: string) => { + const notification = new Notification(title, { body: body, silent: true }); + notification.onclick = () => { + if (url) { + api.app.hide(); + api.openExternalLink(url, true); + } else { + api.app.show(); + } + }; + + return notification; + }, +}; + +// Use `contextBridge` APIs to expose Electron APIs to +// renderer only if context isolation is enabled, otherwise +// just add to the DOM global. +if (process.contextIsolated) { + try { + contextBridge.exposeInMainWorld('gitify', api); + } catch (error) { + logError('preload', 'Failed to expose API to renderer', error); + } +} else { + window.gitify = api; +} diff --git a/src/preload/preload.d.ts b/src/preload/preload.d.ts new file mode 100644 index 000000000..f66dd5101 --- /dev/null +++ b/src/preload/preload.d.ts @@ -0,0 +1,7 @@ +import type { GitifyAPI } from './types'; + +declare global { + interface Window { + gitify: GitifyAPI; + } +} diff --git a/src/preload/types.ts b/src/preload/types.ts new file mode 100644 index 000000000..9b3508825 --- /dev/null +++ b/src/preload/types.ts @@ -0,0 +1,3 @@ +import type { api } from '.'; + +export type GitifyAPI = typeof api; diff --git a/src/preload/utils.test.ts b/src/preload/utils.test.ts new file mode 100644 index 000000000..9af3be541 --- /dev/null +++ b/src/preload/utils.test.ts @@ -0,0 +1,69 @@ +import { EVENTS } from '../shared/events'; + +import { invokeMainEvent, onRendererEvent, sendMainEvent } from './utils'; + +jest.mock('electron', () => { + type Listener = (event: unknown, ...args: unknown[]) => void; + const listeners: Record = {}; + return { + ipcRenderer: { + send: jest.fn(), + invoke: jest.fn().mockResolvedValue('response'), + on: jest.fn((channel: string, listener: Listener) => { + if (!listeners[channel]) { + listeners[channel] = []; + } + listeners[channel].push(listener); + }), + __emit: (channel: string, ...args: unknown[]) => { + const list = listeners[channel] || []; + for (const l of list) { + l({}, ...args); + } + }, + __listeners: listeners, + }, + }; +}); + +import { ipcRenderer } from 'electron'; + +describe('preload/utils', () => { + afterEach(() => { + (ipcRenderer.send as jest.Mock).mockClear(); + (ipcRenderer.invoke as jest.Mock).mockClear(); + (ipcRenderer.on as jest.Mock).mockClear(); + }); + + it('sendMainEvent forwards to ipcRenderer.send', () => { + sendMainEvent(EVENTS.WINDOW_SHOW); + expect(ipcRenderer.send).toHaveBeenCalledWith( + EVENTS.WINDOW_SHOW, + undefined, + ); + }); + + it('invokeMainEvent forwards and resolves', async () => { + const result = await invokeMainEvent(EVENTS.VERSION, 'data'); + expect(ipcRenderer.invoke).toHaveBeenCalledWith(EVENTS.VERSION, 'data'); + expect(result).toBe('response'); + }); + + it('onRendererEvent registers listener and receives emitted data', () => { + const handler = jest.fn(); + onRendererEvent( + EVENTS.UPDATE_TITLE, + handler as unknown as ( + e: Electron.IpcRendererEvent, + args: string, + ) => void, + ); + ( + ipcRenderer as unknown as { + __emit: (channel: string, ...a: unknown[]) => void; + } + ).__emit(EVENTS.UPDATE_TITLE, 'payload'); + expect(ipcRenderer.on).toHaveBeenCalledWith(EVENTS.UPDATE_TITLE, handler); + expect(handler).toHaveBeenCalledWith({}, 'payload'); + }); +}); diff --git a/src/preload/utils.ts b/src/preload/utils.ts new file mode 100644 index 000000000..dcf6c681c --- /dev/null +++ b/src/preload/utils.ts @@ -0,0 +1,35 @@ +import { ipcRenderer } from 'electron'; + +import type { EventData, EventType } from '../shared/events'; + +/** + * Send renderer event without expecting a response + * @param event the type of event to send + * @param data the data to send with the event + */ +export function sendMainEvent(event: EventType, data?: EventData): void { + ipcRenderer.send(event, data); +} + +/** + * Send renderer event and expect a response + * @param event the type of event to send + * @param data the data to send with the event + * @returns + */ +export function invokeMainEvent( + event: EventType, + data?: string, +): Promise { + return ipcRenderer.invoke(event, data); +} + +/** + * Handle renderer event without expecting a response + */ +export function onRendererEvent( + event: EventType, + listener: (event: Electron.IpcRendererEvent, args: string) => void, +) { + ipcRenderer.on(event, listener); +} diff --git a/src/renderer/__mocks__/partial-mocks.ts b/src/renderer/__mocks__/partial-mocks.ts index 04ff306bc..b98d1890e 100644 --- a/src/renderer/__mocks__/partial-mocks.ts +++ b/src/renderer/__mocks__/partial-mocks.ts @@ -1,6 +1,6 @@ +import { Constants } from '../constants'; import type { Hostname, Link } from '../types'; import type { Notification, Subject, User } from '../typesGitHub'; -import { Constants } from '../utils/constants'; import { mockGitifyUser, mockToken } from './state-mocks'; export function partialMockNotification( diff --git a/src/renderer/__mocks__/state-mocks.ts b/src/renderer/__mocks__/state-mocks.ts index a05dae93d..377000f3f 100644 --- a/src/renderer/__mocks__/state-mocks.ts +++ b/src/renderer/__mocks__/state-mocks.ts @@ -1,3 +1,4 @@ +import { Constants } from '../constants'; import { type Account, type AppearanceSettingsState, @@ -15,7 +16,6 @@ import { Theme, type Token, } from '../types'; -import { Constants } from '../utils/constants'; export const mockGitifyUser: GitifyUser = { login: 'octocat', diff --git a/src/renderer/components/AllRead.tsx b/src/renderer/components/AllRead.tsx index 5dca686a7..7de8f6276 100644 --- a/src/renderer/components/AllRead.tsx +++ b/src/renderer/components/AllRead.tsx @@ -1,7 +1,7 @@ import { type FC, useContext, useMemo } from 'react'; +import { Constants } from '../constants'; import { AppContext } from '../context/App'; -import { Constants } from '../utils/constants'; import { hasAnyFiltersSet } from '../utils/notifications/filters/filter'; import { EmojiSplash } from './layout/EmojiSplash'; diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx index c01c15c23..c599a7518 100644 --- a/src/renderer/components/Sidebar.tsx +++ b/src/renderer/components/Sidebar.tsx @@ -14,9 +14,9 @@ import { IconButton, Stack } from '@primer/react'; import { APPLICATION } from '../../shared/constants'; +import { Constants } from '../constants'; import { AppContext } from '../context/App'; import { quitApp } from '../utils/comms'; -import { Constants } from '../utils/constants'; import { openGitHubIssues, openGitHubNotifications, diff --git a/src/renderer/components/settings/SystemSettings.tsx b/src/renderer/components/settings/SystemSettings.tsx index a69c337b0..7d14fad0d 100644 --- a/src/renderer/components/settings/SystemSettings.tsx +++ b/src/renderer/components/settings/SystemSettings.tsx @@ -16,7 +16,6 @@ import { isLinux, isMacOS } from '../../../shared/platform'; import { AppContext } from '../../context/App'; import { defaultSettings } from '../../context/defaults'; import { OpenPreference } from '../../types'; -import { Constants } from '../../utils/constants'; import { Checkbox } from '../fields/Checkbox'; import { RadioGroup } from '../fields/RadioGroup'; import { VolumeDownIcon } from '../icons/VolumeDownIcon'; @@ -55,7 +54,7 @@ export const SystemSettings: FC = () => { When enabled you can use the hotkeys{' '} - {Constants.DEFAULT_KEYBOARD_SHORTCUT} + {APPLICATION.DEFAULT_KEYBOARD_SHORTCUT} {' '} to show or hide {APPLICATION.NAME}. diff --git a/src/renderer/utils/constants.ts b/src/renderer/constants.ts similarity index 80% rename from src/renderer/utils/constants.ts rename to src/renderer/constants.ts index 78ea0c106..d71f62554 100644 --- a/src/renderer/utils/constants.ts +++ b/src/renderer/constants.ts @@ -1,13 +1,8 @@ -import type { ClientID, ClientSecret, Hostname, Link } from '../types'; +import type { ClientID, ClientSecret, Hostname, Link } from './types'; export const Constants = { - REPO_SLUG: 'gitify-app/gitify', - - // Storage STORAGE_KEY: 'gitify-storage', - NOTIFICATION_SOUND: 'clearly.mp3', - // GitHub OAuth Scopes OAUTH_SCOPES: { RECOMMENDED: ['read:user', 'notifications', 'repo'], @@ -25,10 +20,9 @@ export const Constants = { ALL_READ_EMOJIS: ['๐ŸŽ‰', '๐ŸŽŠ', '๐Ÿฅณ', '๐Ÿ‘', '๐Ÿ™Œ', '๐Ÿ˜Ž', '๐Ÿ–๏ธ', '๐Ÿš€', 'โœจ', '๐Ÿ†'], - FETCH_NOTIFICATIONS_INTERVAL: 60000, - REFRESH_ACCOUNTS_INTERVAL: 3600000, + FETCH_NOTIFICATIONS_INTERVAL_MS: 60 * 1000, // 1 minute - DEFAULT_KEYBOARD_SHORTCUT: 'CommandOrControl+Shift+G', + REFRESH_ACCOUNTS_INTERVAL_MS: 60 * 60 * 1000, // 1 hour // GitHub Docs GITHUB_DOCS: { diff --git a/src/renderer/context/App.test.tsx b/src/renderer/context/App.test.tsx index b04c78297..07dcc1815 100644 --- a/src/renderer/context/App.test.tsx +++ b/src/renderer/context/App.test.tsx @@ -2,12 +2,12 @@ import { act, fireEvent, render, waitFor } from '@testing-library/react'; import { useContext } from 'react'; import { mockAuth, mockSettings } from '../__mocks__/state-mocks'; +import { Constants } from '../constants'; import { useNotifications } from '../hooks/useNotifications'; import type { AuthState, Hostname, SettingsState, Token } from '../types'; import { mockSingleNotification } from '../utils/api/__mocks__/response-mocks'; import * as apiRequests from '../utils/api/request'; import * as comms from '../utils/comms'; -import { Constants } from '../utils/constants'; import * as notifications from '../utils/notifications/notifications'; import * as storage from '../utils/storage'; import { AppContext, AppProvider } from './App'; @@ -77,19 +77,19 @@ describe('renderer/context/App.tsx', () => { ); act(() => { - jest.advanceTimersByTime(Constants.FETCH_NOTIFICATIONS_INTERVAL); + jest.advanceTimersByTime(Constants.FETCH_NOTIFICATIONS_INTERVAL_MS); return; }); expect(fetchNotificationsMock).toHaveBeenCalledTimes(2); act(() => { - jest.advanceTimersByTime(Constants.FETCH_NOTIFICATIONS_INTERVAL); + jest.advanceTimersByTime(Constants.FETCH_NOTIFICATIONS_INTERVAL_MS); return; }); expect(fetchNotificationsMock).toHaveBeenCalledTimes(3); act(() => { - jest.advanceTimersByTime(Constants.FETCH_NOTIFICATIONS_INTERVAL); + jest.advanceTimersByTime(Constants.FETCH_NOTIFICATIONS_INTERVAL_MS); return; }); expect(fetchNotificationsMock).toHaveBeenCalledTimes(4); diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index 3049a418c..15d0ac14d 100644 --- a/src/renderer/context/App.tsx +++ b/src/renderer/context/App.tsx @@ -7,12 +7,9 @@ import { useState, } from 'react'; -import { ipcRenderer, webFrame } from 'electron'; - import { useTheme } from '@primer/react'; -import { namespacedEvent } from '../../shared/events'; - +import { Constants } from '../constants'; import { useInterval } from '../hooks/useInterval'; import { useNotifications } from '../hooks/useNotifications'; import type { @@ -49,7 +46,6 @@ import { setKeyboardShortcut, updateTrayTitle, } from '../utils/comms'; -import { Constants } from '../utils/constants'; import { getNotificationCount } from '../utils/notifications/notifications'; import { clearState, loadState, saveState } from '../utils/storage'; import { @@ -145,13 +141,13 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { useInterval(() => { fetchNotifications({ auth, settings }); - }, Constants.FETCH_NOTIFICATIONS_INTERVAL); + }, Constants.FETCH_NOTIFICATIONS_INTERVAL_MS); useInterval(() => { for (const account of auth.accounts) { refreshAccount(account); } - }, Constants.REFRESH_ACCOUNTS_INTERVAL); + }, Constants.REFRESH_ACCOUNTS_INTERVAL_MS); useEffect(() => { const count = getNotificationCount(notifications); @@ -168,7 +164,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { }, [settings.keyboardShortcut]); useEffect(() => { - ipcRenderer.on(namespacedEvent('reset-app'), () => { + window.gitify.onResetApp(() => { clearState(); setAuth(defaultAuth); setSettings(defaultSettings); @@ -273,7 +269,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { setKeyboardShortcut(existing.settings.keyboardShortcut); setAlternateIdleIcon(existing.settings.useAlternateIdleIcon); setSettings({ ...defaultSettings, ...existing.settings }); - webFrame.setZoomLevel( + window.gitify.zoom.setLevel( zoomPercentageToLevel(existing.settings.zoomPercentage), ); } diff --git a/src/renderer/routes/LoginWithOAuthApp.tsx b/src/renderer/routes/LoginWithOAuthApp.tsx index 01c28eed4..64c16e145 100644 --- a/src/renderer/routes/LoginWithOAuthApp.tsx +++ b/src/renderer/routes/LoginWithOAuthApp.tsx @@ -22,6 +22,7 @@ import { Contents } from '../components/layout/Contents'; import { Page } from '../components/layout/Page'; import { Footer } from '../components/primitives/Footer'; import { Header } from '../components/primitives/Header'; +import { Constants } from '../constants'; import { AppContext } from '../context/App'; import type { ClientID, ClientSecret, Hostname, Token } from '../types'; import type { LoginOAuthAppOptions } from '../utils/auth/types'; @@ -32,7 +33,6 @@ import { isValidToken, } from '../utils/auth/utils'; import { openExternalLink } from '../utils/comms'; -import { Constants } from '../utils/constants'; import { rendererLogError } from '../utils/logger'; interface IFormData { diff --git a/src/renderer/routes/LoginWithPersonalAccessToken.tsx b/src/renderer/routes/LoginWithPersonalAccessToken.tsx index eb43b74e4..2a97ee91e 100644 --- a/src/renderer/routes/LoginWithPersonalAccessToken.tsx +++ b/src/renderer/routes/LoginWithPersonalAccessToken.tsx @@ -22,6 +22,7 @@ import { Contents } from '../components/layout/Contents'; import { Page } from '../components/layout/Page'; import { Footer } from '../components/primitives/Footer'; import { Header } from '../components/primitives/Header'; +import { Constants } from '../constants'; import { AppContext } from '../context/App'; import type { Hostname, Token } from '../types'; import type { LoginPersonalAccessTokenOptions } from '../utils/auth/types'; @@ -32,7 +33,6 @@ import { isValidToken, } from '../utils/auth/utils'; import { openExternalLink } from '../utils/comms'; -import { Constants } from '../utils/constants'; import { rendererLogError } from '../utils/logger'; interface IFormData { diff --git a/src/renderer/utils/api/utils.ts b/src/renderer/utils/api/utils.ts index fdd59ffa2..95658f5ae 100644 --- a/src/renderer/utils/api/utils.ts +++ b/src/renderer/utils/api/utils.ts @@ -1,7 +1,7 @@ import type { AxiosResponse } from 'axios'; +import { Constants } from '../../constants'; import type { Hostname } from '../../types'; -import { Constants } from '../constants'; import { isEnterpriseServerHost } from '../helpers'; export function getGitHubAPIBaseUrl(hostname: Hostname): URL { diff --git a/src/renderer/utils/auth/utils.test.ts b/src/renderer/utils/auth/utils.test.ts index ef104bd3b..855c1f054 100644 --- a/src/renderer/utils/auth/utils.test.ts +++ b/src/renderer/utils/auth/utils.test.ts @@ -1,5 +1,3 @@ -import { ipcRenderer } from 'electron'; - import type { AxiosPromise, AxiosResponse } from 'axios'; import axios from 'axios'; import nock from 'nock'; @@ -36,7 +34,7 @@ describe('renderer/utils/auth/utils.ts', () => { it('should call authGitHub - success auth flow', async () => { const mockIpcRendererOn = ( - jest.spyOn(ipcRenderer, 'on') as jest.Mock + jest.spyOn(window.gitify, 'onAuthCallback') as jest.Mock ).mockImplementation((event, callback) => { if (event === 'gitify:auth-callback') { callback(null, 'gitify://auth?code=123-456'); @@ -62,7 +60,7 @@ describe('renderer/utils/auth/utils.ts', () => { it('should call authGitHub - success oauth flow', async () => { const mockIpcRendererOn = ( - jest.spyOn(ipcRenderer, 'on') as jest.Mock + jest.spyOn(window.gitify, 'onAuthCallback') as jest.Mock ).mockImplementation((event, callback) => { if (event === 'gitify:auth-callback') { callback(null, 'gitify://oauth?code=123-456'); @@ -92,7 +90,7 @@ describe('renderer/utils/auth/utils.ts', () => { it('should call authGitHub - failure', async () => { const mockIpcRendererOn = ( - jest.spyOn(ipcRenderer, 'on') as jest.Mock + jest.spyOn(window.gitify, 'onAuthCallback') as jest.Mock ).mockImplementation((event, callback) => { if (event === 'gitify:auth-callback') { callback( diff --git a/src/renderer/utils/auth/utils.ts b/src/renderer/utils/auth/utils.ts index 3c01a78f1..42f59aeef 100644 --- a/src/renderer/utils/auth/utils.ts +++ b/src/renderer/utils/auth/utils.ts @@ -1,11 +1,9 @@ -import { ipcRenderer } from 'electron'; - import { format } from 'date-fns'; import semver from 'semver'; import { APPLICATION } from '../../../shared/constants'; -import { namespacedEvent } from '../../../shared/events'; +import { Constants } from '../../constants'; import type { Account, AuthCode, @@ -20,7 +18,6 @@ import type { UserDetails } from '../../typesGitHub'; import { getAuthenticatedUser } from '../api/client'; import { apiRequest } from '../api/request'; import { encryptValue, openExternalLink } from '../comms'; -import { Constants } from '../constants'; import { getPlatformFromHostname } from '../helpers'; import { rendererLogError, rendererLogInfo, rendererLogWarn } from '../logger'; import type { AuthMethod, AuthResponse, AuthTokenResponse } from './types'; @@ -66,16 +63,13 @@ export function authGitHub( } }; - ipcRenderer.on( - namespacedEvent('auth-callback'), - (_, callbackUrl: string) => { - rendererLogInfo( - 'renderer:auth-callback', - `received authentication callback URL ${callbackUrl}`, - ); - handleCallback(callbackUrl); - }, - ); + window.gitify.onAuthCallback((callbackUrl: string) => { + rendererLogInfo( + 'renderer:auth-callback', + `received authentication callback URL ${callbackUrl}`, + ); + handleCallback(callbackUrl); + }); }); } diff --git a/src/renderer/utils/comms.test.ts b/src/renderer/utils/comms.test.ts index 3e9ba81d3..54f2bed90 100644 --- a/src/renderer/utils/comms.test.ts +++ b/src/renderer/utils/comms.test.ts @@ -1,9 +1,5 @@ -import { ipcRenderer, shell } from 'electron'; - -import { namespacedEvent } from '../../shared/events'; - import { mockSettings } from '../__mocks__/state-mocks'; -import type { Link } from '../types'; +import { type Link, OpenPreference } from '../types'; import { decryptValue, encryptValue, @@ -16,180 +12,156 @@ import { setKeyboardShortcut, showWindow, updateTrayIcon, + updateTrayTitle, } from './comms'; -import { Constants } from './constants'; import * as storage from './storage'; describe('renderer/utils/comms.ts', () => { - beforeEach(() => { - jest.spyOn(ipcRenderer, 'send'); - jest.spyOn(ipcRenderer, 'invoke'); - }); - afterEach(() => { jest.clearAllMocks(); }); describe('openExternalLink', () => { it('should open an external link', () => { - jest - .spyOn(storage, 'loadState') - .mockReturnValue({ settings: mockSettings }); - - openExternalLink('https://www.gitify.io/' as Link); - expect(shell.openExternal).toHaveBeenCalledTimes(1); - expect(shell.openExternal).toHaveBeenCalledWith( - 'https://www.gitify.io/', - { - activate: true, - }, + jest.spyOn(storage, 'loadState').mockReturnValue({ + settings: { ...mockSettings, openLinks: OpenPreference.BACKGROUND }, + }); + + openExternalLink('https://gitify.io/' as Link); + + expect(window.gitify.openExternalLink).toHaveBeenCalledTimes(1); + expect(window.gitify.openExternalLink).toHaveBeenCalledWith( + 'https://gitify.io/', + false, + ); + }); + + it('should open in foreground when preference set to FOREGROUND', () => { + jest.spyOn(storage, 'loadState').mockReturnValue({ + settings: { ...mockSettings, openLinks: OpenPreference.FOREGROUND }, + }); + + openExternalLink('https://gitify.io/' as Link); + + expect(window.gitify.openExternalLink).toHaveBeenCalledWith( + 'https://gitify.io/', + true, ); }); it('should use default open preference if user settings not found', () => { jest.spyOn(storage, 'loadState').mockReturnValue({ settings: null }); - openExternalLink('https://www.gitify.io/' as Link); - expect(shell.openExternal).toHaveBeenCalledTimes(1); - expect(shell.openExternal).toHaveBeenCalledWith( - 'https://www.gitify.io/', - { - activate: true, - }, + openExternalLink('https://gitify.io/' as Link); + + expect(window.gitify.openExternalLink).toHaveBeenCalledTimes(1); + expect(window.gitify.openExternalLink).toHaveBeenCalledWith( + 'https://gitify.io/', + true, ); }); it('should ignore opening external local links file:///', () => { openExternalLink('file:///Applications/SomeApp.app' as Link); - expect(shell.openExternal).toHaveBeenCalledTimes(0); + + expect(window.gitify.openExternalLink).not.toHaveBeenCalled(); }); - }); - it('should get app version', async () => { - await getAppVersion(); - expect(ipcRenderer.invoke).toHaveBeenCalledTimes(1); - expect(ipcRenderer.invoke).toHaveBeenCalledWith(namespacedEvent('version')); + it('should ignore non-https links (http)', () => { + openExternalLink('http://example.com' as Link); + expect(window.gitify.openExternalLink).not.toHaveBeenCalled(); + }); }); - it('should encrypt a value', async () => { - await encryptValue('value'); - expect(ipcRenderer.invoke).toHaveBeenCalledTimes(1); - expect(ipcRenderer.invoke).toHaveBeenCalledWith( - namespacedEvent('safe-storage-encrypt'), - 'value', - ); - }); + describe('app/version & crypto helpers', () => { + it('gets app version', async () => { + const version = await getAppVersion(); + expect(window.gitify.app.version).toHaveBeenCalledTimes(1); + expect(version).toBe('v0.0.1'); + }); - it('should decrypt a value', async () => { - await decryptValue('value'); - expect(ipcRenderer.invoke).toHaveBeenCalledTimes(1); - expect(ipcRenderer.invoke).toHaveBeenCalledWith( - namespacedEvent('safe-storage-decrypt'), - 'value', - ); - }); + it('encrypts value', async () => { + const value = await encryptValue('plain'); - it('should quit the app', () => { - quitApp(); - expect(ipcRenderer.send).toHaveBeenCalledTimes(1); - expect(ipcRenderer.send).toHaveBeenCalledWith(namespacedEvent('quit')); - }); + expect(window.gitify.encryptValue).toHaveBeenCalledTimes(1); + expect(window.gitify.encryptValue).toHaveBeenCalledWith('plain'); + expect(value).toBe('encrypted'); + }); - it('should show the window', () => { - showWindow(); - expect(ipcRenderer.send).toHaveBeenCalledTimes(1); - expect(ipcRenderer.send).toHaveBeenCalledWith( - namespacedEvent('window-show'), - ); - }); + it('decrypts value', async () => { + const value = await decryptValue('encrypted'); - it('should hide the window', () => { - hideWindow(); - expect(ipcRenderer.send).toHaveBeenCalledTimes(1); - expect(ipcRenderer.send).toHaveBeenCalledWith( - namespacedEvent('window-hide'), - ); + expect(window.gitify.decryptValue).toHaveBeenCalledTimes(1); + expect(window.gitify.decryptValue).toHaveBeenCalledWith('encrypted'); + expect(value).toBe('decrypted'); + }); }); - it('should setAutoLaunch (true)', () => { - setAutoLaunch(true); + describe('window / app actions', () => { + it('quits app', () => { + quitApp(); + expect(window.gitify.app.quit).toHaveBeenCalledTimes(1); + }); + + it('shows window', () => { + showWindow(); + expect(window.gitify.app.show).toHaveBeenCalledTimes(1); + }); - expect(ipcRenderer.send).toHaveBeenCalledWith( - namespacedEvent('update-auto-launch'), - { - openAtLogin: true, - openAsHidden: true, - }, - ); + it('hides window', () => { + hideWindow(); + expect(window.gitify.app.hide).toHaveBeenCalledTimes(1); + }); }); - it('should setAutoLaunch (false)', () => { - setAutoLaunch(false); + describe('settings toggles', () => { + it('sets auto launch', () => { + setAutoLaunch(true); - expect(ipcRenderer.send).toHaveBeenCalledWith( - namespacedEvent('update-auto-launch'), - { - openAsHidden: false, - openAtLogin: false, - }, - ); - }); + expect(window.gitify.setAutoLaunch).toHaveBeenCalledTimes(1); + expect(window.gitify.setAutoLaunch).toHaveBeenCalledWith(true); + }); - it('should setAlternateIdleIcon', () => { - setAlternateIdleIcon(true); + it('sets alternate idle icon', () => { + setAlternateIdleIcon(false); - expect(ipcRenderer.send).toHaveBeenCalledWith( - namespacedEvent('use-alternate-idle-icon'), - true, - ); - }); + expect(window.gitify.tray.useAlternateIdleIcon).toHaveBeenCalledTimes(1); + expect(window.gitify.tray.useAlternateIdleIcon).toHaveBeenCalledWith( + false, + ); + }); - it('should enable keyboard shortcut', () => { - setKeyboardShortcut(true); - expect(ipcRenderer.send).toHaveBeenCalledTimes(1); - expect(ipcRenderer.send).toHaveBeenCalledWith( - namespacedEvent('update-keyboard-shortcut'), - { - enabled: true, - keyboardShortcut: Constants.DEFAULT_KEYBOARD_SHORTCUT, - }, - ); - }); + it('sets keyboard shortcut', () => { + setKeyboardShortcut(true); - it('should disable keyboard shortcut', () => { - setKeyboardShortcut(false); - expect(ipcRenderer.send).toHaveBeenCalledTimes(1); - expect(ipcRenderer.send).toHaveBeenCalledWith( - namespacedEvent('update-keyboard-shortcut'), - { - enabled: false, - keyboardShortcut: Constants.DEFAULT_KEYBOARD_SHORTCUT, - }, - ); + expect(window.gitify.setKeyboardShortcut).toHaveBeenCalledTimes(1); + expect(window.gitify.setKeyboardShortcut).toHaveBeenCalledWith(true); + }); }); - it('should send mark the icons as active', () => { - const notificationsLength = 3; - updateTrayIcon(notificationsLength); - expect(ipcRenderer.send).toHaveBeenCalledTimes(1); - expect(ipcRenderer.send).toHaveBeenCalledWith( - namespacedEvent('icon-active'), - ); - }); + describe('tray helpers', () => { + it('updates tray icon with count', () => { + updateTrayIcon(5); - it('should send mark the icons as idle', () => { - const notificationsLength = 0; - updateTrayIcon(notificationsLength); - expect(ipcRenderer.send).toHaveBeenCalledTimes(1); - expect(ipcRenderer.send).toHaveBeenCalledWith(namespacedEvent('icon-idle')); - }); + expect(window.gitify.tray.updateIcon).toHaveBeenCalledTimes(1); + expect(window.gitify.tray.updateIcon).toHaveBeenCalledWith(5); + }); + + it('updates tray icon with default count', () => { + updateTrayIcon(); + expect(window.gitify.tray.updateIcon).toHaveBeenCalledTimes(1); + }); + + it('updates tray title with provided value', () => { + updateTrayTitle('gitify'); - it('should send mark the icons as error', () => { - const notificationsLength = -1; - updateTrayIcon(notificationsLength); - expect(ipcRenderer.send).toHaveBeenCalledTimes(1); - expect(ipcRenderer.send).toHaveBeenCalledWith( - namespacedEvent('icon-error'), - ); + expect(window.gitify.tray.updateTitle).toHaveBeenCalledTimes(1); + expect(window.gitify.tray.updateTitle).toHaveBeenCalledWith('gitify'); + }); + + it('updates tray title with default value', () => { + updateTrayTitle(); + expect(window.gitify.tray.updateTitle).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/src/renderer/utils/comms.ts b/src/renderer/utils/comms.ts index 573d732ad..31f901d6a 100644 --- a/src/renderer/utils/comms.ts +++ b/src/renderer/utils/comms.ts @@ -1,89 +1,62 @@ -import { ipcRenderer, shell } from 'electron'; - -import { namespacedEvent } from '../../shared/events'; - import { defaultSettings } from '../context/defaults'; import { type Link, OpenPreference } from '../types'; -import { Constants } from './constants'; import { loadState } from './storage'; export function openExternalLink(url: Link): void { - if (url.toLowerCase().startsWith('https://')) { - // Load the state from local storage to avoid having to pass settings as a parameter - const { settings } = loadState(); + // Load the state from local storage to avoid having to pass settings as a parameter + const { settings } = loadState(); + const openPreference = settings + ? settings.openLinks + : defaultSettings.openLinks; - const openPreference = settings - ? settings.openLinks - : defaultSettings.openLinks; - - shell.openExternal(url, { - activate: openPreference === OpenPreference.FOREGROUND, - }); + if (url.toLowerCase().startsWith('https://')) { + window.gitify.openExternalLink( + url, + openPreference === OpenPreference.FOREGROUND, + ); } } export async function getAppVersion(): Promise { - return await ipcRenderer.invoke(namespacedEvent('version')); + return await window.gitify.app.version(); } export async function encryptValue(value: string): Promise { - return await ipcRenderer.invoke( - namespacedEvent('safe-storage-encrypt'), - value, - ); + return await window.gitify.encryptValue(value); } export async function decryptValue(value: string): Promise { - return await ipcRenderer.invoke( - namespacedEvent('safe-storage-decrypt'), - value, - ); + return await window.gitify.decryptValue(value); } export function quitApp(): void { - ipcRenderer.send(namespacedEvent('quit')); + window.gitify.app.quit(); } export function showWindow(): void { - ipcRenderer.send(namespacedEvent('window-show')); + window.gitify.app.show(); } export function hideWindow(): void { - ipcRenderer.send(namespacedEvent('window-hide')); + window.gitify.app.hide(); } export function setAutoLaunch(value: boolean): void { - ipcRenderer.send(namespacedEvent('update-auto-launch'), { - openAtLogin: value, - openAsHidden: value, - }); + window.gitify.setAutoLaunch(value); } export function setAlternateIdleIcon(value: boolean): void { - ipcRenderer.send(namespacedEvent('use-alternate-idle-icon'), value); + window.gitify.tray.useAlternateIdleIcon(value); } export function setKeyboardShortcut(keyboardShortcut: boolean): void { - ipcRenderer.send(namespacedEvent('update-keyboard-shortcut'), { - enabled: keyboardShortcut, - keyboardShortcut: Constants.DEFAULT_KEYBOARD_SHORTCUT, - }); + window.gitify.setKeyboardShortcut(keyboardShortcut); } export function updateTrayIcon(notificationsLength = 0): void { - if (notificationsLength < 0) { - ipcRenderer.send(namespacedEvent('icon-error')); - return; - } - - if (notificationsLength > 0) { - ipcRenderer.send(namespacedEvent('icon-active')); - return; - } - - ipcRenderer.send(namespacedEvent('icon-idle')); + window.gitify.tray.updateIcon(notificationsLength); } export function updateTrayTitle(title = ''): void { - ipcRenderer.send(namespacedEvent('update-title'), title); + window.gitify.tray.updateTitle(title); } diff --git a/src/renderer/utils/emojis.test.ts b/src/renderer/utils/emojis.test.ts deleted file mode 100644 index 92d6d1b5a..000000000 --- a/src/renderer/utils/emojis.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ALL_EMOJI_SVG_FILENAMES } from './emojis'; - -describe('renderer/utils/emojis.ts', () => { - it('emoji svg filenames', () => { - expect(ALL_EMOJI_SVG_FILENAMES).toHaveLength(20); - expect(ALL_EMOJI_SVG_FILENAMES).toMatchSnapshot(); - }); -}); diff --git a/src/renderer/utils/emojis.ts b/src/renderer/utils/emojis.ts index 1292032cf..e19a040e4 100644 --- a/src/renderer/utils/emojis.ts +++ b/src/renderer/utils/emojis.ts @@ -1,36 +1,14 @@ -import path from 'node:path'; - import twemoji, { type TwemojiOptions } from '@discordapp/twemoji'; -import { Constants } from './constants'; -import { Errors } from './errors'; - const EMOJI_FORMAT = 'svg'; -const ALL_EMOJIS = [ - ...Constants.ALL_READ_EMOJIS, - ...Errors.BAD_CREDENTIALS.emojis, - ...Errors.MISSING_SCOPES.emojis, - ...Errors.NETWORK.emojis, - ...Errors.RATE_LIMITED.emojis, - ...Errors.UNKNOWN.emojis, -]; - -export const ALL_EMOJI_SVG_FILENAMES = ALL_EMOJIS.map((emoji) => { - const imgHtml = convertTextToEmojiImgHtml(emoji); - return extractSvgFilename(imgHtml); -}); +export async function convertTextToEmojiImgHtml(text: string): Promise { + const directory = await window.gitify.twemojiDirectory(); -export function convertTextToEmojiImgHtml(text: string): string { return twemoji.parse(text, { folder: EMOJI_FORMAT, callback: (icon: string, _options: TwemojiOptions) => { - return path.join('images', 'twemoji', `${icon}.${EMOJI_FORMAT}`); + return `${directory}/${icon}.${EMOJI_FORMAT}`; }, }); } - -function extractSvgFilename(imgHtml: string): string { - const srcMatch = /src="(.*)"/.exec(imgHtml); - return path.basename(srcMatch[1]); -} diff --git a/src/renderer/utils/helpers.ts b/src/renderer/utils/helpers.ts index 772cdccb8..ee8a33c08 100644 --- a/src/renderer/utils/helpers.ts +++ b/src/renderer/utils/helpers.ts @@ -4,11 +4,11 @@ import { ChevronRightIcon, } from '@primer/octicons-react'; +import { Constants } from '../constants'; import type { Chevron, Hostname, Link } from '../types'; import type { Notification } from '../typesGitHub'; import { getHtmlUrl, getLatestDiscussion } from './api/client'; import type { PlatformType } from './auth/types'; -import { Constants } from './constants'; import { rendererLogError, rendererLogWarn } from './logger'; import { getCheckSuiteAttributes } from './notifications/handlers/checkSuite'; import { getClosestDiscussionCommentOrReply } from './notifications/handlers/discussion'; diff --git a/src/renderer/utils/links.test.ts b/src/renderer/utils/links.test.ts index 7ec918363..b2d385aa9 100644 --- a/src/renderer/utils/links.test.ts +++ b/src/renderer/utils/links.test.ts @@ -1,11 +1,11 @@ import { partialMockUser } from '../__mocks__/partial-mocks'; import { mockGitHubCloudAccount } from '../__mocks__/state-mocks'; +import { Constants } from '../constants'; import type { Hostname, Link } from '../types'; import type { Repository } from '../typesGitHub'; import { mockSingleNotification } from './api/__mocks__/response-mocks'; import * as authUtils from './auth/utils'; import * as comms from './comms'; -import { Constants } from './constants'; import * as helpers from './helpers'; import { openAccountProfile, diff --git a/src/renderer/utils/links.ts b/src/renderer/utils/links.ts index 3ea299d4b..88ce44109 100644 --- a/src/renderer/utils/links.ts +++ b/src/renderer/utils/links.ts @@ -1,13 +1,15 @@ +import { APPLICATION } from '../../shared/constants'; + +import { Constants } from '../constants'; import type { Account, Hostname, Link } from '../types'; import type { Notification, Repository, SubjectUser } from '../typesGitHub'; import { getDeveloperSettingsURL } from './auth/utils'; import { openExternalLink } from './comms'; -import { Constants } from './constants'; import { generateGitHubWebUrl } from './helpers'; export function openGitifyReleaseNotes(version: string) { openExternalLink( - `https://github.com/${Constants.REPO_SLUG}/releases/tag/${version}` as Link, + `https://github.com/${APPLICATION.REPO_SLUG}/releases/tag/${version}` as Link, ); } diff --git a/src/renderer/utils/notifications/native.ts b/src/renderer/utils/notifications/native.ts index 3a3ed50dc..fcf804c4c 100644 --- a/src/renderer/utils/notifications/native.ts +++ b/src/renderer/utils/notifications/native.ts @@ -1,14 +1,8 @@ -import path from 'node:path'; - import { APPLICATION } from '../../../shared/constants'; -import { isWindows } from '../../../shared/platform'; import type { AccountNotifications, GitifyState } from '../../types'; -import { Notification } from '../../typesGitHub'; +import type { Notification } from '../../typesGitHub'; import { getAccountUUID } from '../auth/utils'; -import { hideWindow, showWindow } from '../comms'; -import { Constants } from '../constants'; -import { openNotification } from '../links'; import { setTrayIconColor } from './notifications'; export const triggerNativeNotifications = ( @@ -60,43 +54,27 @@ export const triggerNativeNotifications = ( export const raiseNativeNotification = (notifications: Notification[]) => { let title: string; let body: string; + const url: string = null; if (notifications.length === 1) { const notification = notifications[0]; - title = isWindows() ? '' : notification.repository.full_name; + title = window.gitify.platform.isWindows() + ? '' + : notification.repository.full_name; body = notification.subject.title; + // TODO FIXME = set url to notification url } else { title = APPLICATION.NAME; body = `You have ${notifications.length} notifications.`; } - const nativeNotification = new Notification(title, { - body, - silent: true, - }); - - nativeNotification.onclick = () => { - if (notifications.length === 1) { - hideWindow(); - openNotification(notifications[0]); - } else { - showWindow(); - } - }; - - return nativeNotification; + return window.gitify.raiseNativeNotification(title, body, url); }; -export const raiseSoundNotification = (volume: number) => { - const audio = new Audio( - path.join( - __dirname, - '..', - 'assets', - 'sounds', - Constants.NOTIFICATION_SOUND, - ), - ); +export const raiseSoundNotification = async (volume: number) => { + const path = await window.gitify.notificationSoundPath(); + + const audio = new Audio(path); audio.volume = volume; audio.play(); }; diff --git a/src/renderer/utils/storage.test.ts b/src/renderer/utils/storage.test.ts index 0203a03c6..958f66178 100644 --- a/src/renderer/utils/storage.test.ts +++ b/src/renderer/utils/storage.test.ts @@ -1,6 +1,6 @@ import { mockSettings } from '../__mocks__/state-mocks'; +import { Constants } from '../constants'; import type { Token } from '../types'; -import { Constants } from './constants'; import { clearState, loadState, saveState } from './storage'; describe('renderer/utils/storage.ts', () => { diff --git a/src/renderer/utils/storage.ts b/src/renderer/utils/storage.ts index 74686be28..42723019e 100644 --- a/src/renderer/utils/storage.ts +++ b/src/renderer/utils/storage.ts @@ -1,5 +1,5 @@ +import { Constants } from '../constants'; import type { GitifyState } from '../types'; -import { Constants } from './constants'; export function loadState(): GitifyState { const existing = localStorage.getItem(Constants.STORAGE_KEY); diff --git a/src/shared/constants.ts b/src/shared/constants.ts index a35f72f7d..f3c05e48e 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -11,5 +11,9 @@ export const APPLICATION = { REPO_SLUG: 'gitify-app/gitify', + DEFAULT_KEYBOARD_SHORTCUT: 'CommandOrControl+Shift+G', + + NOTIFICATION_SOUND: 'clearly.mp3', + UPDATE_CHECK_INTERVAL_MS: 24 * 60 * 60 * 1000, // 24 hours }; diff --git a/src/shared/events.ts b/src/shared/events.ts index a311b0a57..5864fdfad 100644 --- a/src/shared/events.ts +++ b/src/shared/events.ts @@ -1,5 +1,49 @@ import { APPLICATION } from './constants'; -export function namespacedEvent(event: string) { - return `${APPLICATION.EVENT_PREFIX}${event}`; +const P = APPLICATION.EVENT_PREFIX; + +export const EVENTS = { + AUTH_CALLBACK: `${P}auth-callback`, + ICON_IDLE: `${P}icon-idle`, + ICON_ACTIVE: `${P}icon-active`, + ICON_ERROR: `${P}icon-error`, + QUIT: `${P}quit`, + WINDOW_SHOW: `${P}window-show`, + WINDOW_HIDE: `${P}window-hide`, + VERSION: `${P}version`, + UPDATE_TITLE: `${P}update-title`, + USE_ALTERNATE_IDLE_ICON: `${P}use-alternate-idle-icon`, + UPDATE_KEYBOARD_SHORTCUT: `${P}update-keyboard-shortcut`, + UPDATE_AUTO_LAUNCH: `${P}update-auto-launch`, + SAFE_STORAGE_ENCRYPT: `${P}safe-storage-encrypt`, + SAFE_STORAGE_DECRYPT: `${P}safe-storage-decrypt`, + NOTIFICATION_SOUND_PATH: `${P}notification-sound-path`, + OPEN_EXTERNAL: `${P}open-external`, + RESET_APP: `${P}reset-app`, + UPDATE_THEME: `${P}update-theme`, + TWEMOJI_DIRECTORY: `${P}twemoji-directory`, +} as const; + +export type EventType = (typeof EVENTS)[keyof typeof EVENTS]; + +export interface IAutoLaunch { + openAtLogin: boolean; + openAsHidden: boolean; +} + +export interface IKeyboardShortcut { + enabled: boolean; + keyboardShortcut: string; +} + +export interface IOpenExternal { + url: string; + activate: boolean; } + +export type EventData = + | string + | boolean + | IKeyboardShortcut + | IAutoLaunch + | IOpenExternal; diff --git a/src/shared/theme.ts b/src/shared/theme.ts new file mode 100644 index 000000000..7e4a310ce --- /dev/null +++ b/src/shared/theme.ts @@ -0,0 +1,8 @@ +/** + * Application theme modes shared across processes. + */ +export enum Theme { + SYSTEM = 'SYSTEM', + LIGHT = 'LIGHT', + DARK = 'DARK', +} From b2cf2b8c7aea4f62bd366029086e9bb7139c8d11 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Wed, 27 Aug 2025 13:17:41 -0400 Subject: [PATCH 02/16] feat: context bridge Signed-off-by: Adam Setch --- config/electron-builder.js | 6 +- scripts/afterPack.js | 3 +- scripts/afterSign.js | 3 +- src/main/index.ts | 2 +- src/renderer/__helpers__/jest.setup.ts | 37 +++++++++-- src/renderer/__mocks__/@electron/remote.js | 36 ---------- src/renderer/__mocks__/electron.js | 59 ----------------- .../components/primitives/EmojiText.tsx | 12 ++-- .../settings/AppearanceSettings.test.tsx | 10 ++- .../settings/AppearanceSettings.tsx | 14 ++-- .../settings/SettingsFooter.test.tsx | 65 +++++-------------- .../components/settings/SettingsFooter.tsx | 8 +-- .../components/settings/SystemSettings.tsx | 5 +- .../SettingsFooter.test.tsx.snap | 26 ++++++++ 14 files changed, 102 insertions(+), 184 deletions(-) delete mode 100644 src/renderer/__mocks__/@electron/remote.js delete mode 100644 src/renderer/__mocks__/electron.js diff --git a/config/electron-builder.js b/config/electron-builder.js index c4675dd0e..4d76067c0 100644 --- a/config/electron-builder.js +++ b/config/electron-builder.js @@ -1,7 +1,5 @@ -const { Configuration } = require('electron-builder'); - /** - * @type {Configuration} + * @type {import('electron-builder').Configuration} */ const config = { productName: 'Gitify', @@ -28,7 +26,7 @@ const config = { icon: 'assets/images/app-icon.icns', identity: 'Adam Setch (5KD23H9729)', type: 'distribution', - notarize: false, + notarize: false, // Handle notarization in afterSign.js target: { target: 'default', arch: ['universal'], diff --git a/scripts/afterPack.js b/scripts/afterPack.js index ee66116b5..53174b9a7 100644 --- a/scripts/afterPack.js +++ b/scripts/afterPack.js @@ -1,6 +1,5 @@ const path = require('node:path'); const fs = require('node:fs'); -const { AfterPackContext } = require('electron-builder'); const builderConfig = require('../config/electron-builder'); const electronLanguages = builderConfig.electronLanguages; @@ -11,7 +10,7 @@ function logAfterPackProgress(msg) { } /** - * @param {AfterPackContext} context + * @param {import('electron-builder').AfterPackContext} context */ const afterPack = async (context) => { logAfterPackProgress('Starting...'); diff --git a/scripts/afterSign.js b/scripts/afterSign.js index 57a6b688f..431e47434 100644 --- a/scripts/afterSign.js +++ b/scripts/afterSign.js @@ -1,5 +1,4 @@ const { notarize } = require('@electron/notarize'); -const { AfterPackContext } = require('electron-builder'); function logAfterSignProgress(msg) { // biome-ignore lint/suspicious/noConsole: log notarizing progress @@ -7,7 +6,7 @@ function logAfterSignProgress(msg) { } /** - * @param {AfterPackContext} context + * @param {import('electron-builder').AfterPackContext} context */ const afterSign = async (context) => { logAfterSignProgress('Starting...'); diff --git a/src/main/index.ts b/src/main/index.ts index 5c3de8ec5..211e5afde 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -51,7 +51,7 @@ const browserWindowOpts: BrowserWindowConstructorOptions = { skipTaskbar: true, // Hide the app from the Windows taskbar webPreferences: { preload: preloadFilePath, - contextIsolation: true, + contextIsolation: false, nodeIntegration: false, }, }; diff --git a/src/renderer/__helpers__/jest.setup.ts b/src/renderer/__helpers__/jest.setup.ts index 1f8c2ac4f..c26c9470f 100644 --- a/src/renderer/__helpers__/jest.setup.ts +++ b/src/renderer/__helpers__/jest.setup.ts @@ -1,4 +1,5 @@ import '@testing-library/jest-dom'; + import { TextDecoder, TextEncoder } from 'node:util'; /** @@ -30,8 +31,36 @@ if (!CSS.supports) { CSS.supports = () => true; } -global.ResizeObserver = class { - observe() {} - unobserve() {} - disconnect() {} +window.gitify = { + app: { + version: jest.fn().mockResolvedValue('v0.0.1'), + hide: jest.fn(), + quit: jest.fn(), + show: jest.fn(), + }, + twemojiDirectory: jest.fn().mockResolvedValue('/mock/images/assets'), + openExternalLink: jest.fn(), + decryptValue: jest.fn().mockResolvedValue('decrypted'), + encryptValue: jest.fn().mockResolvedValue('encrypted'), + platform: { + isLinux: jest.fn().mockReturnValue(false), + isMacOS: jest.fn().mockReturnValue(true), + isWindows: jest.fn().mockReturnValue(false), + }, + zoom: { + getLevel: jest.fn(), + setLevel: jest.fn(), + }, + tray: { + updateIcon: jest.fn(), + updateTitle: jest.fn(), + useAlternateIdleIcon: jest.fn(), + }, + notificationSoundPath: jest.fn(), + onAuthCallback: jest.fn(), + onResetApp: jest.fn(), + onSystemThemeUpdate: jest.fn(), + setAutoLaunch: jest.fn(), + setKeyboardShortcut: jest.fn(), + raiseNativeNotification: jest.fn(), }; diff --git a/src/renderer/__mocks__/@electron/remote.js b/src/renderer/__mocks__/@electron/remote.js deleted file mode 100644 index 6e148d15c..000000000 --- a/src/renderer/__mocks__/@electron/remote.js +++ /dev/null @@ -1,36 +0,0 @@ -let instance; - -class BrowserWindow { - constructor() { - if (!instance) { - instance = this; - } - // biome-ignore lint/correctness/noConstructorReturn: This is a mock class - return instance; - } - loadURL = jest.fn(); - webContents = { - on: () => {}, - session: { - clearStorageData: jest.fn(), - }, - }; - on() {} - close = jest.fn(); - hide = jest.fn(); - destroy = jest.fn(); -} - -const dialog = { - showErrorBox: jest.fn(), -}; - -module.exports = { - BrowserWindow: BrowserWindow, - dialog: dialog, - app: { - getLoginItemSettings: jest.fn(), - setLoginItemSettings: () => {}, - }, - getCurrentWindow: jest.fn(() => instance || new BrowserWindow()), -}; diff --git a/src/renderer/__mocks__/electron.js b/src/renderer/__mocks__/electron.js deleted file mode 100644 index 9754ee944..000000000 --- a/src/renderer/__mocks__/electron.js +++ /dev/null @@ -1,59 +0,0 @@ -const { namespacedEvent } = require('../../shared/events'); - -window.Notification = function (title) { - this.title = title; - - return { - onclick: jest.fn(), - }; -}; - -window.Audio = class Audio { - constructor(path) { - this.path = path; - } - - play() {} -}; - -window.localStorage = { - store: {}, - getItem: function (key) { - return this.store[key]; - }, - setItem: function (key, item) { - this.store[key] = item; - }, - removeItem: jest.fn(), -}; - -window.alert = jest.fn(); - -module.exports = { - ipcRenderer: { - send: jest.fn(), - on: jest.fn(), - sendSync: jest.fn(), - invoke: jest.fn((channel, ..._args) => { - switch (channel) { - case 'get-platform': - return Promise.resolve('darwin'); - case namespacedEvent('version'): - return Promise.resolve('0.0.1'); - case namespacedEvent('safe-storage-encrypt'): - return Promise.resolve('encrypted'); - case namespacedEvent('safe-storage-decrypt'): - return Promise.resolve('decrypted'); - default: - return Promise.reject(new Error(`Unknown channel: ${channel}`)); - } - }), - }, - shell: { - openExternal: jest.fn(), - }, - webFrame: { - setZoomLevel: jest.fn(), - getZoomLevel: jest.fn(), - }, -}; diff --git a/src/renderer/components/primitives/EmojiText.tsx b/src/renderer/components/primitives/EmojiText.tsx index 0731d8c87..2dcfa9134 100644 --- a/src/renderer/components/primitives/EmojiText.tsx +++ b/src/renderer/components/primitives/EmojiText.tsx @@ -9,12 +9,16 @@ export interface IEmojiText { } export const EmojiText: FC = ({ text }) => { - const ref = useRef(null); + const ref = useRef(null); useEffect(() => { - if (ref.current) { - ref.current.innerHTML = convertTextToEmojiImgHtml(text); - } + const updateEmojiText = async () => { + if (ref.current) { + const emojiHtml = await convertTextToEmojiImgHtml(text); + ref.current.innerHTML = emojiHtml; + } + }; + updateEmojiText(); }, [text]); return ; diff --git a/src/renderer/components/settings/AppearanceSettings.test.tsx b/src/renderer/components/settings/AppearanceSettings.test.tsx index 946dd2427..210949b64 100644 --- a/src/renderer/components/settings/AppearanceSettings.test.tsx +++ b/src/renderer/components/settings/AppearanceSettings.test.tsx @@ -2,8 +2,6 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter } from 'react-router-dom'; -import { webFrame } from 'electron'; - import { mockAuth, mockGitHubAppAccount, @@ -72,7 +70,7 @@ describe('renderer/components/settings/AppearanceSettings.tsx', () => { }); it('should update the zoom value when using CMD + and CMD -', async () => { - webFrame.getZoomLevel = jest.fn().mockReturnValue(-1); + window.gitify.zoom.getLevel = jest.fn().mockReturnValue(-1); await act(async () => { render( @@ -98,9 +96,9 @@ describe('renderer/components/settings/AppearanceSettings.tsx', () => { }); it('should update the zoom values when using the zoom buttons', async () => { - webFrame.getZoomLevel = jest.fn().mockReturnValue(0); - webFrame.setZoomLevel = jest.fn().mockImplementation((level) => { - webFrame.getZoomLevel = jest.fn().mockReturnValue(level); + window.gitify.zoom.getLevel = jest.fn().mockReturnValue(0); + window.gitify.zoom.setLevel = jest.fn().mockImplementation((level) => { + window.gitify.zoom.getLevel = jest.fn().mockReturnValue(level); fireEvent(window, new Event('resize')); }); diff --git a/src/renderer/components/settings/AppearanceSettings.tsx b/src/renderer/components/settings/AppearanceSettings.tsx index 1566d980b..f6b7363ba 100644 --- a/src/renderer/components/settings/AppearanceSettings.tsx +++ b/src/renderer/components/settings/AppearanceSettings.tsx @@ -1,7 +1,5 @@ import { type FC, useContext, useState } from 'react'; -import { webFrame } from 'electron'; - import { PaintbrushIcon, SyncIcon, @@ -31,7 +29,7 @@ const DELAY = 200; export const AppearanceSettings: FC = () => { const { auth, settings, updateSetting } = useContext(AppContext); const [zoomPercentage, setZoomPercentage] = useState( - zoomLevelToPercentage(webFrame.getZoomLevel()), + zoomLevelToPercentage(window.gitify.zoom.getLevel()), ); window.addEventListener('resize', () => { @@ -39,7 +37,9 @@ export const AppearanceSettings: FC = () => { clearTimeout(timeout); // start timing for event "completion" timeout = setTimeout(() => { - const zoomPercentage = zoomLevelToPercentage(webFrame.getZoomLevel()); + const zoomPercentage = zoomLevelToPercentage( + window.gitify.zoom.getLevel(), + ); setZoomPercentage(zoomPercentage); updateSetting('zoomPercentage', zoomPercentage); }, DELAY); @@ -115,7 +115,7 @@ export const AppearanceSettings: FC = () => { icon={ZoomOutIcon} onClick={() => zoomPercentage > 0 && - webFrame.setZoomLevel( + window.gitify.zoom.setLevel( zoomPercentageToLevel(zoomPercentage - 10), ) } @@ -133,7 +133,7 @@ export const AppearanceSettings: FC = () => { icon={ZoomInIcon} onClick={() => zoomPercentage < 120 && - webFrame.setZoomLevel( + window.gitify.zoom.setLevel( zoomPercentageToLevel(zoomPercentage + 10), ) } @@ -145,7 +145,7 @@ export const AppearanceSettings: FC = () => { aria-label="Reset zoom" data-testid="settings-zoom-reset" icon={SyncIcon} - onClick={() => webFrame.setZoomLevel(0)} + onClick={() => window.gitify.zoom.setLevel(0)} size="small" unsafeDisableTooltip={true} variant="danger" diff --git a/src/renderer/components/settings/SettingsFooter.test.tsx b/src/renderer/components/settings/SettingsFooter.test.tsx index f267b6738..35d2fdd89 100644 --- a/src/renderer/components/settings/SettingsFooter.test.tsx +++ b/src/renderer/components/settings/SettingsFooter.test.tsx @@ -26,61 +26,26 @@ describe('renderer/components/settings/SettingsFooter.tsx', () => { process.env = originalEnv; }); - describe('app version', () => { - it('should show production app version', async () => { - process.env = { - ...originalEnv, - NODE_ENV: 'production', - }; - - await act(async () => { - render( - - - - - , - ); - }); - - expect(screen.getByTestId('settings-release-notes')).toMatchSnapshot(); + it('should show app version', async () => { + await act(async () => { + render( + + + + + , + ); }); - it('should show development app version', async () => { - process.env = { - ...originalEnv, - NODE_ENV: 'development', - }; - - await act(async () => { - render( - - - - - , - ); - }); - - expect(screen.getByTestId('settings-release-notes')).toMatchSnapshot(); - }); + expect(screen.getByTestId('settings-release-notes')).toMatchSnapshot(); }); it('should open release notes', async () => { - process.env = { - ...originalEnv, - NODE_ENV: 'production', - }; const openExternalLinkMock = jest .spyOn(comms, 'openExternalLink') .mockImplementation(); diff --git a/src/renderer/components/settings/SettingsFooter.tsx b/src/renderer/components/settings/SettingsFooter.tsx index 3994deb94..4bb13978d 100644 --- a/src/renderer/components/settings/SettingsFooter.tsx +++ b/src/renderer/components/settings/SettingsFooter.tsx @@ -16,12 +16,8 @@ export const SettingsFooter: FC = () => { useEffect(() => { (async () => { - if (process.env.NODE_ENV === 'development') { - setAppVersion('dev'); - } else { - const result = await getAppVersion(); - setAppVersion(`v${result}`); - } + const result = await getAppVersion(); + setAppVersion(result); })(); }, []); diff --git a/src/renderer/components/settings/SystemSettings.tsx b/src/renderer/components/settings/SystemSettings.tsx index 7d14fad0d..6eb724ddb 100644 --- a/src/renderer/components/settings/SystemSettings.tsx +++ b/src/renderer/components/settings/SystemSettings.tsx @@ -11,7 +11,6 @@ import { } from '@primer/react'; import { APPLICATION } from '../../../shared/constants'; -import { isLinux, isMacOS } from '../../../shared/platform'; import { AppContext } from '../../context/App'; import { defaultSettings } from '../../context/defaults'; @@ -68,7 +67,7 @@ export const SystemSettings: FC = () => { onChange={(evt) => updateSetting('showNotificationsCountInTray', evt.target.checked) } - visible={isMacOS()} + visible={window.gitify.platform.isMacOS()} /> { label="Open at startup" name="openAtStartup" onChange={(evt) => updateSetting('openAtStartup', evt.target.checked)} - visible={!isLinux()} + visible={!window.gitify.platform.isLinux()} /> diff --git a/src/renderer/components/settings/__snapshots__/SettingsFooter.test.tsx.snap b/src/renderer/components/settings/__snapshots__/SettingsFooter.test.tsx.snap index 36e0732a8..b0085bcb5 100644 --- a/src/renderer/components/settings/__snapshots__/SettingsFooter.test.tsx.snap +++ b/src/renderer/components/settings/__snapshots__/SettingsFooter.test.tsx.snap @@ -51,3 +51,29 @@ exports[`renderer/components/settings/SettingsFooter.tsx app version should show `; + +exports[`renderer/components/settings/SettingsFooter.tsx should show app version 1`] = ` + +`; From b517e8df08804b7067c424dce0fd519a07a12160 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Wed, 27 Aug 2025 14:01:01 -0400 Subject: [PATCH 03/16] feat: context bridge Signed-off-by: Adam Setch --- jest.config.ts | 1 - src/main/index.ts | 2 +- src/renderer/__helpers__/jest.setup.ts | 5 +++ .../AccountNotifications.test.tsx | 34 ++++++++++++------- .../AccountNotifications.test.tsx.snap | 8 ++--- .../utils/__snapshots__/emojis.test.ts.snap | 26 -------------- 6 files changed, 31 insertions(+), 45 deletions(-) delete mode 100644 src/renderer/utils/__snapshots__/emojis.test.ts.snap diff --git a/jest.config.ts b/jest.config.ts index 1b61fec23..70fdbdcf7 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -5,7 +5,6 @@ const config: Config = { setupFilesAfterEnv: ['/src/renderer/__helpers__/jest.setup.ts'], testEnvironment: 'jsdom', collectCoverage: true, - collectCoverageFrom: ['src/**/*', '!**/__snapshots__/**'], moduleNameMapper: { // Force CommonJS build for http adapter to be available. // via https://github.com/axios/axios/issues/5101#issuecomment-1276572468 diff --git a/src/main/index.ts b/src/main/index.ts index 211e5afde..5c3de8ec5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -51,7 +51,7 @@ const browserWindowOpts: BrowserWindowConstructorOptions = { skipTaskbar: true, // Hide the app from the Windows taskbar webPreferences: { preload: preloadFilePath, - contextIsolation: false, + contextIsolation: true, nodeIntegration: false, }, }; diff --git a/src/renderer/__helpers__/jest.setup.ts b/src/renderer/__helpers__/jest.setup.ts index c26c9470f..675a92f0b 100644 --- a/src/renderer/__helpers__/jest.setup.ts +++ b/src/renderer/__helpers__/jest.setup.ts @@ -31,6 +31,11 @@ if (!CSS.supports) { CSS.supports = () => true; } +// @ts-expect-error +window.Audio = class Audio { + play() {} +}; + window.gitify = { app: { version: jest.fn().mockResolvedValue('v0.0.1'), diff --git a/src/renderer/components/notifications/AccountNotifications.test.tsx b/src/renderer/components/notifications/AccountNotifications.test.tsx index c55989cb2..8e7712194 100644 --- a/src/renderer/components/notifications/AccountNotifications.test.tsx +++ b/src/renderer/components/notifications/AccountNotifications.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { @@ -59,7 +59,7 @@ describe('renderer/components/notifications/AccountNotifications.tsx', () => { expect(tree).toMatchSnapshot(); }); - it('should render itself - no notifications', () => { + it('should render itself - no notifications', async () => { const props = { account: mockGitHubCloudAccount, notifications: [], @@ -67,16 +67,20 @@ describe('renderer/components/notifications/AccountNotifications.tsx', () => { error: null, }; - const tree = render( - - - , - ); + let tree: ReturnType | null = null; + + await act(async () => { + tree = render( + + + , + ); + }); expect(tree).toMatchSnapshot(); }); - it('should render itself - account error', () => { + it('should render itself - account error', async () => { const props = { account: mockGitHubCloudAccount, notifications: [], @@ -88,11 +92,15 @@ describe('renderer/components/notifications/AccountNotifications.tsx', () => { showAccountHeader: true, }; - const tree = render( - - - , - ); + let tree: ReturnType | null = null; + + await act(async () => { + tree = render( + + + , + ); + }); expect(tree).toMatchSnapshot(); }); diff --git a/src/renderer/components/notifications/__snapshots__/AccountNotifications.test.tsx.snap b/src/renderer/components/notifications/__snapshots__/AccountNotifications.test.tsx.snap index c2dbf27b6..e58a0bab1 100644 --- a/src/renderer/components/notifications/__snapshots__/AccountNotifications.test.tsx.snap +++ b/src/renderer/components/notifications/__snapshots__/AccountNotifications.test.tsx.snap @@ -225,7 +225,7 @@ exports[`renderer/components/notifications/AccountNotifications.tsx should rende alt="๐Ÿ”ฅ" class="emoji" draggable="false" - src="images/twemoji/1f525.svg" + src="/mock/images/assets/1f525.svg" />
Date: Wed, 27 Aug 2025 14:33:47 -0400 Subject: [PATCH 04/16] feat: context bridge Signed-off-by: Adam Setch --- src/renderer/components/AllRead.test.tsx | 62 +++++++++++-------- src/renderer/components/Oops.test.tsx | 19 ++++-- .../__snapshots__/AllRead.test.tsx.snap | 8 +-- .../__snapshots__/Oops.test.tsx.snap | 8 +-- .../components/layout/EmojiSplash.test.tsx | 33 +++++++--- .../__snapshots__/EmojiSplash.test.tsx.snap | 8 +-- .../components/primitives/EmojiText.test.tsx | 12 +++- .../__snapshots__/EmojiText.test.tsx.snap | 6 +- .../SettingsFooter.test.tsx.snap | 52 ---------------- 9 files changed, 96 insertions(+), 112 deletions(-) diff --git a/src/renderer/components/AllRead.test.tsx b/src/renderer/components/AllRead.test.tsx index b43c833d7..10cc58cb9 100644 --- a/src/renderer/components/AllRead.test.tsx +++ b/src/renderer/components/AllRead.test.tsx @@ -1,4 +1,4 @@ -import { render } from '@testing-library/react'; +import { act, render } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { mockSettings } from '../__mocks__/state-mocks'; @@ -11,37 +11,45 @@ describe('renderer/components/AllRead.tsx', () => { ensureStableEmojis(); }); - it('should render itself & its children - no filters', () => { - const tree = render( - - + it('should render itself & its children - no filters', async () => { + let tree: ReturnType | null = null; + + await act(async () => { + tree = render( + - - , - ); + , + ); + }); expect(tree).toMatchSnapshot(); }); - it('should render itself & its children - with filters', () => { - const tree = render( - - - - - , - ); + it('should render itself & its children - with filters', async () => { + let tree: ReturnType | null = null; + + await act(async () => { + tree = render( + + + + + , + ); + }); expect(tree).toMatchSnapshot(); }); diff --git a/src/renderer/components/Oops.test.tsx b/src/renderer/components/Oops.test.tsx index 4293911b1..9eb16be2f 100644 --- a/src/renderer/components/Oops.test.tsx +++ b/src/renderer/components/Oops.test.tsx @@ -1,4 +1,4 @@ -import { render } from '@testing-library/react'; +import { act, render } from '@testing-library/react'; import { ensureStableEmojis } from '../__mocks__/utils'; import { Oops } from './Oops'; @@ -8,19 +8,28 @@ describe('renderer/components/Oops.tsx', () => { ensureStableEmojis(); }); - it('should render itself & its children - specified error', () => { + it('should render itself & its children - specified error', async () => { const mockError = { title: 'Error title', descriptions: ['Error description'], emojis: ['๐Ÿ”ฅ'], }; - const tree = render(); + + let tree: ReturnType | null = null; + + await act(async () => { + tree = render(); + }); expect(tree).toMatchSnapshot(); }); - it('should render itself & its children - fallback to unknown error', () => { - const tree = render(); + it('should render itself & its children - fallback to unknown error', async () => { + let tree: ReturnType | null = null; + + await act(async () => { + tree = render(); + }); expect(tree).toMatchSnapshot(); }); diff --git a/src/renderer/components/__snapshots__/AllRead.test.tsx.snap b/src/renderer/components/__snapshots__/AllRead.test.tsx.snap index b7c0b9505..cc16d56ca 100644 --- a/src/renderer/components/__snapshots__/AllRead.test.tsx.snap +++ b/src/renderer/components/__snapshots__/AllRead.test.tsx.snap @@ -38,7 +38,7 @@ exports[`renderer/components/AllRead.tsx should render itself & its children - n alt="๐ŸŽŠ" class="emoji" draggable="false" - src="images/twemoji/1f38a.svg" + src="/mock/images/assets/1f38a.svg" />
{ - it('should render itself & its children - heading only', () => { - const tree = render(); + it('should render itself & its children - heading only', async () => { + let tree: ReturnType | null = null; + + await act(async () => { + tree = await act(async () => { + return render(); + }); + }); expect(tree).toMatchSnapshot(); }); - it('should render itself & its children - heading and sub-heading', () => { - const tree = render( - , - ); + it('should render itself & its children - heading and sub-heading', async () => { + let tree: ReturnType | null = null; + + await act(async () => { + tree = await act(async () => { + return render( + , + ); + }); + }); expect(tree).toMatchSnapshot(); }); diff --git a/src/renderer/components/layout/__snapshots__/EmojiSplash.test.tsx.snap b/src/renderer/components/layout/__snapshots__/EmojiSplash.test.tsx.snap index 63678a03d..403d19678 100644 --- a/src/renderer/components/layout/__snapshots__/EmojiSplash.test.tsx.snap +++ b/src/renderer/components/layout/__snapshots__/EmojiSplash.test.tsx.snap @@ -38,7 +38,7 @@ exports[`renderer/components/layout/EmojiSplash.tsx should render itself & its c alt="๐Ÿบ" class="emoji" draggable="false" - src="images/twemoji/1f37a.svg" + src="/mock/images/assets/1f37a.svg" />
{ - it('should render', () => { + it('should render', async () => { const props: IEmojiText = { text: '๐Ÿบ', }; - const tree = render(); + + let tree: ReturnType | null = null; + + await act(async () => { + tree = render(); + }); + expect(tree).toMatchSnapshot(); }); }); diff --git a/src/renderer/components/primitives/__snapshots__/EmojiText.test.tsx.snap b/src/renderer/components/primitives/__snapshots__/EmojiText.test.tsx.snap index fcdc09413..ead769f24 100644 --- a/src/renderer/components/primitives/__snapshots__/EmojiText.test.tsx.snap +++ b/src/renderer/components/primitives/__snapshots__/EmojiText.test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`renderer/components/primitives/EmojiText.tsx should render 1`] = ` { @@ -12,7 +12,7 @@ exports[`renderer/components/primitives/EmojiText.tsx should render 1`] = ` alt="๐Ÿบ" class="emoji" draggable="false" - src="images/twemoji/1f37a.svg" + src="/mock/images/assets/1f37a.svg" />
@@ -25,7 +25,7 @@ exports[`renderer/components/primitives/EmojiText.tsx should render 1`] = ` alt="๐Ÿบ" class="emoji" draggable="false" - src="images/twemoji/1f37a.svg" + src="/mock/images/assets/1f37a.svg" /> , diff --git a/src/renderer/components/settings/__snapshots__/SettingsFooter.test.tsx.snap b/src/renderer/components/settings/__snapshots__/SettingsFooter.test.tsx.snap index b0085bcb5..9811b275a 100644 --- a/src/renderer/components/settings/__snapshots__/SettingsFooter.test.tsx.snap +++ b/src/renderer/components/settings/__snapshots__/SettingsFooter.test.tsx.snap @@ -1,57 +1,5 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`renderer/components/settings/SettingsFooter.tsx app version should show development app version 1`] = ` - -`; - -exports[`renderer/components/settings/SettingsFooter.tsx app version should show production app version 1`] = ` - -`; - exports[`renderer/components/settings/SettingsFooter.tsx should show app version 1`] = `