mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-21 15:36:50 -05:00
* attempt to enable sync when multiple tabs are open * allow multiple tabs to work * release notes * rehome the host if the tab closes * ensure new tabs always receive failure messages by broadcasting them on interval * reject after retries are exhausted * forwarding the logs from the worker to the main browser * [autofix.ci] apply automated fixes * add preflight fetch from main thread to server endpoint to trigger permission prompt if required * remove the log prefix for cleaner logs * adding heardbeat to detect closed tabs so they can be removed from the list * store failure payload and broadcast for new tabs after timeout is cleared * if a tab closes a budget, force other tabs to go to the budget list screen * fix safari by detecting crossoriginisolated as a dependency for shared worker * all ios to fallback to non-shared-worker implemenation * coordinator and all backend work going through a leader tab to enable ios * electing new leader tab when oone tab closes or is refreshed * logic for standalone tabs to rejoin shared workers when on same budget * remove the preflight request, shouldnt be needed now the code runs on the main process * handling brand new tabs going to open budgets that are current standalone with no leader * allowing budgets to be closed without kickother others by transfering leadership to remaining oopened tabs * remove unnedd comments * change approach slightly - no more standalone, now every budget gets leader promotion automatically) * adding tests and fixed minor bug to do with deleting budget with multiple tabs open * fix worker not loading * trouble with ts - moving to js * reintroduce ts for the worker --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
459 lines
13 KiB
JavaScript
459 lines
13 KiB
JavaScript
import { initBackend as initSQLBackend } from 'absurd-sql/dist/indexeddb-main-thread';
|
|
import { registerSW } from 'virtual:pwa-register';
|
|
|
|
import * as Platform from 'loot-core/shared/platform';
|
|
|
|
// oxlint-disable-next-line typescript-paths/absolute-parent-import
|
|
import packageJson from '../package.json';
|
|
|
|
import SharedBrowserServerWorker from './shared-browser-server.ts?sharedworker';
|
|
|
|
const backendWorkerUrl = new URL('./browser-server.js', import.meta.url);
|
|
|
|
// This file installs global variables that the app expects.
|
|
// Normally these are already provided by electron, but in a real
|
|
// browser environment this is where we initialize the backend and
|
|
// everything else.
|
|
|
|
const IS_DEV = process.env.NODE_ENV === 'development';
|
|
const ACTUAL_VERSION = Platform.isPlaywright
|
|
? '99.9.9'
|
|
: process.env.REACT_APP_REVIEW_ID
|
|
? '.preview'
|
|
: packageJson.version;
|
|
|
|
// *** Start the backend ***
|
|
|
|
let worker = null;
|
|
// The regular Worker running the backend, created only on the leader tab
|
|
let localBackendWorker = null;
|
|
|
|
/**
|
|
* WorkerBridge wraps a SharedWorker port and presents a Worker-like interface
|
|
* (onmessage, postMessage, addEventListener, start) to the connection layer.
|
|
*
|
|
* The SharedWorker coordinator assigns each tab a role per budget:
|
|
* - LEADER: this tab runs the backend in a dedicated Worker
|
|
* - FOLLOWER: this tab routes messages through the SharedWorker to the leader
|
|
*
|
|
* Multiple budgets can be open simultaneously — each has its own leader.
|
|
*/
|
|
class WorkerBridge {
|
|
constructor(sharedPort) {
|
|
this._sharedPort = sharedPort;
|
|
this._onmessage = null;
|
|
this._listeners = [];
|
|
this._started = false;
|
|
|
|
// Listen for all messages from the SharedWorker port
|
|
sharedPort.addEventListener('message', e => this._onSharedMessage(e));
|
|
}
|
|
|
|
set onmessage(handler) {
|
|
this._onmessage = handler;
|
|
// Setting onmessage on a real MessagePort implicitly starts it.
|
|
// We need to do this explicitly on the underlying port.
|
|
if (!this._started) {
|
|
this._started = true;
|
|
this._sharedPort.start();
|
|
}
|
|
}
|
|
|
|
get onmessage() {
|
|
return this._onmessage;
|
|
}
|
|
|
|
postMessage(msg) {
|
|
// All messages go through the SharedWorker for coordination.
|
|
// The SharedWorker forwards to the leader's Worker via __to-worker.
|
|
this._sharedPort.postMessage(msg);
|
|
}
|
|
|
|
addEventListener(type, handler) {
|
|
this._listeners.push({ type, handler });
|
|
}
|
|
|
|
start() {
|
|
if (!this._started) {
|
|
this._started = true;
|
|
this._sharedPort.start();
|
|
}
|
|
}
|
|
|
|
_dispatch(event) {
|
|
if (this._onmessage) this._onmessage(event);
|
|
for (const { type, handler } of this._listeners) {
|
|
if (type === 'message') handler(event);
|
|
}
|
|
}
|
|
|
|
_onSharedMessage(event) {
|
|
const msg = event.data;
|
|
|
|
// Elected as leader: create the real backend Worker on this tab
|
|
if (msg && msg.type === '__become-leader') {
|
|
this._createLocalWorker(msg.initMsg, msg.budgetToRestore, msg.pendingMsg);
|
|
return;
|
|
}
|
|
|
|
// Forward requests from SharedWorker to our local Worker
|
|
if (msg && msg.type === '__to-worker') {
|
|
if (localBackendWorker) {
|
|
localBackendWorker.postMessage(msg.msg);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Leadership transfer: this tab is closing the budget but other tabs
|
|
// still need it. Terminate our Worker (don't actually close-budget on
|
|
// the backend) and dispatch a synthetic reply so the UI navigates to
|
|
// show-budgets normally.
|
|
if (msg && msg.type === '__close-and-transfer') {
|
|
console.log('[WorkerBridge] Leadership transferred — terminating Worker');
|
|
if (localBackendWorker) {
|
|
localBackendWorker.terminate();
|
|
localBackendWorker = null;
|
|
}
|
|
// Only dispatch a synthetic reply if there's an actual close-budget
|
|
// request to complete. When requestId is null the eviction was
|
|
// triggered externally (e.g. another tab deleted this budget).
|
|
if (msg.requestId) {
|
|
this._dispatch({
|
|
data: { type: 'reply', id: msg.requestId, data: {} },
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Role change notification
|
|
if (msg && msg.type === '__role-change') {
|
|
console.log(
|
|
`[WorkerBridge] Role: ${msg.role}${msg.budgetId ? ` (budget: ${msg.budgetId})` : ''}`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Surface SharedWorker console output in this tab's DevTools
|
|
if (msg && msg.type === '__shared-worker-console') {
|
|
const method = console[msg.level] || console.log;
|
|
method(...msg.args);
|
|
return;
|
|
}
|
|
|
|
// Respond to heartbeat pings
|
|
if (msg && msg.type === '__heartbeat-ping') {
|
|
this._sharedPort.postMessage({ type: '__heartbeat-pong' });
|
|
return;
|
|
}
|
|
|
|
// Everything else goes to the connection layer
|
|
this._dispatch(event);
|
|
}
|
|
|
|
_createLocalWorker(initMsg, budgetToRestore, pendingMsg) {
|
|
if (localBackendWorker) {
|
|
localBackendWorker.terminate();
|
|
}
|
|
localBackendWorker = new Worker(backendWorkerUrl);
|
|
initSQLBackend(localBackendWorker);
|
|
|
|
const sharedPort = this._sharedPort;
|
|
localBackendWorker.onmessage = workerEvent => {
|
|
const workerMsg = workerEvent.data;
|
|
// absurd-sql internal messages are handled by initSQLBackend
|
|
if (
|
|
workerMsg &&
|
|
workerMsg.type &&
|
|
workerMsg.type.startsWith('__absurd:')
|
|
) {
|
|
return;
|
|
}
|
|
// After the backend connects, automatically reload the budget that was
|
|
// open before the leader left (e.g. page refresh). This lets other tabs
|
|
// continue working without being sent to the budget list.
|
|
if (workerMsg.type === 'connect') {
|
|
if (budgetToRestore) {
|
|
console.log(
|
|
`[WorkerBridge] Backend connected, restoring budget "${budgetToRestore}"`,
|
|
);
|
|
const id = budgetToRestore;
|
|
budgetToRestore = null;
|
|
localBackendWorker.postMessage({
|
|
id: '__restore-budget',
|
|
name: 'load-budget',
|
|
args: { id },
|
|
catchErrors: true,
|
|
});
|
|
// Tell SharedWorker to track the restore request so
|
|
// currentBudgetId gets updated when the reply arrives.
|
|
sharedPort.postMessage({
|
|
type: '__track-restore',
|
|
requestId: '__restore-budget',
|
|
budgetId: id,
|
|
});
|
|
} else if (pendingMsg) {
|
|
const toSend = pendingMsg;
|
|
pendingMsg = null;
|
|
localBackendWorker.postMessage(toSend);
|
|
}
|
|
}
|
|
sharedPort.postMessage({ type: '__from-worker', msg: workerMsg });
|
|
};
|
|
|
|
localBackendWorker.postMessage(initMsg);
|
|
}
|
|
}
|
|
|
|
function createBackendWorker() {
|
|
// Use SharedWorker as a coordinator for multi-tab, multi-budget support.
|
|
// Each budget gets its own leader tab running a dedicated Worker. All other
|
|
// tabs on the same budget are followers — their messages are routed through
|
|
// the SharedWorker to the leader's Worker.
|
|
// The SharedWorker never touches SharedArrayBuffer, so this works on all
|
|
// platforms including iOS/Safari.
|
|
if (typeof SharedWorker !== 'undefined' && !Platform.isPlaywright) {
|
|
try {
|
|
const sharedWorker = new SharedBrowserServerWorker({
|
|
name: 'actual-backend',
|
|
});
|
|
|
|
const sharedPort = sharedWorker.port;
|
|
worker = new WorkerBridge(sharedPort);
|
|
console.log('[WorkerBridge] Connected to SharedWorker coordinator');
|
|
|
|
// Don't call start() here. The port must remain un-started so that
|
|
// messages (especially 'connect') are queued until connectWorker()
|
|
// sets onmessage, which implicitly starts the port via the bridge.
|
|
|
|
if (window.SharedArrayBuffer) {
|
|
localStorage.removeItem('SharedArrayBufferOverride');
|
|
}
|
|
|
|
sharedPort.postMessage({
|
|
type: 'init',
|
|
version: ACTUAL_VERSION,
|
|
isDev: IS_DEV,
|
|
publicUrl: process.env.PUBLIC_URL,
|
|
hash: process.env.REACT_APP_BACKEND_WORKER_HASH,
|
|
isSharedArrayBufferOverrideEnabled: localStorage.getItem(
|
|
'SharedArrayBufferOverride',
|
|
),
|
|
});
|
|
|
|
window.addEventListener('beforeunload', () => {
|
|
sharedPort.postMessage({ type: 'tab-closing' });
|
|
});
|
|
|
|
return;
|
|
} catch (e) {
|
|
console.log('SharedWorker failed, falling back to Worker:', e);
|
|
}
|
|
}
|
|
|
|
// Fallback: regular Worker (Playwright, no SharedWorker support, or failure)
|
|
console.log('[WorkerBridge] No SharedWorker available, using direct Worker');
|
|
worker = new Worker(backendWorkerUrl);
|
|
initSQLBackend(worker);
|
|
|
|
if (window.SharedArrayBuffer) {
|
|
localStorage.removeItem('SharedArrayBufferOverride');
|
|
}
|
|
|
|
worker.postMessage({
|
|
type: 'init',
|
|
version: ACTUAL_VERSION,
|
|
isDev: IS_DEV,
|
|
publicUrl: process.env.PUBLIC_URL,
|
|
hash: process.env.REACT_APP_BACKEND_WORKER_HASH,
|
|
hasSharedArrayBuffer: !!window.SharedArrayBuffer,
|
|
isSharedArrayBufferOverrideEnabled: localStorage.getItem(
|
|
'SharedArrayBufferOverride',
|
|
),
|
|
});
|
|
}
|
|
|
|
createBackendWorker();
|
|
|
|
let isUpdateReadyForDownload = false;
|
|
let markUpdateReadyForDownload;
|
|
const isUpdateReadyForDownloadPromise = new Promise(resolve => {
|
|
markUpdateReadyForDownload = () => {
|
|
isUpdateReadyForDownload = true;
|
|
resolve(true);
|
|
};
|
|
});
|
|
const updateSW = registerSW({
|
|
immediate: true,
|
|
onNeedRefresh: markUpdateReadyForDownload,
|
|
});
|
|
|
|
global.Actual = {
|
|
IS_DEV,
|
|
ACTUAL_VERSION,
|
|
|
|
logToTerminal: (...args) => {
|
|
console.log(...args);
|
|
},
|
|
|
|
relaunch: () => {
|
|
window.location.reload();
|
|
},
|
|
|
|
reload: () => {
|
|
if (window.navigator.serviceWorker == null) return;
|
|
|
|
// Unregister the service worker handling routing and then reload. This should force the reload
|
|
// to query the actual server rather than delegating to the worker
|
|
return window.navigator.serviceWorker
|
|
.getRegistration('/')
|
|
.then(registration => {
|
|
if (registration == null) return;
|
|
return registration.unregister();
|
|
})
|
|
.then(() => {
|
|
window.location.reload();
|
|
});
|
|
},
|
|
|
|
startSyncServer: () => {
|
|
// Only for electron app
|
|
},
|
|
|
|
stopSyncServer: () => {
|
|
// Only for electron app
|
|
},
|
|
|
|
isSyncServerRunning: () => false,
|
|
|
|
startOAuthServer: () => {
|
|
return '';
|
|
},
|
|
|
|
restartElectronServer: () => {
|
|
// Only for electron app
|
|
},
|
|
|
|
openFileDialog: async ({ filters = [] }) => {
|
|
const FILE_ACCEPT_OVERRIDES = {
|
|
// Safari on iOS requires explicit MIME/UTType values for some extensions to allow selection.
|
|
qfx: [
|
|
'application/vnd.intu.qfx',
|
|
'application/x-qfx',
|
|
'application/qfx',
|
|
'application/ofx',
|
|
'application/x-ofx',
|
|
'application/octet-stream',
|
|
'com.intuit.qfx',
|
|
],
|
|
};
|
|
|
|
return new Promise(resolve => {
|
|
let createdElement = false;
|
|
// Attempt to reuse an already-created file input.
|
|
let input = document.body.querySelector(
|
|
'input[id="open-file-dialog-input"]',
|
|
);
|
|
if (!input) {
|
|
createdElement = true;
|
|
input = document.createElement('input');
|
|
}
|
|
|
|
input.type = 'file';
|
|
input.id = 'open-file-dialog-input';
|
|
input.value = null;
|
|
|
|
const filter = filters.find(filter => filter.extensions);
|
|
if (filter) {
|
|
input.accept = filter.extensions
|
|
.flatMap(ext => {
|
|
const normalizedExt = ext.startsWith('.')
|
|
? ext.toLowerCase()
|
|
: `.${ext.toLowerCase()}`;
|
|
const overrides = FILE_ACCEPT_OVERRIDES[ext.toLowerCase()] ?? [];
|
|
return [normalizedExt, ...overrides];
|
|
})
|
|
.join(',');
|
|
}
|
|
|
|
input.style.position = 'absolute';
|
|
input.style.top = '0px';
|
|
input.style.left = '0px';
|
|
input.style.display = 'none';
|
|
|
|
input.onchange = e => {
|
|
const file = e.target.files[0];
|
|
const filename = file.name.replace(/.*(\.[^.]*)/, 'file$1');
|
|
|
|
if (file) {
|
|
const reader = new FileReader();
|
|
reader.readAsArrayBuffer(file);
|
|
reader.onload = async function (ev) {
|
|
const filepath = `/uploads/${filename}`;
|
|
|
|
void window.__actionsForMenu
|
|
.uploadFile(filename, ev.target.result)
|
|
.then(() => resolve([filepath]));
|
|
};
|
|
reader.onerror = function () {
|
|
alert('Error reading file');
|
|
};
|
|
}
|
|
};
|
|
|
|
// In Safari the file input has to be in the DOM for change events to
|
|
// reliably fire.
|
|
if (createdElement) {
|
|
document.body.appendChild(input);
|
|
}
|
|
|
|
input.click();
|
|
});
|
|
},
|
|
|
|
saveFile: (contents, defaultFilename) => {
|
|
const temp = document.createElement('a');
|
|
temp.style = 'display: none';
|
|
temp.download = defaultFilename;
|
|
temp.rel = 'noopener';
|
|
|
|
const blob = new Blob([contents]);
|
|
temp.href = URL.createObjectURL(blob);
|
|
temp.dispatchEvent(new MouseEvent('click'));
|
|
},
|
|
|
|
openURLInBrowser: url => {
|
|
window.open(url, '_blank');
|
|
},
|
|
openInFileManager: () => {
|
|
// File manager not available in browser
|
|
},
|
|
onEventFromMain: () => {
|
|
// Only for electron app
|
|
},
|
|
isUpdateReadyForDownload: () => isUpdateReadyForDownload,
|
|
waitForUpdateReadyForDownload: () => isUpdateReadyForDownloadPromise,
|
|
applyAppUpdate: async () => {
|
|
updateSW();
|
|
|
|
// Wait for the app to reload
|
|
await new Promise(() => {
|
|
// Do nothing
|
|
});
|
|
},
|
|
|
|
ipcConnect: () => {
|
|
// Only for electron app
|
|
},
|
|
getServerSocket: async () => {
|
|
return worker;
|
|
},
|
|
|
|
setTheme: theme => {
|
|
window.__actionsForMenu.saveGlobalPrefs({ prefs: { theme } });
|
|
},
|
|
|
|
moveBudgetDirectory: () => {
|
|
// Only for electron app
|
|
},
|
|
};
|