From 584d8ef209ec98099b7649639137d89115a966ad Mon Sep 17 00:00:00 2001 From: Matiss Janis Aboltins Date: Tue, 12 Sep 2023 20:55:46 +0100 Subject: [PATCH] :bug: (electron) reconnect to sockets if connection lost --- packages/desktop-electron/index.js | 64 ++++++++++++++----- packages/loot-core/src/client/actions/app.ts | 8 ++- .../src/platform/client/fetch/index.web.ts | 14 +++- 3 files changed, 67 insertions(+), 19 deletions(-) diff --git a/packages/desktop-electron/index.js b/packages/desktop-electron/index.js index 843d0795cb5..39f44c4e20e 100644 --- a/packages/desktop-electron/index.js +++ b/packages/desktop-electron/index.js @@ -38,6 +38,7 @@ require('./security'); const { fork } = require('child_process'); const path = require('path'); +const http = require('http'); require('./setRequireHook'); @@ -55,10 +56,8 @@ const WindowState = require('./window-state.js'); // Keep a global reference of the window object, if you don't, the window will // be closed automatically when the JavaScript object is garbage collected. let clientWin; -let serverWin; // eslint-disable-line @typescript-eslint/no-unused-vars let serverProcess; let serverSocket; -let IS_QUITTING = false; updater.onEvent((type, data) => { // Notify both the app and the about window @@ -98,9 +97,48 @@ function createBackgroundProcess(socketName) { console.log('Unknown server message: ' + msg.type); } }); + + return serverProcess; +} + +const isPortFree = port => + new Promise(resolve => { + const server = http + .createServer() + .listen(port, () => { + server.close(); + resolve(true); + }) + .on('error', () => { + resolve(false); + }); + }); + +async function createSocketConnection() { + if (!serverSocket) serverSocket = await getRandomPort(); + + // Spawn the child process if it is not already running + // (sometimes long child processes die, so we need to set them + // up again) + const isFree = await isPortFree(serverSocket); + if (isFree) { + await createBackgroundProcess(serverSocket); + } + + if (!clientWin) { + return; + } + + // Send a heartbeat to the client whenever we attempt to create a new + // sockets connection + clientWin.webContents.executeJavaScript( + `window.__actionsForMenu && window.__actionsForMenu.reconnect(${serverSocket})`, + ); } async function createWindow() { + await createSocketConnection(); + const windowState = await WindowState.get(); // Create the browser window. @@ -137,15 +175,6 @@ async function createWindow() { win.loadURL(`app://actual/`); } - win.on('close', () => { - // We don't want to close the budget on exit because that will - // clear the state which re-opens the last budget automatically on - // startup - if (!IS_QUITTING) { - clientWin.webContents.executeJavaScript('__actionsForMenu.closeBudget()'); - } - }); - win.on('closed', () => { clientWin = null; updateMenu(false); @@ -233,8 +262,6 @@ function updateMenu(isBudgetOpen) { app.setAppUserModelId('com.shiftreset.actual'); app.on('ready', async () => { - serverSocket = await getRandomPort(); - // Install an `app://` protocol that always returns the base HTML // file no matter what URL it is. This allows us to use react-router // on the frontend @@ -277,8 +304,6 @@ app.on('ready', async () => { require('electron').powerMonitor.on('suspend', () => { console.log('Suspending', new Date()); }); - - createBackgroundProcess(serverSocket); }); app.on('window-all-closed', () => { @@ -289,7 +314,6 @@ app.on('window-all-closed', () => { }); app.on('before-quit', () => { - IS_QUITTING = true; if (serverProcess) { serverProcess.kill(); serverProcess = null; @@ -302,6 +326,14 @@ app.on('activate', () => { } }); +app.on('did-become-active', () => { + // Reconnect whenever the window becomes active; + // We don't know what might have happened in-between, so it's better + // to be safe than sorry; the client can then decide if it wants to + // reconnect or not. + createSocketConnection(); +}); + ipcMain.on('get-bootstrap-data', event => { event.returnValue = { version: app.getVersion(), diff --git a/packages/loot-core/src/client/actions/app.ts b/packages/loot-core/src/client/actions/app.ts index dac6c7bb5ca..9383b120491 100644 --- a/packages/loot-core/src/client/actions/app.ts +++ b/packages/loot-core/src/client/actions/app.ts @@ -1,4 +1,4 @@ -import { send } from '../../platform/client/fetch'; +import { init as initConnection, send } from '../../platform/client/fetch'; import * as constants from '../constants'; import type { AppState, @@ -15,6 +15,12 @@ export function setAppState(state: Partial): SetAppStateAction { }; } +export function reconnect(connectionName: string) { + return () => { + initConnection(connectionName); + }; +} + export function updateApp() { return async (dispatch: Dispatch) => { global.Actual.applyAppUpdate(); diff --git a/packages/loot-core/src/platform/client/fetch/index.web.ts b/packages/loot-core/src/platform/client/fetch/index.web.ts index 2040d8f0d05..b993bfc4bd6 100644 --- a/packages/loot-core/src/platform/client/fetch/index.web.ts +++ b/packages/loot-core/src/platform/client/fetch/index.web.ts @@ -8,9 +8,17 @@ let replyHandlers = new Map(); let listeners = new Map(); let messageQueue = []; let socketClient = null; +let activePort = null; function connectSocket(port, onOpen) { + // Do nothing if connection to this port is already active + if (socketClient && port === activePort) { + return; + } + let client = new WebSocket('ws://localhost:' + port); + socketClient = client; + activePort = port; client.onmessage = event => { const msg = JSON.parse(event.data); @@ -62,7 +70,6 @@ function connectSocket(port, onOpen) { }; client.onopen = event => { - socketClient = client; // Send any messages that were queued while closed if (messageQueue.length > 0) { messageQueue.forEach(msg => { @@ -73,10 +80,13 @@ function connectSocket(port, onOpen) { onOpen(); }; + + client.onclose = () => { + socketClient = null; + }; } export const init: T.Init = async function (socketName) { - await clearServer(); return new Promise(resolve => connectSocket(socketName, resolve)); };