diff --git a/debug_log_preload.js b/debug_log_preload.js index f4725a495..cfc5c8568 100644 --- a/debug_log_preload.js +++ b/debug_log_preload.js @@ -15,7 +15,7 @@ const { const { Context: SignalContext } = require('./ts/context'); -window.SignalContext = new SignalContext(); +window.SignalContext = new SignalContext(ipcRenderer); const config = url.parse(window.location.toString(), true).query; const { locale } = config; diff --git a/js/permissions_popup_start.js b/js/permissions_popup_start.js index b7008a761..7f8fdf8d3 100644 --- a/js/permissions_popup_start.js +++ b/js/permissions_popup_start.js @@ -20,7 +20,7 @@ async function applyTheme() { applyTheme(); -window.subscribeToSystemThemeChange(() => { +window.SignalContext.nativeThemeListener.subscribe(() => { applyTheme(); }); diff --git a/js/settings_start.js b/js/settings_start.js index 33a8b2caa..eb1948165 100644 --- a/js/settings_start.js +++ b/js/settings_start.js @@ -20,7 +20,7 @@ async function applyTheme() { applyTheme(); -window.subscribeToSystemThemeChange(() => { +window.SignalContext.nativeThemeListener.subscribe(() => { applyTheme(); }); diff --git a/main.js b/main.js index de4184a7d..701ce170a 100644 --- a/main.js +++ b/main.js @@ -122,6 +122,7 @@ const { } = require('./ts/types/Settings'); const { Environment } = require('./ts/environment'); const { ChallengeMainHandler } = require('./ts/main/challengeMain'); +const { NativeThemeNotifier } = require('./ts/main/NativeThemeNotifier'); const { PowerChannel } = require('./ts/main/powerChannel'); const { maybeParseUrl, setUrlSearchParams } = require('./ts/util/url'); @@ -136,6 +137,9 @@ const systemTraySettingCache = new SystemTraySettingCache( const challengeHandler = new ChallengeMainHandler(); +const nativeThemeNotifier = new NativeThemeNotifier(); +nativeThemeNotifier.initialize(); + let sqlInitTimeStart = 0; let sqlInitTimeEnd = 0; @@ -303,6 +307,8 @@ function handleCommonWindowEvents(window) { window.webContents.on('preload-error', (event, preloadPath, error) => { console.error(`Preload error in ${preloadPath}: `, error.message); }); + + nativeThemeNotifier.addWindow(window); } const DEFAULT_WIDTH = 800; diff --git a/permissions_popup_preload.js b/permissions_popup_preload.js index 5d5f56e4e..cff4fb816 100644 --- a/permissions_popup_preload.js +++ b/permissions_popup_preload.js @@ -6,7 +6,7 @@ window.React = require('react'); window.ReactDOM = require('react-dom'); -const { ipcRenderer, remote } = require('electron'); +const { ipcRenderer } = require('electron'); const url = require('url'); const i18n = require('./js/modules/i18n'); const { ConfirmationDialog } = require('./ts/components/ConfirmationDialog'); @@ -17,8 +17,6 @@ const { parseEnvironment, } = require('./ts/environment'); -const { nativeTheme } = remote.require('electron'); - const { Context: SignalContext } = require('./ts/context'); const config = url.parse(window.location.toString(), true).query; @@ -26,7 +24,7 @@ const { locale } = config; const localeMessages = ipcRenderer.sendSync('locale-data'); setEnvironment(parseEnvironment(config.environment)); -window.SignalContext = new SignalContext(); +window.SignalContext = new SignalContext(ipcRenderer); window.getEnvironment = getEnvironment; window.getVersion = () => config.version; @@ -40,19 +38,6 @@ window.Signal = { }, }; -function setSystemTheme() { - window.systemTheme = nativeTheme.shouldUseDarkColors ? 'dark' : 'light'; -} - -setSystemTheme(); - -window.subscribeToSystemThemeChange = fn => { - nativeTheme.on('updated', () => { - setSystemTheme(); - fn(); - }); -}; - require('./ts/logging/set_up_renderer_logging').initialize(); window.closePermissionsPopup = () => diff --git a/preload.js b/preload.js index d1e55d47e..0a021b42a 100644 --- a/preload.js +++ b/preload.js @@ -19,14 +19,14 @@ try { parseEnvironment, Environment, } = require('./ts/environment'); + const ipc = electron.ipcRenderer; const { remote } = electron; const { app } = remote; - const { nativeTheme } = remote.require('electron'); const { Context: SignalContext } = require('./ts/context'); - window.SignalContext = new SignalContext(); + window.SignalContext = new SignalContext(ipc); window.sqlInitializer = require('./ts/sql/initialize'); @@ -77,19 +77,6 @@ try { app.setLoginItemSettings({ openAtLogin: Boolean(value) }); }; - function setSystemTheme() { - window.systemTheme = nativeTheme.shouldUseDarkColors ? 'dark' : 'light'; - } - - setSystemTheme(); - - window.subscribeToSystemThemeChange = fn => { - nativeTheme.on('updated', () => { - setSystemTheme(); - fn(); - }); - }; - window.isBeforeVersion = (toCheck, baseVersion) => { try { return semver.lt(toCheck, baseVersion); @@ -113,7 +100,6 @@ try { } }; - const ipc = electron.ipcRenderer; const localeMessages = ipc.sendSync('locale-data'); window.setBadgeCount = count => ipc.send('set-badge-count', count); diff --git a/screenShare_preload.js b/screenShare_preload.js index 11510d9b6..70f40de81 100644 --- a/screenShare_preload.js +++ b/screenShare_preload.js @@ -20,7 +20,7 @@ const { const { Context: SignalContext } = require('./ts/context'); -window.SignalContext = new SignalContext(); +window.SignalContext = new SignalContext(ipcRenderer); const config = url.parse(window.location.toString(), true).query; const { locale } = config; diff --git a/settings_preload.js b/settings_preload.js index 9be26c426..7f0534e1b 100644 --- a/settings_preload.js +++ b/settings_preload.js @@ -3,7 +3,7 @@ /* global window */ -const { ipcRenderer, remote } = require('electron'); +const { ipcRenderer } = require('electron'); const url = require('url'); const i18n = require('./js/modules/i18n'); @@ -18,11 +18,9 @@ const { locale } = config; const localeMessages = ipcRenderer.sendSync('locale-data'); setEnvironment(parseEnvironment(config.environment)); -const { nativeTheme } = remote.require('electron'); - const { Context: SignalContext } = require('./ts/context'); -window.SignalContext = new SignalContext(); +window.SignalContext = new SignalContext(ipcRenderer); window.platform = process.platform; window.theme = config.theme; @@ -30,19 +28,6 @@ window.i18n = i18n.setup(locale, localeMessages); window.appStartInitialSpellcheckSetting = config.appStartInitialSpellcheckSetting === 'true'; -function setSystemTheme() { - window.systemTheme = nativeTheme.shouldUseDarkColors ? 'dark' : 'light'; -} - -setSystemTheme(); - -window.subscribeToSystemThemeChange = fn => { - nativeTheme.on('updated', () => { - setSystemTheme(); - fn(); - }); -}; - window.getEnvironment = getEnvironment; window.getVersion = () => config.version; window.getAppInstance = () => config.appInstance; diff --git a/sticker-creator/preload.js b/sticker-creator/preload.js index c5c75bb7d..642bb4e90 100644 --- a/sticker-creator/preload.js +++ b/sticker-creator/preload.js @@ -19,7 +19,6 @@ const { const { makeGetter } = require('../preload_utils'); const { dialog } = remote; -const { nativeTheme } = remote.require('electron'); const { Context: SignalContext } = require('../ts/context'); @@ -29,7 +28,7 @@ const MAX_STICKER_DIMENSION = STICKER_SIZE; const MAX_WEBP_STICKER_BYTE_LENGTH = 100 * 1024; const MAX_ANIMATED_STICKER_BYTE_LENGTH = 300 * 1024; -window.SignalContext = new SignalContext(); +window.SignalContext = new SignalContext(ipc); setEnvironment(parseEnvironment(config.environment)); @@ -280,6 +279,7 @@ const getThemeSetting = makeGetter('theme-setting'); async function resolveTheme() { const theme = (await getThemeSetting()) || 'system'; if (process.platform === 'darwin' && theme === 'system') { + const { theme: nativeTheme } = window.SignalContext.nativeThemeListener; return nativeTheme.shouldUseDarkColors ? 'dark' : 'light'; } return theme; @@ -293,8 +293,6 @@ async function applyTheme() { window.addEventListener('DOMContentLoaded', applyTheme); -nativeTheme.on('updated', () => { - applyTheme(); -}); +window.SignalContext.nativeThemeListener.subscribe(() => applyTheme()); window.log.info('sticker-creator preload complete...'); diff --git a/test/setup-test-node.js b/test/setup-test-node.js index 2cb0d27ab..70f763e7d 100644 --- a/test/setup-test-node.js +++ b/test/setup-test-node.js @@ -20,7 +20,7 @@ const storageMap = new Map(); // To replicate logic we have on the client side global.window = { - SignalContext: new SignalContext(), + SignalContext: undefined, log: { info: (...args) => console.log(...args), warn: (...args) => console.warn(...args), @@ -38,6 +38,21 @@ global.window = { isValidGuid, }; +const fakeIPC = { + sendSync(channel) { + // See `ts/context/NativeThemeListener.ts` + if (channel === 'native-theme:init') { + return { shouldUseDarkColors: true }; + } + + throw new Error(`Unsupported sendSync channel: ${channel}`); + }, + + on() {}, +}; + +global.window.SignalContext = new SignalContext(fakeIPC); + // For ducks/network.getEmptyState() global.navigator = {}; global.WebSocket = {}; diff --git a/ts/background.ts b/ts/background.ts index 3265cab53..6306d048a 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -2295,6 +2295,10 @@ export async function startApp(): Promise { } } + window.SignalContext.nativeThemeListener.subscribe(() => { + onChangeTheme(); + }); + const FIVE_MINUTES = 5 * 60 * 1000; // Note: once this function returns, there still might be messages being processed on diff --git a/ts/context/NativeThemeListener.ts b/ts/context/NativeThemeListener.ts new file mode 100644 index 000000000..57bcd869c --- /dev/null +++ b/ts/context/NativeThemeListener.ts @@ -0,0 +1,48 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +/* eslint-disable no-restricted-syntax */ + +import { NativeThemeState } from '../types/NativeThemeNotifier.d'; + +export type Callback = (change: NativeThemeState) => void; + +export interface MinimalIPC { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sendSync(channel: string): any; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + on(channel: string, listener: (event: unknown, ...args: any[]) => void): this; +} + +export type SystemThemeHolder = { systemTheme: 'dark' | 'light' }; + +export class NativeThemeListener { + private readonly subscribers = new Array(); + + public theme: NativeThemeState; + + constructor(ipc: MinimalIPC, private readonly holder: SystemThemeHolder) { + this.theme = ipc.sendSync('native-theme:init'); + this.update(); + + ipc.on( + 'native-theme:changed', + (_event: unknown, change: NativeThemeState) => { + this.theme = change; + this.update(); + + for (const fn of this.subscribers) { + fn(change); + } + } + ); + } + + public subscribe(fn: Callback): void { + this.subscribers.push(fn); + } + + private update(): void { + this.holder.systemTheme = this.theme.shouldUseDarkColors ? 'dark' : 'light'; + } +} diff --git a/ts/context/index.ts b/ts/context/index.ts index 9409e09cf..b1b3ed7b6 100644 --- a/ts/context/index.ts +++ b/ts/context/index.ts @@ -2,7 +2,14 @@ // SPDX-License-Identifier: AGPL-3.0-only import { Bytes } from './Bytes'; +import { NativeThemeListener, MinimalIPC } from './NativeThemeListener'; export class Context { public readonly bytes = new Bytes(); + + public readonly nativeThemeListener; + + constructor(ipc: MinimalIPC) { + this.nativeThemeListener = new NativeThemeListener(ipc, window); + } } diff --git a/ts/main/NativeThemeNotifier.ts b/ts/main/NativeThemeNotifier.ts new file mode 100644 index 000000000..77e66adcb --- /dev/null +++ b/ts/main/NativeThemeNotifier.ts @@ -0,0 +1,46 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +/* eslint-disable no-restricted-syntax */ + +import { ipcMain as ipc, nativeTheme, BrowserWindow } from 'electron'; + +import { NativeThemeState } from '../types/NativeThemeNotifier.d'; + +function getState(): NativeThemeState { + return { + shouldUseDarkColors: nativeTheme.shouldUseDarkColors, + }; +} + +export class NativeThemeNotifier { + private readonly listeners = new Set(); + + public initialize(): void { + nativeTheme.on('updated', () => { + this.notifyListeners(); + }); + + ipc.on('native-theme:init', event => { + // eslint-disable-next-line no-param-reassign + event.returnValue = getState(); + }); + } + + public addWindow(window: BrowserWindow): void { + if (this.listeners.has(window)) { + return; + } + + this.listeners.add(window); + + window.once('closed', () => { + this.listeners.delete(window); + }); + } + + private notifyListeners(): void { + for (const window of this.listeners) { + window.webContents.send('native-theme:changed', getState()); + } + } +} diff --git a/ts/test-electron/context/NativeThemeListener_test.ts b/ts/test-electron/context/NativeThemeListener_test.ts new file mode 100644 index 000000000..306dd2508 --- /dev/null +++ b/ts/test-electron/context/NativeThemeListener_test.ts @@ -0,0 +1,81 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { EventEmitter } from 'events'; + +import { + NativeThemeListener, + MinimalIPC, + SystemThemeHolder, +} from '../../context/NativeThemeListener'; +import { NativeThemeState } from '../../types/NativeThemeNotifier.d'; + +class FakeIPC extends EventEmitter implements MinimalIPC { + constructor(private readonly state: NativeThemeState) { + super(); + } + + public sendSync(channel: string) { + assert.strictEqual(channel, 'native-theme:init'); + return this.state; + } +} + +describe('NativeThemeListener', () => { + const holder: SystemThemeHolder = { systemTheme: 'dark' }; + + it('syncs the initial native theme', () => { + const dark = new NativeThemeListener( + new FakeIPC({ + shouldUseDarkColors: true, + }), + holder + ); + + assert.strictEqual(holder.systemTheme, 'dark'); + assert.isTrue(dark.theme.shouldUseDarkColors); + + const light = new NativeThemeListener( + new FakeIPC({ + shouldUseDarkColors: false, + }), + holder + ); + + assert.strictEqual(holder.systemTheme, 'light'); + assert.isFalse(light.theme.shouldUseDarkColors); + }); + + it('should react to native theme changes', () => { + const ipc = new FakeIPC({ + shouldUseDarkColors: true, + }); + + const listener = new NativeThemeListener(ipc, holder); + + ipc.emit('native-theme:changed', null, { + shouldUseDarkColors: false, + }); + + assert.strictEqual(holder.systemTheme, 'light'); + assert.isFalse(listener.theme.shouldUseDarkColors); + }); + + it('should notify subscribers of native theme changes', done => { + const ipc = new FakeIPC({ + shouldUseDarkColors: true, + }); + + const listener = new NativeThemeListener(ipc, holder); + + listener.subscribe(state => { + assert.isFalse(state.shouldUseDarkColors); + done(); + }); + + ipc.emit('native-theme:changed', null, { + shouldUseDarkColors: false, + }); + }); +}); diff --git a/ts/types/NativeThemeNotifier.d.ts b/ts/types/NativeThemeNotifier.d.ts new file mode 100644 index 000000000..d01d44ad9 --- /dev/null +++ b/ts/types/NativeThemeNotifier.d.ts @@ -0,0 +1,6 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export type NativeThemeState = Readonly<{ + shouldUseDarkColors: boolean; +}>;