diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d8c94dc4d..07edcf302 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,7 +26,6 @@ jobs: - run: pnpm install - run: pnpm build - - run: pnpm prepare:remove-source-maps - run: pnpm package:macos --publish=never -c.mac.identity=null env: CSC_LINK: ${{ secrets.CSC_LINK }} @@ -61,7 +60,6 @@ jobs: - run: pnpm install - run: pnpm build - - run: pnpm prepare:remove-source-maps - run: pnpm package:win --publish=never - name: Clean up builds @@ -93,7 +91,6 @@ jobs: - run: pnpm install - run: pnpm build - - run: pnpm prepare:remove-source-maps - run: pnpm package:linux --publish=never - name: Clean up builds diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 77a572a8c..8752c8efb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -30,7 +30,6 @@ jobs: env: OAUTH_CLIENT_ID: ${{ secrets.OAUTH_CLIENT_ID }} OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }} - - run: pnpm prepare:remove-source-maps - run: pnpm package:macos --publish onTagOrDraft env: APPLE_ID_USERNAME: ${{ secrets.APPLE_ID_USERNAME }} @@ -70,7 +69,6 @@ jobs: env: OAUTH_CLIENT_ID: ${{ secrets.OAUTH_CLIENT_ID }} OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }} - - run: pnpm prepare:remove-source-maps - run: pnpm package:win --publish onTagOrDraft env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -104,7 +102,6 @@ jobs: env: OAUTH_CLIENT_ID: ${{ secrets.OAUTH_CLIENT_ID }} OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }} - - run: pnpm prepare:remove-source-maps - run: pnpm package:linux --publish onTagOrDraft env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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/config/webpack.config.main.base.ts b/config/webpack.config.main.base.ts index af0807023..2762011a3 100644 --- a/config/webpack.config.main.base.ts +++ b/config/webpack.config.main.base.ts @@ -18,9 +18,6 @@ const configuration: webpack.Configuration = { output: { path: webpackPaths.buildPath, filename: 'main.js', - library: { - type: 'umd', - }, }, }; diff --git a/config/webpack.config.main.prod.ts b/config/webpack.config.main.prod.ts index 3ebfe192f..7ecf4bac5 100644 --- a/config/webpack.config.main.prod.ts +++ b/config/webpack.config.main.prod.ts @@ -5,7 +5,7 @@ import { merge } from 'webpack-merge'; import baseConfig from './webpack.config.main.base'; const configuration: webpack.Configuration = { - devtool: 'source-map', + devtool: false, mode: 'production', diff --git a/config/webpack.config.preload.base.ts b/config/webpack.config.preload.base.ts new file mode 100644 index 000000000..1fe0af5c8 --- /dev/null +++ b/config/webpack.config.preload.base.ts @@ -0,0 +1,24 @@ +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', + }, +}; + +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..15ad49881 --- /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: false, + + 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..be1212a90 100644 --- a/config/webpack.config.renderer.base.ts +++ b/config/webpack.config.renderer.base.ts @@ -1,30 +1,43 @@ 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', mode: 'development', - target: 'electron-renderer', + target: ['web', 'electron-renderer'], entry: [path.join(webpackPaths.srcRendererPath, 'index.tsx')], output: { path: webpackPaths.buildPath, filename: 'renderer.js', - library: { - type: 'umd', - }, }, module: { @@ -61,7 +74,6 @@ const configuration: webpack.Configuration = { removeAttributeQuotes: true, removeComments: true, }, - isBrowser: false, }), // Twemoji SVGs for Emoji parsing @@ -87,4 +99,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.config.renderer.prod.ts b/config/webpack.config.renderer.prod.ts index babaaf36a..f1fdb2948 100644 --- a/config/webpack.config.renderer.prod.ts +++ b/config/webpack.config.renderer.prod.ts @@ -6,7 +6,7 @@ import { merge } from 'webpack-merge'; import baseConfig from './webpack.config.renderer.base'; const configuration: webpack.Configuration = { - devtool: 'source-map', + devtool: false, mode: 'production', 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 b2055895a..ebd4d1ec7 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,14 @@ "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", "package:macos": "electron-builder --mac --config ./config/electron-builder.js", "package:win": "electron-builder --win --config ./config/electron-builder.js", @@ -31,12 +32,10 @@ "keywords": [ "gitify", "github", - "notifier", "notifications", "electron", - "atom", - "shell", - "app", + "menubar", + "taskbar", "tray" ], "author": { @@ -67,7 +66,6 @@ }, "homepage": "https://gitify.io/", "dependencies": { - "@electron/remote": "2.1.3", "electron-log": "5.4.3", "electron-updater": "6.6.2", "menubar": "9.5.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c767eacd..55c3483f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - '@electron/remote': - specifier: 2.1.3 - version: 2.1.3(electron@38.0.0) electron-log: specifier: 5.4.3 version: 5.4.3 @@ -504,11 +501,6 @@ packages: engines: {node: '>=12.13.0'} hasBin: true - '@electron/remote@2.1.3': - resolution: {integrity: sha512-XlpxC8S4ttj/v2d+PKp9na/3Ev8bV7YWNL7Cw5b9MAWgTphEml7iYgbc7V0r9D6yDOfOkj06bchZgOZdlWJGNA==} - peerDependencies: - electron: '>= 13.0.0' - '@electron/universal@1.5.1': resolution: {integrity: sha512-kbgXxyEauPJiQQUNG2VgUeyfQNFk6hBF11ISN2PNI6agUgPl55pv4eQmaqHzTAzchBvqZ2tQuRVaPStGf0mxGw==} engines: {node: '>=8.6'} @@ -5032,10 +5024,6 @@ snapshots: - bluebird - supports-color - '@electron/remote@2.1.3(electron@38.0.0)': - dependencies: - electron: 38.0.0 - '@electron/universal@1.5.1': dependencies: '@electron/asar': 3.2.18 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/scripts/delete-source-maps.ts b/scripts/delete-source-maps.ts deleted file mode 100644 index 8d61b989b..000000000 --- a/scripts/delete-source-maps.ts +++ /dev/null @@ -1,16 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; - -import { rimrafSync } from 'rimraf'; - -import webpackPaths from '../config/webpack.paths'; - -function deleteSourceMaps() { - if (fs.existsSync(webpackPaths.buildPath)) { - rimrafSync(path.join(webpackPaths.buildPath, '*.map'), { - glob: true, - }); - } -} - -deleteSourceMaps(); 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/__helpers__/jest.setup.ts b/src/renderer/__helpers__/jest.setup.ts index 1f8c2ac4f..38e52824b 100644 --- a/src/renderer/__helpers__/jest.setup.ts +++ b/src/renderer/__helpers__/jest.setup.ts @@ -1,37 +1,69 @@ import '@testing-library/jest-dom'; -import { TextDecoder, TextEncoder } from 'node:util'; + +import { TextEncoder } from 'node:util'; /** - * Prevent the following errors with jest: - * - ReferenceError: TextEncoder is not defined - * - ReferenceError: TextDecoder is not defined + * Gitify context bridge API */ -if (!('TextEncoder' in globalThis)) { - (globalThis as unknown as { TextEncoder: typeof TextEncoder }).TextEncoder = - TextEncoder; -} -if (!('TextDecoder' in globalThis)) { - (globalThis as unknown as { TextDecoder: typeof TextDecoder }).TextDecoder = - TextDecoder; -} +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(), +}; // Mock OAuth client ID and secret process.env.OAUTH_CLIENT_ID = 'FAKE_CLIENT_ID_123'; process.env.OAUTH_CLIENT_SECRET = 'FAKE_CLIENT_SECRET_123'; /** - * Primer Setup + * Primer (@primer/react) Setup + * + * Borrowed from https://github.com/primer/react/blob/main/packages/react/src/utils/test-helpers.tsx */ -if (typeof CSS === 'undefined') { - global.CSS = {} as typeof CSS; -} +// @ts-expect-error: prevent ReferenceError: TextEncoder is not defined +global.TextEncoder = TextEncoder; -if (!CSS.supports) { - CSS.supports = () => true; -} +// JSDOM doesn't mock ResizeObserver +global.ResizeObserver = jest.fn().mockImplementation(() => { + return { + observe: jest.fn(), + disconnect: jest.fn(), + unobserve: jest.fn(), + }; +}); -global.ResizeObserver = class { - observe() {} - unobserve() {} - disconnect() {} +// @ts-expect-error only declare properties used internally +global.CSS = { + escape: jest.fn(), + supports: jest.fn().mockImplementation(() => { + return false; + }), }; 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/__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.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/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/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/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/__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" />
{ 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" />
{ - 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/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/primitives/__snapshots__/EmojiText.test.tsx.snap b/src/renderer/components/primitives/__snapshots__/EmojiText.test.tsx.snap index f9bb94adf..ead769f24 100644 --- a/src/renderer/components/primitives/__snapshots__/EmojiText.test.tsx.snap +++ b/src/renderer/components/primitives/__snapshots__/EmojiText.test.tsx.snap @@ -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/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 a69c337b0..6eb724ddb 100644 --- a/src/renderer/components/settings/SystemSettings.tsx +++ b/src/renderer/components/settings/SystemSettings.tsx @@ -11,12 +11,10 @@ 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'; 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 +53,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}. @@ -69,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..9811b275a 100644 --- a/src/renderer/components/settings/__snapshots__/SettingsFooter.test.tsx.snap +++ b/src/renderer/components/settings/__snapshots__/SettingsFooter.test.tsx.snap @@ -1,35 +1,9 @@ // 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`] = `