Compare commits

..

3 Commits

Author SHA1 Message Date
github-actions[bot]
f34acd2927 [AI] sync-server: support non-RS256 OpenID ID tokens (#6524)
Auto-detect the ID-token signing algorithm from the IdP's discovery
metadata, with an optional ACTUAL_OPENID_ID_TOKEN_SIGNED_RESPONSE_ALG
override. RS256 is still preferred when advertised, so existing installs
are unaffected; IdPs that sign with ECDSA (ES256/ES384/ES512) or other
RSA variants now work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:13:45 +01:00
Matt Fiddaman
695fd0e7e0 fix bank sync account linking modal being disabled when relinking existing accounts (#7487)
* fix link account modal button disabled

* note
2026-04-18 22:25:28 +00:00
Matiss Janis Aboltins
9682f6d8c9 ci: disable fail-fast for Electron build workflows (#7547)
* [AI] Disable fail-fast for Electron build matrices

Prevents cancellation of in-progress platform builds when one fails, so
Windows/macOS/Linux results are all visible on a single run.

* Add release notes for PR #7547

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-18 21:32:21 +00:00
37 changed files with 407 additions and 1254 deletions

View File

@@ -22,6 +22,7 @@ jobs:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
os:
- ubuntu-22.04

View File

@@ -26,6 +26,7 @@ concurrency:
jobs:
build:
strategy:
fail-fast: false
matrix:
os:
- ubuntu-22.04

View File

@@ -20,6 +20,7 @@ concurrency:
jobs:
build:
strategy:
fail-fast: false
matrix:
os:
- ubuntu-22.04

1
.gitignore vendored
View File

@@ -92,4 +92,3 @@ storybook-static
.actualrc.yaml
.actualrc.yml
actual.config.js
.playwright-cli/

View File

@@ -1,102 +0,0 @@
/// <reference lib="webworker" />
// Worker entry for @actual-app/api's browser build.
//
// This owns the real loot-core instance (sql.js + absurd-sql + IndexedDB)
// and speaks loot-core's existing backend protocol over postMessage:
// main → worker: {id, name, args, undoTag?, catchErrors?}
// worker → main: {type:'reply', id, result, mutated, undoTag}
// {type:'error', id, error}
// {type:'connect'} (handshake heartbeat)
//
// Bootstrapping:
// - We register an `api-browser/init` handler that runs loot-core's public
// init(config), so the main-thread facade can kick off the DB + auth via
// a normal RPC call. The reply carries no return value (loot-core's
// `init(config)` resolves to `lib`, which isn't structured-cloneable).
// - connection.init(self, handlers) starts the message loop and the
// `{type:'connect'}` handshake loot-core's client connection expects.
import * as connection from '@actual-app/core/platform/server/connection';
import { handlers, init } from '@actual-app/core/server/main';
import type { InitConfig } from '@actual-app/core/server/main';
// Dev-server friendliness: consumer bundlers (Vite first, others too) run
// import-analysis on every `.js` URL they serve. loot-core's JS migrations
// use `#`-subpath imports that only resolve inside loot-core — analysis
// fails when those files live under node_modules/@actual-app/api/dist/.
// Our build writes those files with an extra `.data` suffix, so bundlers
// leave them alone. Translate the URLs here so loot-core's fetch layer
// still sees `.js` names both in the manifest and on-disk.
//
// The wrap has to install before connection.init() runs, and populateDefault-
// Filesystem is kicked off lazily from the first `load-budget` / init call.
{
const origFetch = globalThis.fetch;
const MIGRATION_JS = /\/data\/migrations\/[^/?]+\.js(\?.*)?$/;
globalThis.fetch = (async (
input: RequestInfo | URL,
initArg?: RequestInit,
): Promise<Response> => {
const url =
typeof input === 'string' ? input : (input as URL | Request).toString();
if (MIGRATION_JS.test(url)) {
// Re-target .js → .js.data before hitting the network.
const patched = url.replace(/(\.js)(\?|$)/, '.js.data$2');
return origFetch(patched, initArg);
}
if (
url.endsWith('/data-file-index.txt') ||
url.endsWith('data-file-index.txt')
) {
const res = await origFetch(input as RequestInfo | URL, initArg);
if (!res.ok) return res;
const text = await res.text();
const rewritten = text.replace(/\.js\.data(\r?\n|$)/g, '.js$1');
return new Response(rewritten, {
status: res.status,
statusText: res.statusText,
headers: res.headers,
});
}
return origFetch(input as RequestInfo | URL, initArg);
}) as typeof fetch;
}
// `api-browser/init` is a worker-local handler; it isn't part of the shared
// Handlers type. Assign via the index-signature cast rather than extending
// the type globally.
(handlers as Record<string, (args?: unknown) => Promise<unknown>>)[
'api-browser/init'
] = async function (args?: unknown) {
const payload = (args ?? {}) as InitConfig & { __assetsBaseUrl?: string };
// Main thread hands us a URL pointing at the api's own dist/ dir. Setting
// PUBLIC_URL here is what makes loot-core's populateDefaultFilesystem
// fetch `data-file-index.txt` / `data/<name>` / `sql-wasm.wasm` from our
// package instead of the consumer's page origin — no manual copy step.
const { __assetsBaseUrl, ...config } = payload;
if (__assetsBaseUrl) {
process.env.PUBLIC_URL = __assetsBaseUrl;
}
await init(config);
// Nothing to return — the resolved `lib` has functions and isn't
// structured-cloneable anyway.
};
self.addEventListener('error', e => {
// eslint-disable-next-line no-console
console.error(
'[api worker] uncaught',
(e as ErrorEvent).error ?? (e as ErrorEvent).message,
);
});
self.addEventListener('unhandledrejection', e => {
// eslint-disable-next-line no-console
console.error(
'[api worker] unhandled rejection',
(e as PromiseRejectionEvent).reason,
);
});
connection.init(self as unknown as Window, handlers);

View File

@@ -1,39 +0,0 @@
// Browser main-thread stub for `@actual-app/core/server/main`.
//
// The real loot-core runs inside the worker (see browser-worker.ts). The
// main-thread bundle reuses packages/api/methods.ts verbatim, but that file
// reads `lib.send(...)` from loot-core. Resolving that import to this stub
// routes every call over postMessage instead of touching loot-core on the
// main thread.
export type BrowserSendFn = (name: string, args?: unknown) => Promise<unknown>;
let workerSend: BrowserSendFn = () => {
return Promise.reject(
new Error('@actual-app/api: call init() before any other method'),
);
};
// Shape-cast rather than `typeof import(...)` so this stub stays
// module-graph-independent from the real loot-core.
export const lib = {
send(name: string, args?: unknown) {
return workerSend(name, args);
},
} as unknown as {
send: <T = unknown>(name: string, args?: unknown) => Promise<T>;
};
export function _setBrowserSend(fn: BrowserSendFn) {
workerSend = fn;
}
// Inline InitConfig (matches loot-core's shape) so this stub does not force
// TS to pull in the real @actual-app/core/server/main module graph at all.
export type InitConfig = {
dataDir?: string;
serverURL?: string;
password?: string;
sessionToken?: string;
verbose?: boolean;
};

View File

@@ -1,132 +0,0 @@
// Main-thread RPC bridge to the api worker.
//
// Reuses `createBackendWorker` from loot-core so absurd-sql's main-thread
// plumbing (IDB helper worker, __absurd:* filtering) stays in one place.
// Speaks loot-core's existing backend protocol:
// out: {id, name, args, catchErrors?}
// in : {type:'reply', id, result, error?}
// {type:'error', id, error}
// {type:'connect'} (handshake heartbeat)
// {type:'push', name, args}
//
// We handle the handshake by replying {name:'client-connected-to-backend'}
// on the first 'connect'. Messages sent before handshake completes are
// queued.
import { createBackendWorker } from '@actual-app/core/platform/client/backend-worker';
import type { BackendWorker } from '@actual-app/core/platform/client/backend-worker';
type Pending = {
resolve: (v: unknown) => void;
reject: (e: unknown) => void;
};
type Reply =
| {
type: 'reply';
id: string;
result?: unknown;
error?: { type?: string; message?: string; [k: string]: unknown };
}
| {
type: 'error';
id: string;
error: { type?: string; message?: string; [k: string]: unknown };
};
let backend: BackendWorker | null = null;
let connected = false;
let queue: Array<{ id: string; name: string; args?: unknown }> = [];
const pending = new Map<string, Pending>();
function nextId(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
}
function toError(info: { type?: string; message?: string } | undefined) {
const msg = info?.message || info?.type || 'api worker error';
const err = new Error(msg);
if (info?.type) err.name = info.type;
return err;
}
export function setWorker(worker: Worker): BackendWorker {
if (backend) {
backend.terminate();
}
connected = false;
queue = [];
pending.clear();
backend = createBackendWorker(worker);
backend.onMessage((data: unknown) => {
if (!data || typeof data !== 'object') return;
const msg = data as { type?: string; name?: string };
if (msg.type === 'connect') {
if (!connected) {
connected = true;
backend!.postMessage({ name: 'client-connected-to-backend' });
// Drain anything queued while waiting for the handshake.
const drained = queue;
queue = [];
for (const m of drained) backend!.postMessage(m);
}
return;
}
if (msg.type === 'reply' || msg.type === 'error') {
const reply = msg as Reply;
const p = pending.get(reply.id);
if (!p) return;
pending.delete(reply.id);
if (reply.type === 'error') {
p.reject(toError(reply.error));
} else if ('error' in reply && reply.error) {
// api/* handlers funnel errors through the reply envelope.
p.reject(toError(reply.error));
} else {
p.resolve(reply.result);
}
return;
}
// push/capture-exception/etc. — ignore for now; the api consumer
// doesn't subscribe to loot-core's server events.
});
return backend;
}
export function rpc(name: string, args?: unknown): Promise<unknown> {
if (!backend) {
return Promise.reject(
new Error('@actual-app/api: init() must be called before any api method'),
);
}
return new Promise((resolve, reject) => {
const id = nextId();
pending.set(id, { resolve, reject });
const msg = { id, name, args };
if (connected) {
backend!.postMessage(msg);
} else {
queue.push(msg);
}
});
}
export function terminate() {
if (backend) {
backend.terminate();
backend = null;
}
connected = false;
queue = [];
pending.clear();
}

View File

@@ -1,66 +0,0 @@
// Main-thread browser entry for @actual-app/api.
//
// Public surface matches the Node entry. The worker is spawned internally
// so consumers write:
//
// import * as api from '@actual-app/api';
// await api.init({ dataDir: '/documents', serverURL, password });
// await api.getAccounts();
//
// worker.js must be a sibling of browser.js at runtime. Our build ships
// them together in dist/; the consumer's bundler resolves the worker URL
// via `new URL(..., import.meta.url)`.
import { _setBrowserSend } from './browser/lib-stub';
import type { InitConfig } from './browser/lib-stub';
import { rpc, setWorker, terminate } from './browser/rpc';
export * from './methods';
export * as utils from './utils';
// Wire methods.ts's `lib.send` through the worker.
_setBrowserSend((name, args) => rpc(name, args));
function createWorker(): Worker {
// Vite's `vite:worker-import-meta-url` plugin rewrites this pattern at
// the CONSUMER's build time (emit worker.js as an asset, substitute the
// hashed URL). Feeding it a non-literal first argument keeps the api's
// OWN lib build from trying to pre-bundle it, which would fail because
// ./worker.js is not a source-tree sibling of this file.
const rel = './worker.js';
return new Worker(new URL(rel, import.meta.url), { type: 'module' });
}
export async function init(config: InitConfig = {}) {
setWorker(createWorker());
// Point loot-core's browser fs at our dist/ directory. We want the
// directory portion of this bundle's own URL so loot-core's fetches land
// on files we ship (data-file-index.txt, migrations/, default-db.sqlite,
// sql-wasm.wasm). Vite's asset plugin tries to pre-bundle
// `new URL('.', import.meta.url)` at consumer build time and picks up
// the `development` export condition (inlining index.ts as a data URL!).
// Derive the base URL via string manipulation instead so static analyzers
// leave it alone.
const assetsBaseUrl = import.meta.url.replace(/[^/]+$/, '');
await rpc('api-browser/init', { ...config, __assetsBaseUrl: assetsBaseUrl });
// Return a {send} handle compatible with the Node entry so existing
// consumer code that does `const internal = await api.init(...); internal.send(...)`
// keeps working on the browser build too.
return {
send: (name: string, args?: unknown) => rpc(name, args),
};
}
export async function shutdown() {
try {
await rpc('sync');
} catch {
// most likely no budget loaded
}
try {
await rpc('close-budget');
} catch {
// ignore
}
terminate();
}

View File

@@ -2,18 +2,38 @@ import * as fs from 'fs/promises';
import * as path from 'path';
import type { RuleEntity } from '@actual-app/core/types/models';
import { vi } from 'vitest';
import * as api from '../index';
import * as api from './index';
// In tests we run from source; loot-core's API fs uses __dirname (for the built dist/).
// Mock the fs so path constants point at loot-core package root where migrations live.
vi.mock(
'../loot-core/src/platform/server/fs/index.api',
async importOriginal => {
const actual = (await importOriginal()) as Record<string, unknown>;
const pathMod = await import('path');
const lootCoreRoot = pathMod.join(__dirname, '..', 'loot-core');
return {
...actual,
migrationsPath: pathMod.join(lootCoreRoot, 'migrations'),
bundledDatabasePath: pathMod.join(lootCoreRoot, 'default-db.sqlite'),
demoBudgetPath: pathMod.join(lootCoreRoot, 'demo-budget'),
};
},
);
const budgetName = 'test-budget';
global.IS_TESTING = true;
beforeEach(async () => {
const budgetPath = path.join(__dirname, '/../mocks/budgets/', budgetName);
const budgetPath = path.join(__dirname, '/mocks/budgets/', budgetName);
await fs.rm(budgetPath, { force: true, recursive: true });
await createTestBudget('default-budget-template', budgetName);
await api.init({
dataDir: path.join(__dirname, '/../mocks/budgets/'),
dataDir: path.join(__dirname, '/mocks/budgets/'),
});
});
@@ -25,10 +45,10 @@ afterEach(async () => {
async function createTestBudget(templateName: string, name: string) {
const templatePath = path.join(
__dirname,
'/../../loot-core/src/mocks/files',
'/../loot-core/src/mocks/files',
templateName,
);
const budgetPath = path.join(__dirname, '/../mocks/budgets/', name);
const budgetPath = path.join(__dirname, '/mocks/budgets/', name);
await fs.mkdir(budgetPath);
await fs.copyFile(

View File

@@ -13,7 +13,6 @@
".": {
"types": "./@types/index.d.ts",
"development": "./index.ts",
"browser": "./dist/browser.js",
"default": "./dist/index.js"
}
},
@@ -21,37 +20,26 @@
"exports": {
".": {
"types": "./@types/index.d.ts",
"browser": "./dist/browser.js",
"default": "./dist/index.js"
}
}
},
"scripts": {
"build": "npm-run-all -s build:node build:browser-worker build:browser",
"build:node": "vite build --config vite.config.mts && tsgo --emitDeclarationOnly",
"build:browser": "vite build --config vite.browser.config.mts",
"build:browser-worker": "vite build --config vite.browser-worker.config.mts",
"test": "npm-run-all -cp 'test:*'",
"test:node": "vitest --run --config vite.config.mts",
"test:browser": "vitest --run --config vitest.browser.config.mts",
"build": "vite build && tsgo --emitDeclarationOnly",
"test": "vitest --run",
"typecheck": "tsgo -b && tsc-strict"
},
"dependencies": {
"@actual-app/core": "workspace:*",
"@actual-app/crdt": "workspace:*",
"absurd-sql": "0.0.54",
"better-sqlite3": "^12.8.0",
"compare-versions": "^6.1.1"
},
"devDependencies": {
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
"fake-indexeddb": "^6.2.5",
"jsdom": "^27.4.0",
"npm-run-all": "^4.1.5",
"rollup-plugin-visualizer": "^7.0.1",
"typescript-strict-plugin": "^2.4.4",
"vite": "^8.0.5",
"vite-plugin-node-polyfills": "^0.26.0",
"vite-plugin-peggy-loader": "^2.0.1",
"vitest": "^4.1.2"
},

View File

@@ -1,183 +0,0 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import * as api from '../index.browser';
// Swap the real Worker constructor for a mock that the tests control. Vitest
// picks this up via vite.config resolve.alias; here we just stand in globally
// because jsdom does not ship Worker at all.
class MockWorker {
public posted: Array<unknown> = [];
public responder: (
req: { id: string; name: string; args?: unknown },
reply: (res: unknown) => void,
) => void = () => undefined;
private listeners: Array<(e: MessageEvent) => void> = [];
onmessage: ((e: MessageEvent) => void) | null = null;
onerror: ((e: ErrorEvent) => void) | null = null;
private connected = false;
addEventListener(type: string, handler: (e: MessageEvent) => void) {
if (type === 'message') this.listeners.push(handler);
}
removeEventListener() {
// no-op for tests
}
postMessage(msg: unknown) {
this.posted.push(msg);
if (
msg &&
typeof msg === 'object' &&
(msg as { name?: string }).name === 'client-connected-to-backend'
) {
// Handshake complete; we won't keep sending 'connect' heartbeats.
return;
}
const req = msg as { id: string; name: string; args?: unknown };
queueMicrotask(() => {
this.responder(req, (data: unknown) => {
const ev = { data } as MessageEvent;
this.onmessage?.(ev);
for (const l of this.listeners) l(ev);
});
});
}
/** Simulate loot-core's connect handshake from the worker side. */
fireConnect() {
if (this.connected) return;
this.connected = true;
const ev = { data: { type: 'connect' } } as MessageEvent;
this.onmessage?.(ev);
for (const l of this.listeners) l(ev);
}
terminate() {
this.listeners = [];
}
}
// Every Worker the api spawns inside init() comes through here.
let lastMockWorker: MockWorker | null = null;
const mockWorkerResponder = vi.fn<
(
req: { id: string; name: string; args?: unknown },
reply: (res: unknown) => void,
) => void
>(() => undefined);
// Global Worker stub — the api's internal `new Worker(...)` will call this.
// @ts-expect-error jsdom has no Worker; we override the global for the test.
globalThis.Worker = class {
constructor(_url: URL | string, _opts?: WorkerOptions) {
const w = new MockWorker();
w.responder = (req, reply) => mockWorkerResponder(req, reply);
lastMockWorker = w;
// Fire the connect handshake on the next tick so init() resolves.
queueMicrotask(() => w.fireConnect());
return w as unknown as Worker;
}
};
// absurd-sql's main-thread bridge expects real Worker event semantics. The
// mock above exposes addEventListener; initSQLBackend just attaches a
// message listener, so it's safe with jsdom.
afterEach(async () => {
// Keep whatever responder the test installed so shutdown's sync/close-budget
// calls resolve rather than hang.
await api.shutdown().catch(() => undefined);
mockWorkerResponder.mockReset();
lastMockWorker = null;
});
describe('@actual-app/api browser facade', () => {
test('spawns a worker on init and forwards config via api-browser/init', async () => {
mockWorkerResponder.mockImplementation((req, reply) => {
reply({ type: 'reply', id: req.id, result: undefined });
});
await api.init({
dataDir: '/documents',
serverURL: 'https://example.test',
password: 'pw',
});
expect(lastMockWorker).toBeTruthy();
// First post after the handshake ack is the api-browser/init request.
const initCall = lastMockWorker!.posted.find(
m =>
m &&
typeof m === 'object' &&
(m as { name?: string }).name === 'api-browser/init',
) as { name: string; args: unknown } | undefined;
expect(initCall).toBeTruthy();
expect(initCall!.args).toMatchObject({
dataDir: '/documents',
serverURL: 'https://example.test',
password: 'pw',
});
// The api also hands over its own asset base URL so loot-core's fs
// can fetch migrations / default-db / WASM from the api's dist/
// instead of the consumer's page origin.
expect(
(initCall!.args as { __assetsBaseUrl?: string }).__assetsBaseUrl,
).toBeTypeOf('string');
});
test('rpc methods forward as {id, name, args} and read {type:reply, result}', async () => {
mockWorkerResponder.mockImplementation((req, reply) => {
if (req.name === 'api-browser/init') {
reply({ type: 'reply', id: req.id, result: undefined });
return;
}
if (req.name === 'api/accounts-get') {
reply({
type: 'reply',
id: req.id,
result: [{ id: 'a1', name: 'Checking' }],
});
return;
}
reply({
type: 'error',
id: req.id,
error: { type: 'APIError', message: 'unexpected' },
});
});
await api.init({ dataDir: '/documents' });
const accounts = await api.getAccounts();
expect(accounts).toEqual([{ id: 'a1', name: 'Checking' }]);
const sendCalls = lastMockWorker!.posted.filter(
m =>
m &&
typeof m === 'object' &&
(m as { name?: string }).name === 'api/accounts-get',
);
expect(sendCalls).toHaveLength(1);
expect((sendCalls[0] as { args?: unknown }).args).toBeUndefined();
});
test('worker errors reject at the call site', async () => {
mockWorkerResponder.mockImplementation((req, reply) => {
if (req.name === 'api-browser/init') {
reply({ type: 'reply', id: req.id, result: undefined });
return;
}
reply({
type: 'reply',
id: req.id,
error: { type: 'APIError', message: 'budget not loaded' },
});
});
await api.init({ dataDir: '/documents' });
await expect(api.getAccounts()).rejects.toThrow(/budget not loaded/);
});
});

View File

@@ -1,43 +0,0 @@
import { afterEach, describe, expect, test } from 'vitest';
import * as api from '../index';
declare const __API_DATA_DIR__: string;
afterEach(async () => {
await api.shutdown();
});
describe('api CRUD roundtrip (Node)', () => {
test('creates a budget, writes, reads it back', async () => {
const internal = await api.init({ dataDir: __API_DATA_DIR__ });
await internal.send('create-budget', {
budgetName: 'Integration Test',
testMode: true,
testBudgetId: 'integration-test',
});
await api.loadBudget('integration-test');
const accountId = await api.createAccount(
{ name: 'Checking', offbudget: false },
0,
);
await api.addTransactions(accountId, [
{ date: '2026-04-01', amount: 1000, payee_name: 'Coffee' },
{ date: '2026-04-02', amount: -500, payee_name: 'Book' },
]);
const accounts = await api.getAccounts();
expect(accounts.map(a => a.name)).toContain('Checking');
const txns = await api.getTransactions(
accountId,
'2026-04-01',
'2026-04-30',
);
expect(txns).toHaveLength(2);
expect(txns.map(t => t.amount).sort((a, b) => a - b)).toEqual([-500, 1000]);
});
});

View File

@@ -1,31 +0,0 @@
import * as fsPromises from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { vi } from 'vitest';
// In tests we run from source; loot-core's API fs uses __dirname (for the built dist/).
// Mock the fs so path constants point at loot-core package root where migrations live.
vi.mock(
'../../loot-core/src/platform/server/fs/index.api',
async importOriginal => {
const actual = (await importOriginal()) as Record<string, unknown>;
const lootCoreRoot = path.join(__dirname, '..', '..', 'loot-core');
return {
...actual,
migrationsPath: path.join(lootCoreRoot, 'migrations'),
bundledDatabasePath: path.join(lootCoreRoot, 'default-db.sqlite'),
demoBudgetPath: path.join(lootCoreRoot, 'demo-budget'),
};
},
);
global.IS_TESTING = true;
// Shared integration test lives in a filesystem-backed tmp dir.
const dataDir = path.join(
os.tmpdir(),
`api-it-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
await fsPromises.mkdir(dataDir, { recursive: true });
globalThis.__API_DATA_DIR__ = dataDir;

View File

@@ -8,12 +8,7 @@
"module": "es2022",
"moduleResolution": "bundler",
"customConditions": ["api"],
// composite + declaration: true require `noEmit: false`, so use
// emitDeclarationOnly to keep typecheck + project refs working without
// clobbering the Vite build artifacts in dist/. build:node also passes
// --emitDeclarationOnly on the CLI (redundant but explicit).
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": true,
"outDir": "dist",
@@ -28,8 +23,7 @@
"**/node_modules/*",
"dist",
"@types",
"**/*.test.ts",
"test/setup.*.ts",
"*.test.ts",
"*.config.ts",
"*.config.mts"
]

View File

@@ -1,62 +0,0 @@
import path from 'path';
import { defineConfig } from 'vite';
import { nodePolyfills } from 'vite-plugin-node-polyfills';
import peggyLoader from 'vite-plugin-peggy-loader';
const distDir = path.resolve(__dirname, 'dist');
// Worker bundle: contains the full loot-core + sql.js + absurd-sql stack.
// Runs inside a Web Worker where absurd-sql's Atomics.wait has the right
// thread context. Consumer spawns the worker with this file as the entry.
export default defineConfig({
define: {
// NODE_ENV is read at build time by dead-code elimination paths and
// must stay a literal. The others (PUBLIC_URL, DATA_DIR, SERVER_URL,
// DOCUMENT_DIR) are set at runtime via the `api-browser/init` handler
// which receives them from the main thread — so they stay as
// `process.env.<name>` references and the nodePolyfills-provided
// process shim serves as the backing store.
'process.env.NODE_ENV': JSON.stringify('production'),
},
build: {
target: 'esnext',
outDir: distDir,
emptyOutDir: false,
sourcemap: true,
lib: {
entry: path.resolve(__dirname, 'browser-worker.ts'),
formats: ['es'],
fileName: () => 'worker.js',
},
rollupOptions: {
output: {
codeSplitting: false,
},
},
},
plugins: [
peggyLoader(),
nodePolyfills({
include: [
'process',
'buffer',
'stream',
'path',
'crypto',
'timers',
'util',
'zlib',
'fs',
'assert',
],
globals: {
process: true,
Buffer: true,
global: true,
},
}),
],
// Intentionally no resolve.conditions: ['api'] — loot-core falls back to
// its default (browser) platform files.
});

View File

@@ -1,39 +0,0 @@
import path from 'path';
import { defineConfig } from 'vite';
const distDir = path.resolve(__dirname, 'dist');
// Main-thread facade only. Tiny bundle: no loot-core, no sql.js, no absurd-sql.
// The worker is built separately by vite.browser-worker.config.mts. The
// consumer constructs the Worker (handling URL resolution through their own
// bundler) and hands it to init().
export default defineConfig({
build: {
target: 'esnext',
outDir: distDir,
emptyOutDir: false,
sourcemap: true,
lib: {
entry: path.resolve(__dirname, 'index.browser.ts'),
formats: ['es'],
fileName: () => 'browser.js',
},
rollupOptions: {
output: {
codeSplitting: false,
},
},
},
resolve: {
alias: {
// methods.ts reads `lib.send` from loot-core's server/main. Route it
// through the main-thread stub so loot-core is never pulled into
// the main bundle.
'@actual-app/core/server/main': path.resolve(
__dirname,
'browser/lib-stub.ts',
),
},
},
});

View File

@@ -49,61 +49,10 @@ function copyMigrationsAndDefaultDb() {
throw new Error(`default-db.sqlite not found at ${defaultDbPath}`);
}
fs.copyFileSync(defaultDbPath, path.join(distDir, 'default-db.sqlite'));
// Browser consumers need sql.js' WASM to be served at the same origin
// as the bundle. Ship it alongside dist/ so downstream apps just point
// a static handler at dist and don't have to reach into node_modules.
const sqlJsWasm = require.resolve('@jlongster/sql.js/dist/sql-wasm.wasm');
fs.copyFileSync(sqlJsWasm, path.join(distDir, 'sql-wasm.wasm'));
// loot-core's browser fs bootstraps by fetching:
// `${PUBLIC_URL}data-file-index.txt` - flat manifest
// `${PUBLIC_URL}data/<name>` - each file listed in the manifest
// We point PUBLIC_URL at the api's dist dir at runtime (see
// index.browser.ts), so these two shapes need to exist here.
//
// JS migrations get a `.data` suffix on the *wire* path. Consumer
// bundlers (Vite's dev server first, others to varying degrees)
// auto-transform `.js` URLs through their import-analysis pipelines,
// which fails on loot-core's `#`-subpath imports. The api's worker
// (browser-worker.ts) wraps `fetch` to translate back to `.js` so
// loot-core's migration runner finds the file under its original
// name in the virtual FS. `.sql` migrations stay as-is.
const dataDir = path.join(distDir, 'data');
const dataMigrationsDir = path.join(dataDir, 'migrations');
fs.mkdirSync(dataMigrationsDir, { recursive: true });
linkOrCopy(
path.join(distDir, 'default-db.sqlite'),
path.join(dataDir, 'default-db.sqlite'),
);
const wireMigrationNames: string[] = [];
for (const name of fs.readdirSync(migrationsDest)) {
const wireName = name.endsWith('.js') ? `${name}.data` : name;
linkOrCopy(
path.join(migrationsDest, name),
path.join(dataMigrationsDir, wireName),
);
wireMigrationNames.push(`migrations/${wireName}`);
}
wireMigrationNames.sort();
// data-file-index.txt: one path per line, relative to `data/`.
const manifest =
['default-db.sqlite', ...wireMigrationNames].join('\n') + '\n';
fs.writeFileSync(path.join(distDir, 'data-file-index.txt'), manifest);
},
};
}
function linkOrCopy(src: string, dest: string) {
try {
fs.linkSync(src, dest);
} catch {
fs.copyFileSync(src, dest);
}
}
export default defineConfig({
ssr: {
noExternal: true,
@@ -133,9 +82,6 @@ export default defineConfig({
},
test: {
globals: true,
environment: 'node',
setupFiles: ['./test/setup.node.ts'],
exclude: ['**/node_modules/**', '**/browser-facade.test.ts'],
onConsoleLog(log: string, type: 'stdout' | 'stderr'): boolean | void {
// print only console.error
return type === 'stderr';

View File

@@ -1,35 +0,0 @@
import path from 'path';
import { defineConfig } from 'vite';
import peggyLoader from 'vite-plugin-peggy-loader';
// Deliberately independent from vite.browser.config.mts: the build config
// applies node polyfills that would swap out Node fs in the test setup
// file. The test setup uses real Node fs to stream the on-disk fixtures
// (default-db.sqlite, migrations, sql.js WASM) through a fetch polyfill.
export default defineConfig({
plugins: [peggyLoader()],
// The facade test imports `../index.browser` directly and uses a mock
// Worker. loot-core never loads on the main thread, so no platform
// condition juggling is needed here. The sibling vite.browser.config.mts
// aliases loot-core to the stub for the bundled facade; for the test we
// mirror that so `methods.ts` resolves correctly.
resolve: {
alias: {
'@actual-app/core/server/main': path.resolve(
__dirname,
'browser/lib-stub.ts',
),
},
},
test: {
globals: true,
environment: 'jsdom',
include: ['test/browser-facade.test.ts'],
onConsoleLog(log: string, type: 'stdout' | 'stderr'): boolean | void {
return type === 'stderr';
},
maxWorkers: 2,
},
});

View File

@@ -139,6 +139,7 @@
"@use-gesture/react": "^10.3.1",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^6.0.1",
"absurd-sql": "0.0.54",
"auto-text-size": "^0.2.3",
"babel-plugin-react-compiler": "^1.0.0",
"cmdk": "^1.1.1",

View File

@@ -1,5 +1,5 @@
import { startBrowserBackend } from '@actual-app/core/platform/client/browser-preload';
import * as Platform from '@actual-app/core/shared/platform';
import { initBackend as initSQLBackend } from 'absurd-sql/dist/indexeddb-main-thread';
import { registerSW } from 'virtual:pwa-register';
// oxlint-disable-next-line typescript-paths/absolute-parent-import
@@ -22,23 +22,256 @@ const ACTUAL_VERSION = Platform.isPlaywright
: packageJson.version;
// *** Start the backend ***
//
// The multi-tab coordinator (leader/follower over SharedWorker), direct
// Worker fallback, and sqlite worker bridge now all live in loot-core
// (packages/loot-core/src/platform/client/browser-preload). We only
// hand it the desktop-specific inputs.
const worker = startBrowserBackend({
backendWorkerUrl,
initPayload: {
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,
},
createSharedWorker: () =>
new SharedBrowserServerWorker({ name: 'actual-backend' }),
forceDirectWorker: Platform.isPlaywright,
});
hasSharedArrayBuffer: !!window.SharedArrayBuffer,
isSharedArrayBufferOverrideEnabled: localStorage.getItem(
'SharedArrayBufferOverride',
),
});
}
createBackendWorker();
let isUpdateReadyForDownload = false;
let markUpdateReadyForDownload;

View File

@@ -128,7 +128,16 @@ export function SelectLinkedAccountsModal({
const localAccounts = allAccounts.filter(a => a.closed === 0);
const [draftLinkAccounts, setDraftLinkAccounts] = useState<
Map<string, 'linking' | 'unlinking'>
>(new Map());
>(() => {
const externalAccountIds = new Set(externalAccounts.map(a => a.account_id));
const initial = new Map<string, 'linking' | 'unlinking'>();
for (const acc of localAccounts) {
if (acc.account_id && externalAccountIds.has(acc.account_id)) {
initial.set(acc.account_id, 'linking');
}
}
return initial;
});
const [chosenAccounts, setChosenAccounts] = useState<Record<string, string>>(
() => {
return Object.fromEntries(

View File

@@ -60,8 +60,6 @@
"default": "./src/shared/platform.ts"
},
"#mocks": "./src/mocks/index.ts",
"#platform/client/backend-worker": "./src/platform/client/backend-worker/index.ts",
"#platform/client/browser-preload": "./src/platform/client/browser-preload/index.ts",
"#platform/client/undo": "./src/platform/client/undo/index.ts",
"#platform/exceptions": "./src/platform/exceptions/index.ts",
"#platform/server/indexeddb": "./src/platform/server/indexeddb/index.ts",
@@ -106,8 +104,6 @@
"./client/transfer": "./src/client/transfer.ts",
"./client/undo": "./src/client/undo.ts",
"./mocks": "./src/mocks/index.ts",
"./platform/client/backend-worker": "./src/platform/client/backend-worker/index.ts",
"./platform/client/browser-preload": "./src/platform/client/browser-preload/index.ts",
"./platform/client/connection": {
"electron-renderer": "./src/platform/client/connection/index.electron.ts",
"default": "./src/platform/client/connection/index.ts"

View File

@@ -1,56 +0,0 @@
// Shared main-thread bootstrap for the browser backend Web Worker.
//
// The same absurd-sql plumbing is needed by:
// - packages/desktop-client/src/browser-preload.js (full multi-tab setup)
// - packages/api/browser/rpc.ts (thin api consumer)
//
// Both need: initSQLBackend(worker) so absurd-sql's __absurd:spawn-idb-worker
// messages are handled, and a way to ignore those internal messages when
// consuming the channel for loot-core's {id, name, args} protocol.
import { initBackend as initSQLBackend } from 'absurd-sql/dist/indexeddb-main-thread';
export type BackendWorker = {
worker: Worker;
/** Register a listener for non-internal messages from the worker. */
onMessage: (handler: (data: unknown) => void) => () => void;
/** Send a message to the worker (loot-core request shape or handshake ack). */
postMessage: (msg: unknown) => void;
/** Terminate the worker and drop all listeners. */
terminate: () => void;
};
export function createBackendWorker(worker: Worker): BackendWorker {
// Hooks __absurd:spawn-idb-worker; without this any sqlite write inside
// the worker hangs on Atomics.wait because the IDB helper never spawns.
initSQLBackend(worker);
const listeners = new Set<(data: unknown) => void>();
worker.addEventListener('message', event => {
const data = (event as MessageEvent).data;
if (isAbsurdMessage(data)) return;
for (const listener of listeners) listener(data);
});
return {
worker,
onMessage(handler) {
listeners.add(handler);
return () => listeners.delete(handler);
},
postMessage(msg) {
worker.postMessage(msg);
},
terminate() {
worker.terminate();
listeners.clear();
},
};
}
function isAbsurdMessage(data: unknown): boolean {
if (!data || typeof data !== 'object') return false;
const type = (data as { type?: unknown }).type;
return typeof type === 'string' && type.startsWith('__absurd:');
}

View File

@@ -1,7 +0,0 @@
export { WorkerBridge, type WorkerLike } from './worker-bridge';
export {
startBrowserBackend,
type StartBackendOptions,
type StartBackendInit,
type StartBackendHandle,
} from './start';

View File

@@ -1,143 +0,0 @@
// @ts-strict-ignore
// Parameterized backend bootstrap moved out of desktop-client's
// browser-preload.js. Picks between a SharedWorker-coordinated multi-tab
// setup and a direct Worker fallback. Consumers hand in the concrete URLs,
// init payload, and (if they want multi-tab coordination) a SharedWorker
// factory — keeping loot-core free of Vite-specific asset imports.
import { createBackendWorker as initSQLBackend } from '#platform/client/backend-worker';
import { logger } from '#platform/server/log';
import { WorkerBridge } from './worker-bridge';
export type StartBackendInit = {
version: string;
isDev: boolean;
publicUrl?: string;
hash?: string;
};
export type StartBackendOptions = {
/** URL of the backend Worker script to spawn. */
backendWorkerUrl: URL;
/** Payload posted to the worker (or shared coordinator) as its init msg. */
initPayload: StartBackendInit;
/**
* Optional factory returning a SharedWorker instance. When provided, the
* backend runs through loot-core's multi-tab coordinator (leader/follower).
* Omit to always spawn a direct Worker on this page.
*/
createSharedWorker?: () => SharedWorker;
/**
* Skip the SharedWorker path even if `createSharedWorker` is provided.
* Typically wired to a platform flag (e.g. Playwright tests).
*/
forceDirectWorker?: boolean;
};
export type StartBackendHandle = Worker | WorkerBridge;
export function startBrowserBackend(
opts: StartBackendOptions,
): StartBackendHandle {
const {
backendWorkerUrl,
initPayload,
createSharedWorker,
forceDirectWorker,
} = opts;
// 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 (
!forceDirectWorker &&
typeof SharedWorker !== 'undefined' &&
createSharedWorker
) {
try {
const sharedWorker = createSharedWorker();
const sharedPort = sharedWorker.port;
const bridge = new WorkerBridge(sharedPort, backendWorkerUrl);
logger.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 (
(globalThis as unknown as { SharedArrayBuffer?: unknown })
.SharedArrayBuffer
) {
try {
localStorage.removeItem('SharedArrayBufferOverride');
} catch {
// localStorage may be unavailable in some embeddings; ignore.
}
}
let isSharedArrayBufferOverrideEnabled: string | null = null;
try {
isSharedArrayBufferOverrideEnabled = localStorage.getItem(
'SharedArrayBufferOverride',
);
} catch {
// ignore
}
sharedPort.postMessage({
type: 'init',
...initPayload,
isSharedArrayBufferOverrideEnabled,
});
window.addEventListener('beforeunload', () => {
sharedPort.postMessage({ type: 'tab-closing' });
});
return bridge;
} catch (e) {
logger.log('SharedWorker failed, falling back to Worker:', e);
}
}
// Fallback: regular Worker (Playwright, no SharedWorker support, or the
// consumer opted out by omitting createSharedWorker).
logger.log('[WorkerBridge] No SharedWorker available, using direct Worker');
const worker = new Worker(backendWorkerUrl);
initSQLBackend(worker);
if (
(globalThis as unknown as { SharedArrayBuffer?: unknown }).SharedArrayBuffer
) {
try {
localStorage.removeItem('SharedArrayBufferOverride');
} catch {
// ignore
}
}
let isSharedArrayBufferOverrideEnabled: string | null = null;
try {
isSharedArrayBufferOverrideEnabled = localStorage.getItem(
'SharedArrayBufferOverride',
);
} catch {
// ignore
}
worker.postMessage({
type: 'init',
...initPayload,
hasSharedArrayBuffer: !!(
globalThis as unknown as { SharedArrayBuffer?: unknown }
).SharedArrayBuffer,
isSharedArrayBufferOverrideEnabled,
});
return worker;
}

View File

@@ -1,202 +0,0 @@
// @ts-strict-ignore
// Moved verbatim from packages/desktop-client/src/browser-preload.js — this
// is the SharedWorker-port → Worker-like adapter loot-core's client
// connection layer consumes. Works identically for any browser consumer
// that opts into multi-tab coordination.
import { createBackendWorker as initSQLBackend } from '#platform/client/backend-worker';
import { logger } from '#platform/server/log';
export type WorkerLike = {
onmessage: ((e: MessageEvent) => void) | null;
postMessage: (msg: unknown) => void;
addEventListener: (type: string, handler: (e: MessageEvent) => void) => void;
start?: () => void;
terminate?: () => void;
};
/**
* 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.
*/
export class WorkerBridge {
_sharedPort: MessagePort;
_onmessage: ((e: MessageEvent) => void) | null;
_listeners: Array<{ type: string; handler: (e: MessageEvent) => void }>;
_started: boolean;
localBackendWorker: Worker | null;
backendWorkerUrl: URL;
constructor(sharedPort: MessagePort, backendWorkerUrl: URL) {
this._sharedPort = sharedPort;
this._onmessage = null;
this._listeners = [];
this._started = false;
this.localBackendWorker = null;
this.backendWorkerUrl = backendWorkerUrl;
// 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 (this.localBackendWorker) {
this.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') {
logger.log('[WorkerBridge] Leadership transferred — terminating Worker');
if (this.localBackendWorker) {
this.localBackendWorker.terminate();
this.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: {} },
} as MessageEvent);
}
return;
}
// Role change notification
if (msg && msg.type === '__role-change') {
logger.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 (this.localBackendWorker) {
this.localBackendWorker.terminate();
}
this.localBackendWorker = new Worker(this.backendWorkerUrl);
initSQLBackend(this.localBackendWorker);
const sharedPort = this._sharedPort;
const localWorker = this.localBackendWorker;
localWorker.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) {
logger.log(
`[WorkerBridge] Backend connected, restoring budget "${budgetToRestore}"`,
);
const id = budgetToRestore;
budgetToRestore = null;
localWorker.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;
localWorker.postMessage(toSend);
}
}
sharedPort.postMessage({ type: '__from-worker', msg: workerMsg });
};
localWorker.postMessage(initMsg);
}
}

View File

@@ -1,3 +0,0 @@
declare module 'absurd-sql/dist/indexeddb-main-thread' {
export function initBackend(worker: Worker): void;
}

View File

@@ -64,6 +64,19 @@ export async function bootstrapOpenId(configParameter) {
return {};
}
export function pickIdTokenSignedResponseAlg(issuer, configParameter) {
if (configParameter.id_token_signed_response_alg) {
return configParameter.id_token_signed_response_alg;
}
const supported = issuer?.metadata?.id_token_signing_alg_values_supported;
if (Array.isArray(supported) && supported.length > 0) {
return supported.includes('RS256') ? 'RS256' : supported[0];
}
return 'RS256';
}
async function setupOpenIdClient(configParameter) {
const issuer =
typeof configParameter.issuer === 'string'
@@ -73,6 +86,12 @@ async function setupOpenIdClient(configParameter) {
authorization_endpoint: configParameter.issuer.authorization_endpoint,
token_endpoint: configParameter.issuer.token_endpoint,
userinfo_endpoint: configParameter.issuer.userinfo_endpoint,
...(Array.isArray(
configParameter.issuer.id_token_signing_alg_values_supported,
) && {
id_token_signing_alg_values_supported:
configParameter.issuer.id_token_signing_alg_values_supported,
}),
});
const client = new issuer.Client({
@@ -83,6 +102,10 @@ async function setupOpenIdClient(configParameter) {
configParameter.server_hostname,
).toString(),
validate_id_token: true,
id_token_signed_response_alg: pickIdTokenSignedResponseAlg(
issuer,
configParameter,
),
});
return client;

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { pickIdTokenSignedResponseAlg } from './openid';
describe('pickIdTokenSignedResponseAlg', () => {
it('returns the explicit override when provided, even if discovery suggests otherwise', () => {
const issuer = {
metadata: { id_token_signing_alg_values_supported: ['RS256', 'ES384'] },
};
expect(
pickIdTokenSignedResponseAlg(issuer, {
id_token_signed_response_alg: 'ES512',
}),
).toBe('ES512');
});
it('prefers RS256 when it is among the advertised algorithms (backwards-compat)', () => {
const issuer = {
metadata: { id_token_signing_alg_values_supported: ['ES384', 'RS256'] },
};
expect(pickIdTokenSignedResponseAlg(issuer, {})).toBe('RS256');
});
it('falls back to the first advertised algorithm when RS256 is not offered', () => {
const issuer = {
metadata: { id_token_signing_alg_values_supported: ['ES384', 'ES256'] },
};
expect(pickIdTokenSignedResponseAlg(issuer, {})).toBe('ES384');
});
it('uses a lone ECDSA algorithm (the bug-report case)', () => {
const issuer = {
metadata: { id_token_signing_alg_values_supported: ['ES384'] },
};
expect(pickIdTokenSignedResponseAlg(issuer, {})).toBe('ES384');
});
it('defaults to RS256 when the issuer exposes no signing metadata', () => {
expect(pickIdTokenSignedResponseAlg({ metadata: {} }, {})).toBe('RS256');
expect(pickIdTokenSignedResponseAlg(undefined, {})).toBe('RS256');
});
it('treats an empty override as "auto-detect"', () => {
const issuer = {
metadata: { id_token_signing_alg_values_supported: ['ES384'] },
};
expect(
pickIdTokenSignedResponseAlg(issuer, {
id_token_signed_response_alg: '',
}),
).toBe('ES384');
});
});

View File

@@ -255,6 +255,12 @@ const configSchema = convict({
default: 'openid',
env: 'ACTUAL_OPENID_AUTH_METHOD',
},
id_token_signed_response_alg: {
doc: 'Algorithm used by the OpenID provider to sign ID tokens (e.g. RS256, ES256, ES384). Auto-detected from discovery metadata when empty.',
format: String,
default: '',
env: 'ACTUAL_OPENID_ID_TOKEN_SIGNED_RESPONSE_ALG',
},
},
token_expiration: {

View File

@@ -138,4 +138,21 @@ describe('config schema', () => {
authorizationEndpoint,
);
});
it('parses the ID token signing algorithm override from the environment', () => {
process.env.TEST_OPENID_ID_TOKEN_SIGNED_RESPONSE_ALG = 'ES384';
const testSchema = convict({
openId: {
id_token_signed_response_alg: {
doc: 'Algorithm used by the OpenID provider to sign ID tokens.',
format: String,
default: '',
env: 'TEST_OPENID_ID_TOKEN_SIGNED_RESPONSE_ALG',
},
},
});
expect(() => testSchema.validate()).not.toThrow();
expect(testSchema.get('openId.id_token_signed_response_alg')).toBe('ES384');
});
});

View File

@@ -0,0 +1,6 @@
---
category: Bugfixes
authors: [matt-fidd]
---
Fix bank sync account linking modal being disabled when relinking existing accounts

View File

@@ -1,6 +0,0 @@
---
category: Features
authors: [MatissJanis]
---
api: support using in browser context

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
Disable fail-fast in Electron build workflows to allow all matrix jobs to complete independently.

View File

@@ -0,0 +1,6 @@
---
category: Bugfixes
authors: [MatissJanis]
---
Sync server: accept non-RS256 OpenID ID tokens. The ID-token signing algorithm is now auto-detected from the provider's discovery metadata (with an optional `ACTUAL_OPENID_ID_TOKEN_SIGNED_RESPONSE_ALG` override), fixing login against IdPs that sign with ECDSA (ES256/ES384/ES512) or other RSA variants.

View File

@@ -26,16 +26,11 @@ __metadata:
"@actual-app/core": "workspace:*"
"@actual-app/crdt": "workspace:*"
"@typescript/native-preview": "npm:^7.0.0-dev.20260404.1"
absurd-sql: "npm:0.0.54"
better-sqlite3: "npm:^12.8.0"
compare-versions: "npm:^6.1.1"
fake-indexeddb: "npm:^6.2.5"
jsdom: "npm:^27.4.0"
npm-run-all: "npm:^4.1.5"
rollup-plugin-visualizer: "npm:^7.0.1"
typescript-strict-plugin: "npm:^2.4.4"
vite: "npm:^8.0.5"
vite-plugin-node-polyfills: "npm:^0.26.0"
vite-plugin-peggy-loader: "npm:^2.0.1"
vitest: "npm:^4.1.2"
languageName: unknown
@@ -249,6 +244,7 @@ __metadata:
"@use-gesture/react": "npm:^10.3.1"
"@vitejs/plugin-basic-ssl": "npm:^2.3.0"
"@vitejs/plugin-react": "npm:^6.0.1"
absurd-sql: "npm:0.0.54"
auto-text-size: "npm:^0.2.3"
babel-plugin-react-compiler: "npm:^1.0.0"
cmdk: "npm:^1.1.1"