diff --git a/desktop/src/auto-update.js b/desktop/src/auto-update.js index b15fc52c9..7561143ae 100644 --- a/desktop/src/auto-update.js +++ b/desktop/src/auto-update.js @@ -1,34 +1,34 @@ // @flow // eslint-disable-next-line import/extensions import { app, ipcMain, autoUpdater } from 'electron/main'; -const getUpdateUrl = version => +const getUpdateURL = (version: string) => `https://electron-update.commtechnologies.org/update/${process.platform}/${version}`; export function initAutoUpdate(): void { - autoUpdater.setFeedURL({ url: getUpdateUrl(app.getVersion()) }); + autoUpdater.setFeedURL({ url: getUpdateURL(app.getVersion()) }); // Check for new updates every 10 minutes const updateIntervalMs = 10 * 60_000; - let currentTimeout = null; + let currentTimeout: ?TimeoutID = null; const scheduleCheckForUpdates = () => { if (!currentTimeout) { currentTimeout = setTimeout(() => { currentTimeout = null; autoUpdater.checkForUpdates(); }, updateIntervalMs); } }; scheduleCheckForUpdates(); autoUpdater.on('update-not-available', scheduleCheckForUpdates); autoUpdater.on('error', error => { console.error(error); scheduleCheckForUpdates(); }); ipcMain.on('update-to-new-version', () => autoUpdater.quitAndInstall()); } diff --git a/desktop/src/handle-squirrel-event.js b/desktop/src/handle-squirrel-event.js index dd96c7545..1d77655c8 100644 --- a/desktop/src/handle-squirrel-event.js +++ b/desktop/src/handle-squirrel-event.js @@ -1,51 +1,51 @@ // @flow import { spawn, execSync } from 'child_process'; import { app } from 'electron'; import path from 'path'; // Squirrel will start the app with additional flags during installing, // uninstalling and updating so we can for example create or delete shortcuts. // After handling some of these events the app will be closed. If this function // returns false, the app should start normally. export function handleSquirrelEvent(): boolean { if (process.argv.length === 1) { return false; } const updateExe = path.resolve(process.execPath, '..', '..', 'Update.exe'); const commExeName = path.basename(process.execPath); - const spawnUpdate = args => { + const spawnUpdate = (args: string[]) => { return spawn(updateExe, args, { detached: true }).on('close', app.quit); }; const squirrelEvent = process.argv[1]; switch (squirrelEvent) { case '--squirrel-install': case '--squirrel-updated': execSync( path.resolve(__dirname, '../assets/windows-runtime-installer.exe'), ); spawnUpdate(['--createShortcut', commExeName]); return true; case '--squirrel-uninstall': spawnUpdate(['--removeShortcut', commExeName]); return true; case '--squirrel-obsolete': app.quit(); return true; case '--squirrel-firstrun': return false; } return false; } export function isNormalStartup(): boolean { const squirrelEvent = process.argv[1]; return !squirrelEvent || squirrelEvent === '--squirrel-firstrun'; } diff --git a/desktop/src/main.js b/desktop/src/main.js index c3678653e..40bd7b53c 100644 --- a/desktop/src/main.js +++ b/desktop/src/main.js @@ -1,345 +1,350 @@ // @flow +import type { Event, MenuItemConstructorOptions } from 'electron'; import { app, BrowserWindow, shell, Menu, ipcMain, systemPreferences, autoUpdater, // eslint-disable-next-line import/extensions } from 'electron/main'; import contextMenu from 'electron-context-menu'; import fs from 'fs'; import path from 'path'; import { initAutoUpdate } from './auto-update.js'; import { handleSquirrelEvent } from './handle-squirrel-event.js'; import { listenForNotifications, registerForNotifications, } from './push-notifications.js'; const isDev = process.env.ENV === 'dev'; const url = isDev ? 'http://localhost:3000/webapp/' : 'https://web.comm.app'; const isMac = process.platform === 'darwin'; const scrollbarCSS = fs.promises.readFile( path.resolve(__dirname, '../scrollbar.css'), 'utf8', ); let mainWindow = null; const setApplicationMenu = () => { - let mainMenu = []; + let mainMenu: MenuItemConstructorOptions[] = []; if (isMac) { mainMenu = [ { label: app.name, submenu: [ { role: 'about' }, { type: 'separator' }, { role: 'services' }, { type: 'separator' }, { role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }, { role: 'quit' }, ], }, ]; } const viewMenu = { label: 'View', submenu: [ { role: 'reload' }, { type: 'separator' }, { role: 'resetZoom' }, { role: 'zoomIn' }, { role: 'zoomOut' }, { type: 'separator' }, { role: 'togglefullscreen' }, { role: 'toggleDevTools' }, { label: 'Toggle Shared Worker Developer Tools', click: () => { if (mainWindow) { mainWindow.webContents.inspectSharedWorker(); } }, }, ], }; const windowMenu = { label: 'Window', submenu: [ { role: 'minimize' }, ...(isMac ? [ { type: 'separator' }, { role: 'front' }, { type: 'separator' }, { role: 'window' }, ] : [{ role: 'close' }]), ], }; const menu = Menu.buildFromTemplate([ ...mainMenu, { role: 'fileMenu' }, { role: 'editMenu' }, viewMenu, windowMenu, ]); Menu.setApplicationMenu(menu); }; const createMainWindow = (urlPath?: string) => { const win = new BrowserWindow({ show: false, width: 1300, height: 800, minWidth: 1100, minHeight: 600, titleBarStyle: 'hidden', trafficLightPosition: { x: 20, y: 24 }, titleBarOverlay: { color: '#0A0A0A', symbolColor: '#FFFFFF', height: 64, }, backgroundColor: '#0A0A0A', webPreferences: { preload: path.resolve(__dirname, 'preload.js'), }, }); const updateNavigationState = () => { win.webContents.send('on-navigate', { canGoBack: win.webContents.canGoBack(), canGoForward: win.webContents.canGoForward(), }); }; win.webContents.on('did-navigate-in-page', updateNavigationState); const clearHistory = () => { win.webContents.clearHistory(); updateNavigationState(); }; ipcMain.on('clear-history', clearHistory); const doubleClickTopBar = () => { if (isMac) { // Possible values for AppleActionOnDoubleClick are Maximize, // Minimize or None. We handle the last two inside this if. // Maximize (which is the only behaviour for other platforms) // is handled in the later block. const action = systemPreferences.getUserDefault( 'AppleActionOnDoubleClick', 'string', ); if (action === 'None') { return; } else if (action === 'Minimize') { win.minimize(); return; } } if (win.isMaximized()) { win.unmaximize(); } else { win.maximize(); } }; ipcMain.on('double-click-top-bar', doubleClickTopBar); - const updateDownloaded = (event, releaseNotes, releaseName) => { + const updateDownloaded = ( + event: Event, + releaseNotes?: string, + releaseName: string, + ) => { win.webContents.send('on-new-version-available', releaseName); }; autoUpdater.on('update-downloaded', updateDownloaded); win.on('closed', () => { mainWindow = null; ipcMain.removeListener('clear-history', clearHistory); ipcMain.removeListener('double-click-top-bar', doubleClickTopBar); autoUpdater.removeListener('update-downloaded', updateDownloaded); }); win.webContents.setWindowOpenHandler(({ url: openURL }) => { shell.openExternal(openURL); // Returning 'deny' prevents a new electron window from being created return { action: 'deny' }; }); (async () => { const css = await scrollbarCSS; win.webContents.insertCSS(css); })(); win.loadURL(url + (urlPath ?? '')); mainWindow = win; return win; }; const createSplashWindow = () => { const win = new BrowserWindow({ width: 300, height: 300, resizable: false, frame: false, alwaysOnTop: true, center: true, backgroundColor: '#111827', }); win.loadFile(path.resolve(__dirname, '../pages/splash.html')); return win; }; const createErrorWindow = () => { const win = new BrowserWindow({ show: false, width: 400, height: 300, resizable: false, center: true, titleBarStyle: 'hidden', trafficLightPosition: { x: 20, y: 24 }, backgroundColor: '#111827', }); win.on('close', () => { app.quit(); }); win.loadFile(path.resolve(__dirname, '../pages/error.html')); return win; }; const show = (urlPath?: string) => { const splash = createSplashWindow(); const error = createErrorWindow(); const main = createMainWindow(urlPath); let loadedSuccessfully = true; const failedLoadHandler = () => { loadedSuccessfully = false; if (!splash.isDestroyed()) { splash.destroy(); } if (!error.isDestroyed()) { error.show(); } setTimeout(() => { loadedSuccessfully = true; main.loadURL(url); }, 1000); }; main.webContents.on('did-fail-load', failedLoadHandler); main.webContents.on('did-finish-load', () => { if (loadedSuccessfully) { if (!splash.isDestroyed()) { splash.destroy(); } if (!error.isDestroyed()) { error.destroy(); } main.webContents.removeListener('did-fail-load', failedLoadHandler); main.show(); if (app.isPackaged) { (async () => { const token = await registerForNotifications(); main.webContents.send('on-device-token-registered', token); })(); } } }); }; const run = () => { app.setName('Comm'); contextMenu({ showSaveImageAs: true, showSaveVideoAs: true, }); if (process.platform === 'win32') { app.setAppUserModelId('Comm'); } setApplicationMenu(); (async () => { await app.whenReady(); if (app.isPackaged) { try { initAutoUpdate(); } catch (error) { console.error(error); } listenForNotifications(threadID => { if (mainWindow) { mainWindow.webContents.send('on-notification-clicked', { threadID, }); } else { show(`chat/thread/${threadID}/`); } }); } ipcMain.on('set-badge', (event, value) => { if (isMac) { app.dock.setBadge(value?.toString() ?? ''); } }); ipcMain.on('get-version', event => { event.returnValue = app.getVersion().toString(); }); show(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { show(); } }); })(); app.on('window-all-closed', () => { if (!isMac) { app.quit(); } }); app.on('render-process-gone', (event, webContents, details) => { console.error( `EVENT: render-process-gone. Reason: '${details.reason}'. ` + `ExitCode: '${details.exitCode}'.`, ); }); app.on('child-process-gone', (event, details) => { console.error( `EVENT: child-process-gone. Process type: '${details.type}'. ` + `Reason: '${details.reason}'. ExitCode: '${details.exitCode}'.`, ); }); }; if (app.isPackaged && process.platform === 'win32') { if (!handleSquirrelEvent()) { run(); } } else { run(); } diff --git a/desktop/src/preload.js b/desktop/src/preload.js index 388514d54..969c29528 100644 --- a/desktop/src/preload.js +++ b/desktop/src/preload.js @@ -1,40 +1,45 @@ // @flow +import type { IpcRendererEvent } from 'electron'; // eslint-disable-next-line import/extensions import { contextBridge, ipcRenderer } from 'electron/renderer'; import type { ElectronBridge } from 'lib/types/electron-types.js'; const bridge: ElectronBridge = { onNavigate: callback => { - const withEvent = (event, ...args) => callback(...args); + const withEvent = (event: IpcRendererEvent, ...args: $ReadOnlyArray) => + callback(...args); ipcRenderer.on('on-navigate', withEvent); return () => ipcRenderer.removeListener('on-navigate', withEvent); }, clearHistory: () => ipcRenderer.send('clear-history'), doubleClickTopBar: () => ipcRenderer.send('double-click-top-bar'), setBadge: value => ipcRenderer.send('set-badge', value), version: ipcRenderer.sendSync('get-version'), onNewVersionAvailable: callback => { - const withEvent = (event, ...args) => callback(...args); + const withEvent = (event: IpcRendererEvent, ...args: $ReadOnlyArray) => + callback(...args); ipcRenderer.on('on-new-version-available', withEvent); return () => ipcRenderer.removeListener('on-new-version-available', withEvent); }, updateToNewVersion: () => ipcRenderer.send('update-to-new-version'), platform: { win32: 'windows', darwin: 'macos' }[process.platform], onDeviceTokenRegistered: callback => { - const withEvent = (event, ...args) => callback(...args); + const withEvent = (event: IpcRendererEvent, ...args: $ReadOnlyArray) => + callback(...args); ipcRenderer.on('on-device-token-registered', withEvent); return () => ipcRenderer.removeListener('on-device-token-registered', withEvent); }, onNotificationClicked: callback => { - const withEvent = (event, ...args) => callback(...args); + const withEvent = (event: IpcRendererEvent, ...args: $ReadOnlyArray) => + callback(...args); ipcRenderer.on('on-notification-clicked', withEvent); return () => ipcRenderer.removeListener('on-notification-clicked', withEvent); }, }; contextBridge.exposeInMainWorld('electronContextBridge', bridge); diff --git a/desktop/src/push-notifications.js b/desktop/src/push-notifications.js index 60c94fc93..652b5a34f 100644 --- a/desktop/src/push-notifications.js +++ b/desktop/src/push-notifications.js @@ -1,119 +1,126 @@ // @flow +import type { + PushNotificationManager as PushNotificationManagerType, + PushNotificationReceivedEventArgs, +} from '@commapp/windowspush'; // eslint-disable-next-line import/extensions import { app, pushNotifications, Notification } from 'electron/main'; import EventEmitter from 'events'; import { resolve } from 'path'; import { isNormalStartup } from './handle-squirrel-event.js'; let windowsPushNotificationManager; const windowsPushNotifEventEmitter = new EventEmitter(); if (process.platform === 'win32' && app.isPackaged && isNormalStartup()) { (async () => { try { const { PushNotificationManager } = await import('@commapp/windowspush'); if (!PushNotificationManager.isSupported()) { return; } windowsPushNotificationManager = PushNotificationManager.default; - const handleEvent = (manager, event) => { + const handleEvent = ( + manager: PushNotificationManagerType, + event: PushNotificationReceivedEventArgs, + ) => { const byteArray = []; for (let i = 0; i < event.payload.length; i++) { byteArray.push(event.payload[i]); } const payload = Buffer.from(byteArray).toString('utf-8'); windowsPushNotifEventEmitter.emit( 'received-wns-notification', JSON.parse(payload), ); }; // Windows requires that this must be called before the call to `register` windowsPushNotificationManager.addListener('PushReceived', handleEvent); windowsPushNotificationManager.register(); app.on('quit', () => { windowsPushNotificationManager.removeListener( 'PushReceived', handleEvent, ); windowsPushNotificationManager.unregisterAll(); }); } catch (err) { console.error( `Error while loading windows push notifications ${err.message}`, ); } })(); } async function registerForNotifications(): Promise { if (process.platform === 'darwin') { try { const token = await pushNotifications.registerForAPNSNotifications(); return token; } catch (err) { console.error(err); } } else if (process.platform === 'win32' && windowsPushNotificationManager) { try { - const token = await new Promise((resolvePromise, reject) => { + const token = await new Promise((resolvePromise, reject) => { windowsPushNotificationManager.createChannelAsync( 'f09f4211-a998-40c1-a515-689e3faecb62', (error, result) => { if (error) { reject(error); } resolvePromise(result.channel.uri); }, ); }); return token; } catch (err) { console.error(err); } } return null; } function showNewNotification( payload: { +[string]: mixed }, handleClick: (threadID: string) => void, ) { if ( typeof payload.title !== 'string' || typeof payload.body !== 'string' || typeof payload.threadID !== 'string' ) { return; } const { title, body, threadID } = payload; const windowsIconPath = resolve(__dirname, '../icons/icon.ico'); const notif = new Notification({ title, body, icon: process.platform === 'win32' ? windowsIconPath : undefined, }); notif.on('click', () => handleClick(threadID)); notif.show(); } function listenForNotifications(handleClick: (threadID: string) => void) { if (process.platform === 'darwin') { pushNotifications.on('received-apns-notification', (event, userInfo) => { showNewNotification(userInfo, handleClick); }); } else if (process.platform === 'win32') { windowsPushNotifEventEmitter.on('received-wns-notification', payload => { showNewNotification(payload, handleClick); }); } } export { listenForNotifications, registerForNotifications };