feat: add helper process (#2753)

This commit is contained in:
Peng Xiao 2023-06-13 10:01:43 +08:00 committed by GitHub
parent dff8a0db7d
commit 5ba2dff008
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
74 changed files with 1002 additions and 1048 deletions

View File

@ -121,7 +121,7 @@ jobs:
path: apps/electron/resources/web-static
- name: Build layers
run: yarn workspace @affine/electron build-layers
run: yarn workspace @affine/electron build
- name: Signing By Apple Developer ID
if: ${{ matrix.spec.platform == 'darwin' }}

View File

@ -117,7 +117,7 @@ jobs:
path: apps/electron/resources/web-static
- name: Build layers
run: yarn workspace @affine/electron build-layers
run: yarn workspace @affine/electron build
- name: Signing By Apple Developer ID
if: ${{ matrix.spec.platform == 'darwin' }}

View File

@ -34,7 +34,7 @@
"packages/**/*.spec.tsx",
"apps/web/**/*.spec.ts",
"apps/web/**/*.spec.tsx",
"apps/electron/layers/**/*.spec.ts",
"apps/electron/src/**/*.spec.ts",
"tests/unit/**/*.spec.ts",
"tests/unit/**/*.spec.tsx"
],

View File

@ -1,5 +1,6 @@
*.autogen.*
dist
e2e-dist-*
resources/web-static

View File

@ -1,497 +0,0 @@
import assert from 'node:assert';
import path from 'node:path';
import { setTimeout } from 'node:timers/promises';
import fs from 'fs-extra';
import { v4 } from 'uuid';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import * as Y from 'yjs';
import type { MainIPCHandlerMap } from '../exposed';
const registeredHandlers = new Map<
string,
((...args: any[]) => Promise<any>)[]
>();
type WithoutFirstParameter<T> = T extends (_: any, ...args: infer P) => infer R
? (...args: P) => R
: T;
// common mock dispatcher for ipcMain.handle AND app.on
// alternatively, we can use single parameter for T & F, eg, dispatch('workspace:list'),
// however this is too hard to be typed correctly
async function dispatch<
T extends keyof MainIPCHandlerMap,
F extends keyof MainIPCHandlerMap[T]
>(
namespace: T,
functionName: F,
...args: Parameters<WithoutFirstParameter<MainIPCHandlerMap[T][F]>>
): // @ts-expect-error
ReturnType<MainIPCHandlerMap[T][F]> {
// @ts-expect-error
const handlers = registeredHandlers.get(namespace + ':' + functionName);
assert(handlers);
// we only care about the first handler here
return await handlers[0](null, ...args);
}
const SESSION_DATA_PATH = path.join(__dirname, './tmp', 'affine-test');
const DOCUMENTS_PATH = path.join(__dirname, './tmp', 'affine-test-documents');
const browserWindow = {
isDestroyed: () => {
return false;
},
setWindowButtonVisibility: (_v: boolean) => {
// will be stubbed later
},
webContents: {
send: (_type: string, ..._args: any[]) => {
// will be stubbed later
},
},
};
const ipcMain = {
handle: (key: string, callback: (...args: any[]) => Promise<any>) => {
const handlers = registeredHandlers.get(key) || [];
handlers.push(callback);
registeredHandlers.set(key, handlers);
},
setMaxListeners: (_n: number) => {
// noop
},
};
const nativeTheme = {
themeSource: 'light',
};
function compareBuffer(
a: Uint8Array | null | undefined,
b: Uint8Array | null | undefined
) {
if (
(a == null && b == null) ||
a == null ||
b == null ||
a.length !== b.length
) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}
const electronModule = {
app: {
getPath: (name: string) => {
if (name === 'sessionData') {
return SESSION_DATA_PATH;
} else if (name === 'documents') {
return DOCUMENTS_PATH;
}
throw new Error('not implemented');
},
name: 'affine-test',
on: (name: string, callback: (...args: any[]) => any) => {
const handlers = registeredHandlers.get(name) || [];
handlers.push(callback);
registeredHandlers.set(name, handlers);
},
addListener: (...args: any[]) => {
// @ts-expect-error
electronModule.app.on(...args);
},
removeListener: () => {},
},
BrowserWindow: {
getAllWindows: () => {
return [browserWindow];
},
},
nativeTheme: nativeTheme,
ipcMain,
shell: {} as Partial<Electron.Shell>,
dialog: {} as Partial<Electron.Dialog>,
};
// dynamically import handlers so that we can inject local variables to mocks
vi.doMock('electron', () => {
return electronModule;
});
beforeEach(async () => {
const { registerHandlers } = await import('../handlers');
registerHandlers();
// should also register events
const { registerEvents } = await import('../events');
registerEvents();
await fs.mkdirp(SESSION_DATA_PATH);
registeredHandlers.get('ready')?.forEach(fn => fn());
});
afterEach(async () => {
// reset registered handlers
registeredHandlers.get('before-quit')?.forEach(fn => fn());
// wait for the db to be closed on Windows
if (process.platform === 'win32') {
await setTimeout(200);
}
await fs.remove(SESSION_DATA_PATH);
});
describe('ensureSQLiteDB', () => {
test('should create db file on connection if it does not exist', async () => {
const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db');
const workspaceDB = await ensureSQLiteDB(id);
const file = workspaceDB.path;
const fileExists = await fs.pathExists(file);
expect(fileExists).toBe(true);
});
test('should emit the same db instance for the same id', async () => {
const [id1, id2] = [v4(), v4()];
const { ensureSQLiteDB } = await import('../db/ensure-db');
const workspaceDB1 = await ensureSQLiteDB(id1);
const workspaceDB2 = await ensureSQLiteDB(id2);
const workspaceDB3 = await ensureSQLiteDB(id1);
expect(workspaceDB1).toBe(workspaceDB3);
expect(workspaceDB1).not.toBe(workspaceDB2);
});
test('when app quit, db should be closed', async () => {
const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db');
const workspaceDB = await ensureSQLiteDB(id);
const file = workspaceDB.path;
const fileExists = await fs.pathExists(file);
expect(fileExists).toBe(true);
registeredHandlers.get('before-quit')?.forEach(fn => fn());
await setTimeout(100);
expect(workspaceDB.db).toBe(null);
});
});
describe('workspace handlers', () => {
test('list all workspace ids', async () => {
const ids = [v4(), v4()];
const { ensureSQLiteDB } = await import('../db/ensure-db');
await Promise.all(ids.map(id => ensureSQLiteDB(id)));
const list = await dispatch('workspace', 'list');
expect(list.map(([id]) => id).sort()).toEqual(ids.sort());
});
test('delete workspace', async () => {
const ids = [v4(), v4()];
const { ensureSQLiteDB } = await import('../db/ensure-db');
const dbs = await Promise.all(ids.map(id => ensureSQLiteDB(id)));
await dispatch('workspace', 'delete', ids[1]);
const list = await dispatch('workspace', 'list');
expect(list.map(([id]) => id)).toEqual([ids[0]]);
// deleted db should be closed
expect(dbs[1].db).toBe(null);
});
});
describe('UI handlers', () => {
test('theme-change', async () => {
await dispatch('ui', 'handleThemeChange', 'dark');
expect(nativeTheme.themeSource).toBe('dark');
await dispatch('ui', 'handleThemeChange', 'light');
expect(nativeTheme.themeSource).toBe('light');
});
test('sidebar-visibility-change (macOS)', async () => {
vi.stubGlobal('process', { platform: 'darwin' });
const setWindowButtonVisibility = vi.fn();
browserWindow.setWindowButtonVisibility = setWindowButtonVisibility;
await dispatch('ui', 'handleSidebarVisibilityChange', true);
expect(setWindowButtonVisibility).toBeCalledWith(true);
await dispatch('ui', 'handleSidebarVisibilityChange', false);
expect(setWindowButtonVisibility).toBeCalledWith(false);
vi.unstubAllGlobals();
});
test('sidebar-visibility-change (non-macOS)', async () => {
vi.stubGlobal('process', { platform: 'linux' });
const setWindowButtonVisibility = vi.fn();
browserWindow.setWindowButtonVisibility = setWindowButtonVisibility;
await dispatch('ui', 'handleSidebarVisibilityChange', true);
expect(setWindowButtonVisibility).not.toBeCalled();
vi.unstubAllGlobals();
});
});
describe('db handlers', () => {
test('apply doc and get doc updates', async () => {
const workspaceId = v4();
const bin = await dispatch('db', 'getDocAsUpdates', workspaceId);
// ? is this a good test?
expect(bin.every((byte: number) => byte === 0)).toBe(true);
const ydoc = new Y.Doc();
const ytext = ydoc.getText('test');
ytext.insert(0, 'hello world');
const bin2 = Y.encodeStateAsUpdate(ydoc);
await dispatch('db', 'applyDocUpdate', workspaceId, bin2);
const bin3 = await dispatch('db', 'getDocAsUpdates', workspaceId);
const ydoc2 = new Y.Doc();
Y.applyUpdate(ydoc2, bin3);
const ytext2 = ydoc2.getText('test');
expect(ytext2.toString()).toBe('hello world');
});
test('get non existent blob', async () => {
const workspaceId = v4();
const bin = await dispatch('db', 'getBlob', workspaceId, 'non-existent-id');
expect(bin).toBeUndefined();
});
test('list blobs (empty)', async () => {
const workspaceId = v4();
const list = await dispatch('db', 'getBlobKeys', workspaceId);
expect(list).toEqual([]);
});
test('CRUD blobs', async () => {
const testBin = new Uint8Array([1, 2, 3, 4, 5]);
const testBin2 = new Uint8Array([6, 7, 8, 9, 10]);
const workspaceId = 'test-workspace-id';
// add blob
await dispatch('db', 'addBlob', workspaceId, 'testBin', testBin);
// get blob
expect(
compareBuffer(
await dispatch('db', 'getBlob', workspaceId, 'testBin'),
testBin
)
).toBe(true);
// add another blob
await dispatch('db', 'addBlob', workspaceId, 'testBin2', testBin2);
expect(
compareBuffer(
await dispatch('db', 'getBlob', workspaceId, 'testBin2'),
testBin2
)
).toBe(true);
// list blobs
let lists = await dispatch('db', 'getBlobKeys', workspaceId);
expect(lists).toHaveLength(2);
expect(lists).toContain('testBin');
expect(lists).toContain('testBin2');
// delete blob
await dispatch('db', 'deleteBlob', workspaceId, 'testBin');
lists = await dispatch('db', 'getBlobKeys', workspaceId);
expect(lists).toEqual(['testBin2']);
});
});
describe('dialog handlers', () => {
test('revealDBFile', async () => {
const mockShowItemInFolder = vi.fn();
electronModule.shell.showItemInFolder = mockShowItemInFolder;
const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db');
const db = await ensureSQLiteDB(id);
await dispatch('dialog', 'revealDBFile', id);
expect(mockShowItemInFolder).toBeCalledWith(db.path);
});
test('saveDBFileAs (skipped)', async () => {
const mockShowSaveDialog = vi.fn(() => {
return { filePath: undefined };
}) as any;
const mockShowItemInFolder = vi.fn();
electronModule.dialog.showSaveDialog = mockShowSaveDialog;
electronModule.shell.showItemInFolder = mockShowItemInFolder;
const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db');
await ensureSQLiteDB(id);
await dispatch('dialog', 'saveDBFileAs', id);
expect(mockShowSaveDialog).toBeCalled();
expect(mockShowItemInFolder).not.toBeCalled();
electronModule.dialog = {};
electronModule.shell = {};
});
test('saveDBFileAs', async () => {
const newSavedPath = path.join(SESSION_DATA_PATH, 'saved-to');
const mockShowSaveDialog = vi.fn(() => {
return { filePath: newSavedPath };
}) as any;
const mockShowItemInFolder = vi.fn();
electronModule.dialog.showSaveDialog = mockShowSaveDialog;
electronModule.shell.showItemInFolder = mockShowItemInFolder;
const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db');
await ensureSQLiteDB(id);
await dispatch('dialog', 'saveDBFileAs', id);
expect(mockShowSaveDialog).toBeCalled();
expect(mockShowItemInFolder).toBeCalledWith(newSavedPath);
// check if file is saved to new path
expect(await fs.exists(newSavedPath)).toBe(true);
});
test('loadDBFile (skipped)', async () => {
const mockShowOpenDialog = vi.fn(() => {
return { filePaths: undefined };
}) as any;
electronModule.dialog.showOpenDialog = mockShowOpenDialog;
const res = await dispatch('dialog', 'loadDBFile');
expect(mockShowOpenDialog).toBeCalled();
expect(res.canceled).toBe(true);
});
test('loadDBFile (error, in app-data)', async () => {
const mockShowOpenDialog = vi.fn(() => {
return {
filePaths: [path.join(SESSION_DATA_PATH, 'workspaces')],
};
}) as any;
electronModule.dialog.showOpenDialog = mockShowOpenDialog;
const res = await dispatch('dialog', 'loadDBFile');
expect(mockShowOpenDialog).toBeCalled();
expect(res.error).toBe('DB_FILE_PATH_INVALID');
});
test('loadDBFile (error, not a valid affine file)', async () => {
// create a random db file
const basePath = path.join(SESSION_DATA_PATH, 'random-path');
const dbPath = path.join(basePath, 'xxx.affine');
await fs.ensureDir(basePath);
await fs.writeFile(dbPath, 'hello world');
const mockShowOpenDialog = vi.fn(() => {
return { filePaths: [dbPath] };
}) as any;
electronModule.dialog.showOpenDialog = mockShowOpenDialog;
const res = await dispatch('dialog', 'loadDBFile');
expect(mockShowOpenDialog).toBeCalled();
expect(res.error).toBe('DB_FILE_INVALID');
electronModule.dialog = {};
});
test('loadDBFile (correct)', async () => {
// we use ensureSQLiteDB to create a valid db file
const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db');
const db = await ensureSQLiteDB(id);
// copy db file to dbPath
const basePath = path.join(SESSION_DATA_PATH, 'random-path');
const clonedDBPath = path.join(basePath, 'xxx.affine');
await fs.ensureDir(basePath);
await fs.copyFile(db.path, clonedDBPath);
// delete workspace
await dispatch('workspace', 'delete', id);
// try load originDBFilePath
const mockShowOpenDialog = vi.fn(() => {
return { filePaths: [clonedDBPath] };
}) as any;
electronModule.dialog.showOpenDialog = mockShowOpenDialog;
const res = await dispatch('dialog', 'loadDBFile');
expect(mockShowOpenDialog).toBeCalled();
const newId = res.workspaceId;
expect(newId).not.toBeUndefined();
assert(newId);
const meta = await dispatch('workspace', 'getMeta', newId);
expect(meta.secondaryDBPath).toBe(clonedDBPath);
// try load it again, will trigger error (db file already loaded)
const res2 = await dispatch('dialog', 'loadDBFile');
expect(res2.error).toBe('DB_FILE_ALREADY_LOADED');
});
test('moveDBFile (valid)', async () => {
const sendStub = vi.fn();
browserWindow.webContents.send = sendStub;
const newPath = path.join(SESSION_DATA_PATH, 'xxx');
const showOpenDialog = vi.fn(() => {
return { filePaths: [newPath] };
}) as any;
electronModule.dialog.showOpenDialog = showOpenDialog;
const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db');
const db = await ensureSQLiteDB(id);
const res = await dispatch('dialog', 'moveDBFile', id);
expect(showOpenDialog).toBeCalled();
assert(res.filePath);
expect(path.dirname(res.filePath)).toBe(newPath);
expect(res.filePath.endsWith('.affine')).toBe(true);
// should also send workspace meta change event
expect(sendStub).toBeCalledWith('workspace:onMetaChange', {
workspaceId: id,
meta: { id, secondaryDBPath: res.filePath, mainDBPath: db.path },
});
electronModule.dialog = {};
browserWindow.webContents.send = () => {};
});
test('moveDBFile (canceled)', async () => {
const showOpenDialog = vi.fn(() => {
return { filePaths: null };
}) as any;
electronModule.dialog.showOpenDialog = showOpenDialog;
const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db');
await ensureSQLiteDB(id);
const res = await dispatch('dialog', 'moveDBFile', id);
expect(showOpenDialog).toBeCalled();
expect(res.filePath).toBe(undefined);
electronModule.dialog = {};
});
});
describe('applicationMenu', () => {
// test some basic IPC events
test('applicationMenu event', async () => {
const { applicationMenuSubjects } = await import('../application-menu');
const sendStub = vi.fn();
browserWindow.webContents.send = sendStub;
applicationMenuSubjects.newPageAction.next();
expect(sendStub).toHaveBeenCalledWith(
'applicationMenu:onNewPageAction',
undefined
);
browserWindow.webContents.send = () => {};
});
});

View File

@ -1,12 +0,0 @@
import { app } from 'electron';
export const appContext = {
get appName() {
return app.name;
},
get appDataPath() {
return app.getPath('sessionData');
},
};
export type AppContext = typeof appContext;

View File

@ -1,18 +0,0 @@
export type MainEventListener = (...args: any[]) => () => void;
export type IsomorphicHandler = (
e: Electron.IpcMainInvokeEvent,
...args: any[]
) => Promise<any>;
export type NamespaceHandlers = {
[key: string]: IsomorphicHandler;
};
export interface WorkspaceMeta {
id: string;
mainDBPath: string;
secondaryDBPath?: string; // assume there will be only one
}
export type YOrigin = 'self' | 'external' | 'upstream' | 'renderer';

View File

@ -1,45 +0,0 @@
import type { MessagePort, Worker } from 'node:worker_threads';
import type { EventBasedChannel } from 'async-call-rpc';
export function getTime() {
return new Date().getTime();
}
export const isMacOS = () => {
return process.platform === 'darwin';
};
export const isWindows = () => {
return process.platform === 'win32';
};
export class ThreadWorkerChannel implements EventBasedChannel {
constructor(private worker: Worker) {}
on(listener: (data: unknown) => void) {
this.worker.addListener('message', listener);
return () => {
this.worker.removeListener('message', listener);
};
}
send(data: unknown) {
this.worker.postMessage(data);
}
}
export class MessagePortChannel implements EventBasedChannel {
constructor(private port: MessagePort) {}
on(listener: (data: unknown) => void) {
this.port.addListener('message', listener);
return () => {
this.port.removeListener('message', listener);
};
}
send(data: unknown) {
this.port.postMessage(data);
}
}

View File

@ -1,35 +0,0 @@
import path from 'node:path';
import { Worker } from 'node:worker_threads';
import { mergeUpdate } from './merge-update';
export function mergeUpdateWorker(updates: Uint8Array[]) {
// fallback to main thread if worker is disabled (in vitest)
if (process.env.USE_WORKER !== 'true') {
return mergeUpdate(updates);
}
return new Promise<Uint8Array>((resolve, reject) => {
// it is intended to have "./workers" in the path
const workerFile = path.join(__dirname, './workers/merge-update.worker.js');
// convert updates to SharedArrayBuffer[s]
const sharedArrayBufferUpdates = updates.map(update => {
const buffer = new SharedArrayBuffer(update.byteLength);
const view = new Uint8Array(buffer);
view.set(update);
return view;
});
const worker = new Worker(workerFile, {
workerData: sharedArrayBufferUpdates,
});
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', code => {
if (code !== 0) {
reject(new Error(`Worker stopped with exit code ${code}`));
}
});
});
}

View File

@ -1,14 +0,0 @@
import { parentPort, workerData } from 'node:worker_threads';
import { mergeUpdate } from './merge-update';
function getMergeUpdate(updates: Uint8Array[]) {
const update = mergeUpdate(updates);
const buffer = new SharedArrayBuffer(update.byteLength);
const view = new Uint8Array(buffer);
view.set(update);
return update;
}
parentPort?.postMessage(getMergeUpdate(workerData));

View File

@ -1,75 +0,0 @@
// NOTE: we will generate preload types from this file
import { ipcRenderer } from 'electron';
type MainExposedMeta = {
handlers: [namespace: string, handlerNames: string[]][];
events: [namespace: string, eventNames: string[]][];
};
const meta: MainExposedMeta = (() => {
const val = process.argv
.find(arg => arg.startsWith('--exposed-meta='))
?.split('=')[1];
return val ? JSON.parse(val) : null;
})();
// main handlers that can be invoked from the renderer process
const apis: any = (() => {
const { handlers: handlersMeta } = meta;
const all = handlersMeta.map(([namespace, functionNames]) => {
const namespaceApis = functionNames.map(name => {
const channel = `${namespace}:${name}`;
return [
name,
(...args: any[]) => {
return ipcRenderer.invoke(channel, ...args);
},
];
});
return [namespace, Object.fromEntries(namespaceApis)];
});
return Object.fromEntries(all);
})();
// main events that can be listened to from the renderer process
const events: any = (() => {
const { events: eventsMeta } = meta;
// NOTE: ui may try to listen to a lot of the same events, so we increase the limit...
ipcRenderer.setMaxListeners(100);
const all = eventsMeta.map(([namespace, eventNames]) => {
const namespaceEvents = eventNames.map(name => {
const channel = `${namespace}:${name}`;
return [
name,
(callback: (...args: any[]) => void) => {
const fn: (
event: Electron.IpcRendererEvent,
...args: any[]
) => void = (_, ...args) => {
callback(...args);
};
ipcRenderer.on(channel, fn);
return () => {
ipcRenderer.off(channel, fn);
};
},
];
});
return [namespace, Object.fromEntries(namespaceEvents)];
});
return Object.fromEntries(all);
})();
const appInfo = {
electron: true,
};
export { apis, appInfo, events };
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
export type { MainIPCEventMap } from '../../main/src/exposed';

View File

@ -17,12 +17,12 @@
"generate-assets": "zx scripts/generate-assets.mjs",
"package": "electron-forge package",
"make": "electron-forge make",
"test": "playwright test"
"test": "DEBUG=pw:browser playwright test"
},
"config": {
"forge": "./forge.config.js"
},
"main": "./dist/layers/main/index.js",
"main": "./dist/main.js",
"exports": {
"./scripts/plugins/build-plugins.mjs": "./scripts/plugins/build-plugins.mjs"
},

View File

@ -18,7 +18,6 @@ if (process.platform === 'win32') {
async function buildLayers() {
const common = config();
await esbuild.build(common.preload);
console.log('Build plugin infra');
spawnSync('yarn', ['build'], {
stdio: 'inherit',
@ -28,10 +27,11 @@ async function buildLayers() {
console.log('Build plugins');
await import('./plugins/build-plugins.mjs');
await esbuild.build(common.workers);
await esbuild.build({
...common.main,
...common.layers,
define: {
...common.main.define,
...common.define,
'process.env.NODE_ENV': `"${NODE_ENV}"`,
'process.env.BUILD_TYPE': `"${process.env.BUILD_TYPE || 'stable'}"`,
},

View File

@ -18,7 +18,7 @@ const mode = (process.env.NODE_ENV = process.env.NODE_ENV || 'development');
// List of env that will be replaced by esbuild
const ENV_MACROS = ['AFFINE_GOOGLE_CLIENT_ID', 'AFFINE_GOOGLE_CLIENT_SECRET'];
/** @return {{main: import('esbuild').BuildOptions, preload: import('esbuild').BuildOptions}} */
/** @return {{layers: import('esbuild').BuildOptions, workers: import('esbuild').BuildOptions}} */
export const config = () => {
const define = Object.fromEntries([
...ENV_MACROS.map(key => [
@ -34,27 +34,18 @@ export const config = () => {
}
return {
main: {
layers: {
entryPoints: [
resolve(electronDir, './layers/main/src/index.ts'),
resolve(
electronDir,
'./layers/main/src/workers/merge-update.worker.ts'
),
resolve(electronDir, './layers/main/src/workers/plugin.worker.ts'),
resolve(electronDir, './src/main/index.ts'),
resolve(electronDir, './src/preload/index.ts'),
resolve(electronDir, './src/helper/index.ts'),
],
outdir: resolve(electronDir, './dist/layers/main'),
entryNames: '[dir]',
outdir: resolve(electronDir, './dist'),
bundle: true,
target: `node${NODE_MAJOR_VERSION}`,
platform: 'node',
external: [
'electron',
'yjs',
'better-sqlite3',
'electron-updater',
'@toeverything/plugin-infra',
'async-call-rpc',
],
external: ['electron', 'electron-updater', '@toeverything/plugin-infra'],
define: define,
format: 'cjs',
loader: {
@ -63,14 +54,23 @@ export const config = () => {
assetNames: '[name]',
treeShaking: true,
},
preload: {
entryPoints: [resolve(electronDir, './layers/preload/src/index.ts')],
outdir: resolve(electronDir, './dist/layers/preload'),
workers: {
entryPoints: [
resolve(electronDir, './src/main/workers/plugin.worker.ts'),
],
entryNames: '[dir]/[name]',
outdir: resolve(electronDir, './dist/workers'),
bundle: true,
target: `node${NODE_MAJOR_VERSION}`,
platform: 'node',
external: ['electron'],
external: ['electron', 'electron-updater', '@toeverything/plugin-infra'],
define: define,
format: 'cjs',
loader: {
'.node': 'copy',
},
assetNames: '[name]',
treeShaking: true,
},
};
};

View File

@ -76,19 +76,20 @@ async function watchPlugins() {
await import('./plugins/dev-plugins.mjs');
}
async function watchPreload() {
async function watchLayers() {
return new Promise(async resolve => {
let initialBuild = false;
const preloadBuild = await esbuild.context({
...common.preload,
const buildContext = await esbuild.context({
...common.layers,
plugins: [
...(common.preload.plugins ?? []),
...(common.layers.plugins ?? []),
{
name: 'electron-dev:reload-app-on-preload-change',
name: 'electron-dev:reload-app-on-layers-change',
setup(build) {
build.onEnd(() => {
if (initialBuild) {
console.log(`[preload] has changed, [re]launching electron...`);
console.log(`[layers] has changed, [re]launching electron...`);
spawnOrReloadElectron();
} else {
resolve();
@ -99,25 +100,24 @@ async function watchPreload() {
},
],
});
// watch will trigger build.onEnd() on first run & on subsequent changes
await preloadBuild.watch();
await buildContext.watch();
});
}
async function watchMain() {
async function watchWorkers() {
return new Promise(async resolve => {
let initialBuild = false;
const mainBuild = await esbuild.context({
...common.main,
const buildContext = await esbuild.context({
...common.workers,
plugins: [
...(common.main.plugins ?? []),
...(common.workers.plugins ?? []),
{
name: 'electron-dev:reload-app-on-main-change',
name: 'electron-dev:reload-app-on-workers-change',
setup(build) {
build.onEnd(() => {
if (initialBuild) {
console.log(`[main] has changed, [re]launching electron...`);
console.log(`[workers] has changed, [re]launching electron...`);
spawnOrReloadElectron();
} else {
resolve();
@ -128,14 +128,14 @@ async function watchMain() {
},
],
});
await mainBuild.watch();
await buildContext.watch();
});
}
async function main() {
await watchPlugins();
await watchMain();
await watchPreload();
await watchLayers();
await watchWorkers();
if (watchMode) {
console.log(`Watching for changes...`);

View File

@ -85,7 +85,6 @@ async function cleanup() {
if (!process.env.SKIP_WEB_BUILD) {
await fs.emptyDir(publicAffineOutDir);
}
await fs.emptyDir(path.join(electronRootDir, 'layers', 'main', 'dist'));
await fs.emptyDir(path.join(electronRootDir, 'layers', 'preload', 'dist'));
await fs.remove(path.join(electronRootDir, 'dist'));
await fs.remove(path.join(electronRootDir, 'out'));
}

View File

@ -6,56 +6,22 @@ import { v4 } from 'uuid';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
const tmpDir = path.join(__dirname, 'tmp');
const appDataPath = path.join(tmpDir, 'app-data');
const registeredHandlers = new Map<
string,
((...args: any[]) => Promise<any>)[]
>();
const SESSION_DATA_PATH = path.join(tmpDir, 'affine-test');
const DOCUMENTS_PATH = path.join(tmpDir, 'affine-test-documents');
const electronModule = {
app: {
getPath: (name: string) => {
if (name === 'sessionData') {
return SESSION_DATA_PATH;
} else if (name === 'documents') {
return DOCUMENTS_PATH;
}
throw new Error('not implemented');
vi.doMock('../../main-rpc', () => ({
mainRPC: {
getPath: async () => appDataPath,
},
name: 'affine-test',
on: (name: string, callback: (...args: any[]) => any) => {
const handlers = registeredHandlers.get(name) || [];
handlers.push(callback);
registeredHandlers.set(name, handlers);
},
addListener: (...args: any[]) => {
// @ts-expect-error
electronModule.app.on(...args);
},
removeListener: () => {},
},
shell: {} as Partial<Electron.Shell>,
dialog: {} as Partial<Electron.Dialog>,
};
const runHandler = async (key: string) => {
await Promise.all(
(registeredHandlers.get(key) ?? []).map(handler => handler())
);
};
// dynamically import handlers so that we can inject local variables to mocks
vi.doMock('electron', () => {
return electronModule;
});
}));
const constructorStub = vi.fn();
const destroyStub = vi.fn();
destroyStub.mockReturnValue(Promise.resolve());
function existProcess() {
process.emit('beforeExit', 0);
}
vi.doMock('../secondary-db', () => {
return {
SecondaryWorkspaceSQLiteDB: class {
@ -77,7 +43,7 @@ beforeEach(() => {
});
afterEach(async () => {
await runHandler('before-quit');
existProcess();
// wait for the db to be closed on Windows
if (process.platform === 'win32') {
await setTimeout(200);
@ -110,7 +76,7 @@ test('db should be destroyed when app quits', async () => {
expect(db0.db).not.toBeNull();
expect(db1.db).not.toBeNull();
await runHandler('before-quit');
existProcess();
// wait the async `db.destroy()` to be called
await setTimeout(100);
@ -130,10 +96,9 @@ test('db should be removed in db$Map after destroyed', async () => {
test('if db has a secondary db path, we should also poll that', async () => {
const { ensureSQLiteDB } = await import('../ensure-db');
const { appContext } = await import('../../context');
const { storeWorkspaceMeta } = await import('../../workspace');
const workspaceId = v4();
await storeWorkspaceMeta(appContext, workspaceId, {
await storeWorkspaceMeta(workspaceId, {
secondaryDBPath: path.join(tmpDir, 'secondary.db'),
});
@ -145,7 +110,7 @@ test('if db has a secondary db path, we should also poll that', async () => {
expect(constructorStub).toBeCalledWith(path.join(tmpDir, 'secondary.db'), db);
// if secondary meta is changed
await storeWorkspaceMeta(appContext, workspaceId, {
await storeWorkspaceMeta(workspaceId, {
secondaryDBPath: path.join(tmpDir, 'secondary2.db'),
});
@ -155,7 +120,7 @@ test('if db has a secondary db path, we should also poll that', async () => {
expect(destroyStub).toBeCalledTimes(1);
// if secondary meta is changed (but another workspace)
await storeWorkspaceMeta(appContext, v4(), {
await storeWorkspaceMeta(v4(), {
secondaryDBPath: path.join(tmpDir, 'secondary3.db'),
});
await vi.advanceTimersByTimeAsync(1500);

View File

@ -5,15 +5,16 @@ import { v4 } from 'uuid';
import { afterEach, expect, test, vi } from 'vitest';
import * as Y from 'yjs';
import type { AppContext } from '../../context';
import { dbSubjects } from '../subjects';
const tmpDir = path.join(__dirname, 'tmp');
const appDataPath = path.join(tmpDir, 'app-data');
const testAppContext: AppContext = {
appDataPath: path.join(tmpDir, 'test-data'),
appName: 'test',
};
vi.doMock('../../main-rpc', () => ({
mainRPC: {
getPath: async () => appDataPath,
},
}));
afterEach(async () => {
await fs.remove(tmpDir);
@ -30,9 +31,9 @@ function getTestUpdates() {
test('can create new db file if not exists', async () => {
const { openWorkspaceDatabase } = await import('../workspace-db-adapter');
const workspaceId = v4();
const db = await openWorkspaceDatabase(testAppContext, workspaceId);
const db = await openWorkspaceDatabase(workspaceId);
const dbPath = path.join(
testAppContext.appDataPath,
appDataPath,
`workspaces/${workspaceId}`,
`storage.db`
);
@ -45,7 +46,7 @@ test('on applyUpdate (from self), will not trigger update', async () => {
const workspaceId = v4();
const onUpdate = vi.fn();
const db = await openWorkspaceDatabase(testAppContext, workspaceId);
const db = await openWorkspaceDatabase(workspaceId);
db.update$.subscribe(onUpdate);
db.applyUpdate(getTestUpdates(), 'self');
expect(onUpdate).not.toHaveBeenCalled();
@ -58,7 +59,7 @@ test('on applyUpdate (from renderer), will trigger update', async () => {
const onUpdate = vi.fn();
const onExternalUpdate = vi.fn();
const db = await openWorkspaceDatabase(testAppContext, workspaceId);
const db = await openWorkspaceDatabase(workspaceId);
db.update$.subscribe(onUpdate);
const sub = dbSubjects.externalUpdate.subscribe(onExternalUpdate);
db.applyUpdate(getTestUpdates(), 'renderer');
@ -73,7 +74,7 @@ test('on applyUpdate (from external), will trigger update & send external update
const onUpdate = vi.fn();
const onExternalUpdate = vi.fn();
const db = await openWorkspaceDatabase(testAppContext, workspaceId);
const db = await openWorkspaceDatabase(workspaceId);
db.update$.subscribe(onUpdate);
const sub = dbSubjects.externalUpdate.subscribe(onExternalUpdate);
db.applyUpdate(getTestUpdates(), 'external');
@ -86,7 +87,7 @@ test('on applyUpdate (from external), will trigger update & send external update
test('on destroy, check if resources have been released', async () => {
const { openWorkspaceDatabase } = await import('../workspace-db-adapter');
const workspaceId = v4();
const db = await openWorkspaceDatabase(testAppContext, workspaceId);
const db = await openWorkspaceDatabase(workspaceId);
const updateSub = {
complete: vi.fn(),
next: vi.fn(),

View File

@ -93,7 +93,7 @@ export abstract class BaseSQLiteAdapter {
}
// add a single update to SQLite
async addUpdateToSQLite(db: SqliteConnection, updates: Uint8Array[]) {
async addUpdateToSQLite(updates: Uint8Array[]) {
// batch write instead write per key stroke?
try {
if (!this.db) {
@ -101,7 +101,7 @@ export abstract class BaseSQLiteAdapter {
return;
}
const start = performance.now();
await db.insertUpdates(updates);
await this.db.insertUpdates(updates);
logger.debug(
`[SQLiteAdapter][${this.role}] addUpdateToSQLite`,
'length:',
@ -110,7 +110,7 @@ export abstract class BaseSQLiteAdapter {
'ms'
);
} catch (error) {
logger.error('addUpdateToSQLite', error);
logger.error('addUpdateToSQLite', this.path, error);
}
}
}

View File

@ -1,4 +1,3 @@
import { app } from 'electron';
import type { Subject } from 'rxjs';
import { Observable } from 'rxjs';
import {
@ -25,7 +24,6 @@ import {
tap,
} from 'rxjs/operators';
import { appContext } from '../context';
import { logger } from '../logger';
import { getWorkspaceMeta, workspaceSubjects } from '../workspace';
import { SecondaryWorkspaceSQLiteDB } from './secondary-db';
@ -36,7 +34,7 @@ import { openWorkspaceDatabase } from './workspace-db-adapter';
export const db$Map = new Map<string, Observable<WorkspaceSQLiteDB>>();
// use defer to prevent `app` is undefined while running tests
const beforeQuit$ = defer(() => fromEvent(app, 'before-quit'));
const beforeQuit$ = defer(() => fromEvent(process, 'beforeExit'));
// return a stream that emit a single event when the subject completes
function completed<T>(subject: Subject<T>) {
@ -55,7 +53,7 @@ function getWorkspaceDB$(id: string) {
if (!db$Map.has(id)) {
db$Map.set(
id,
from(openWorkspaceDatabase(appContext, id)).pipe(
from(openWorkspaceDatabase(id)).pipe(
tap({
next: db => {
logger.info(
@ -103,7 +101,7 @@ function getWorkspaceDB$(id: string) {
function startPollingSecondaryDB(db: WorkspaceSQLiteDB) {
return merge(
getWorkspaceMeta(appContext, db.workspaceId),
getWorkspaceMeta(db.workspaceId),
workspaceSubjects.meta.pipe(
map(({ meta }) => meta),
filter(meta => meta.id === db.workspaceId)

View File

@ -1,5 +1,5 @@
import { appContext } from '../context';
import type { MainEventListener, NamespaceHandlers } from '../type';
import { mainRPC } from '../main-rpc';
import type { MainEventRegister } from '../type';
import { ensureSQLiteDB } from './ensure-db';
import { dbSubjects } from './subjects';
@ -7,34 +7,34 @@ export * from './ensure-db';
export * from './subjects';
export const dbHandlers = {
getDocAsUpdates: async (_, id: string) => {
getDocAsUpdates: async (id: string) => {
const workspaceDB = await ensureSQLiteDB(id);
return workspaceDB.getDocAsUpdates();
},
applyDocUpdate: async (_, id: string, update: Uint8Array) => {
applyDocUpdate: async (id: string, update: Uint8Array) => {
const workspaceDB = await ensureSQLiteDB(id);
return workspaceDB.applyUpdate(update);
},
addBlob: async (_, workspaceId: string, key: string, data: Uint8Array) => {
addBlob: async (workspaceId: string, key: string, data: Uint8Array) => {
const workspaceDB = await ensureSQLiteDB(workspaceId);
return workspaceDB.addBlob(key, data);
},
getBlob: async (_, workspaceId: string, key: string) => {
getBlob: async (workspaceId: string, key: string) => {
const workspaceDB = await ensureSQLiteDB(workspaceId);
return workspaceDB.getBlob(key);
},
deleteBlob: async (_, workspaceId: string, key: string) => {
deleteBlob: async (workspaceId: string, key: string) => {
const workspaceDB = await ensureSQLiteDB(workspaceId);
return workspaceDB.deleteBlob(key);
},
getBlobKeys: async (_, workspaceId: string) => {
getBlobKeys: async (workspaceId: string) => {
const workspaceDB = await ensureSQLiteDB(workspaceId);
return workspaceDB.getBlobKeys();
},
getDefaultStorageLocation: async () => {
return appContext.appDataPath;
return await mainRPC.getPath('sessionData');
},
} satisfies NamespaceHandlers;
};
export const dbEvents = {
onExternalUpdate: (
@ -45,4 +45,4 @@ export const dbEvents = {
sub.unsubscribe();
};
},
} satisfies Record<string, MainEventListener>;
} satisfies Record<string, MainEventRegister>;

View File

@ -4,12 +4,11 @@ import type { SqliteConnection } from '@affine/native';
import { debounce } from 'lodash-es';
import * as Y from 'yjs';
import type { AppContext } from '../context';
import { logger } from '../logger';
import type { YOrigin } from '../type';
import { mergeUpdateWorker } from '../workers';
import { getWorkspaceMeta } from '../workspace';
import { BaseSQLiteAdapter } from './base-db-adapter';
import { mergeUpdate } from './merge-update';
import type { WorkspaceSQLiteDB } from './workspace-db-adapter';
const FLUSH_WAIT_TIME = 5000;
@ -35,8 +34,7 @@ export class SecondaryWorkspaceSQLiteDB extends BaseSQLiteAdapter {
}
override async destroy() {
const { db } = this;
await this.flushUpdateQueue(db);
await this.flushUpdateQueue();
this.unsubscribers.forEach(unsub => unsub());
this.yDoc.destroy();
await super.destroy();
@ -51,12 +49,12 @@ export class SecondaryWorkspaceSQLiteDB extends BaseSQLiteAdapter {
// and flush the queue in a future time
async addUpdateToUpdateQueue(db: SqliteConnection, update: Uint8Array) {
this.updateQueue.push(update);
await this.debouncedFlush(db);
await this.debouncedFlush();
}
async flushUpdateQueue(db = this.db) {
if (!db) {
return; // skip if db is not connected
async flushUpdateQueue() {
if (this.destroyed) {
return;
}
logger.debug(
'flushUpdateQueue',
@ -67,7 +65,7 @@ export class SecondaryWorkspaceSQLiteDB extends BaseSQLiteAdapter {
const updates = [...this.updateQueue];
this.updateQueue = [];
await this.run(async () => {
await this.addUpdateToSQLite(db, updates);
await this.addUpdateToSQLite(updates);
});
}
@ -198,7 +196,7 @@ export class SecondaryWorkspaceSQLiteDB extends BaseSQLiteAdapter {
return;
}
const merged = await mergeUpdateWorker(updates);
const merged = mergeUpdate(updates);
this.applyUpdate(merged, 'self');
logger.debug(
@ -211,10 +209,7 @@ export class SecondaryWorkspaceSQLiteDB extends BaseSQLiteAdapter {
}
}
export async function getSecondaryWorkspaceDBPath(
context: AppContext,
workspaceId: string
) {
const meta = await getWorkspaceMeta(context, workspaceId);
export async function getSecondaryWorkspaceDBPath(workspaceId: string) {
const meta = await getWorkspaceMeta(workspaceId);
return meta?.secondaryDBPath;
}

View File

@ -1,7 +1,5 @@
import { Subject } from 'rxjs';
export const dbSubjects = {
// emit workspace id when the db file is missing
fileMissing: new Subject<string>(),
externalUpdate: new Subject<{ workspaceId: string; update: Uint8Array }>(),
};

View File

@ -1,13 +1,11 @@
import type { SqliteConnection } from '@affine/native';
import { Subject } from 'rxjs';
import * as Y from 'yjs';
import type { AppContext } from '../context';
import { logger } from '../logger';
import type { YOrigin } from '../type';
import { mergeUpdateWorker } from '../workers';
import { getWorkspaceMeta } from '../workspace';
import { BaseSQLiteAdapter } from './base-db-adapter';
import { mergeUpdate } from './merge-update';
import { dbSubjects } from './subjects';
export class WorkspaceSQLiteDB extends BaseSQLiteAdapter {
@ -40,20 +38,20 @@ export class WorkspaceSQLiteDB extends BaseSQLiteAdapter {
if (!this.firstConnected) {
this.yDoc.on('update', async (update: Uint8Array, origin: YOrigin) => {
if (origin === 'renderer') {
await this.addUpdateToSQLite(db, [update]);
await this.addUpdateToSQLite([update]);
} else if (origin === 'external') {
dbSubjects.externalUpdate.next({
workspaceId: this.workspaceId,
update,
});
await this.addUpdateToSQLite(db, [update]);
await this.addUpdateToSQLite([update]);
logger.debug('external update', this.workspaceId);
}
});
}
const updates = await this.getUpdates();
const merged = await mergeUpdateWorker(updates.map(update => update.data));
const merged = mergeUpdate(updates.map(update => update.data));
// to initialize the yDoc, we need to apply all updates from the db
this.applyUpdate(merged, 'self');
@ -89,17 +87,14 @@ export class WorkspaceSQLiteDB extends BaseSQLiteAdapter {
await super.deleteBlob(key);
}
override async addUpdateToSQLite(db: SqliteConnection, data: Uint8Array[]) {
override async addUpdateToSQLite(data: Uint8Array[]) {
this.update$.next();
await super.addUpdateToSQLite(db, data);
await super.addUpdateToSQLite(data);
}
}
export async function openWorkspaceDatabase(
context: AppContext,
workspaceId: string
) {
const meta = await getWorkspaceMeta(context, workspaceId);
export async function openWorkspaceDatabase(workspaceId: string) {
const meta = await getWorkspaceMeta(workspaceId);
const db = new WorkspaceSQLiteDB(meta.mainDBPath, workspaceId);
await db.init();
logger.info(`openWorkspaceDatabase [${workspaceId}]`);

View File

@ -1,17 +1,16 @@
import path from 'node:path';
import { app } from 'electron';
import { dialog, shell } from 'electron';
import fs from 'fs-extra';
import { nanoid } from 'nanoid';
import { appContext } from '../context';
import { ensureSQLiteDB } from '../db/ensure-db';
import type { WorkspaceSQLiteDB } from '../db/workspace-db-adapter';
import { logger } from '../logger';
import { mainRPC } from '../main-rpc';
import {
getWorkspaceDBPath,
getWorkspaceMeta,
getWorkspacesBasePath,
listWorkspaces,
storeWorkspaceMeta,
} from '../workspace';
@ -20,11 +19,11 @@ import {
// we are using native dialogs because HTML dialogs do not give full file paths
export async function revealDBFile(workspaceId: string) {
const meta = await getWorkspaceMeta(appContext, workspaceId);
const meta = await getWorkspaceMeta(workspaceId);
if (!meta) {
return;
}
shell.showItemInFolder(meta.secondaryDBPath ?? meta.mainDBPath);
await mainRPC.showItemInFolder(meta.secondaryDBPath ?? meta.mainDBPath);
}
// provide a backdoor to set dialog path for testing in playwright
@ -88,7 +87,7 @@ export async function saveDBFileAs(
const db = await ensureSQLiteDB(workspaceId);
const ret =
getFakedResult() ??
(await dialog.showSaveDialog({
(await mainRPC.showSaveDialog({
properties: ['showOverwriteConfirmation'],
title: 'Save Workspace',
showsTagField: false,
@ -111,7 +110,7 @@ export async function saveDBFileAs(
await fs.copyFile(db.path, filePath);
logger.log('saved', filePath);
shell.showItemInFolder(filePath);
mainRPC.showItemInFolder(filePath);
return { filePath };
} catch (err) {
logger.error('saveDBFileAs', err);
@ -131,11 +130,11 @@ export async function selectDBFileLocation(): Promise<SelectDBFileLocationResult
try {
const ret =
getFakedResult() ??
(await dialog.showOpenDialog({
(await mainRPC.showOpenDialog({
properties: ['openDirectory'],
title: 'Set Workspace Storage Location',
buttonLabel: 'Select',
defaultPath: app.getPath('documents'),
defaultPath: await mainRPC.getPath('documents'),
message: "Select a location to store the workspace's database file",
}));
const dir = ret.filePaths?.[0];
@ -177,7 +176,7 @@ export async function loadDBFile(): Promise<LoadDBFileResult> {
try {
const ret =
getFakedResult() ??
(await dialog.showOpenDialog({
(await mainRPC.showOpenDialog({
properties: ['openFile'],
title: 'Load Workspace',
buttonLabel: 'Load',
@ -197,7 +196,7 @@ export async function loadDBFile(): Promise<LoadDBFileResult> {
}
// the imported file should not be in app data dir
if (filePath.startsWith(path.join(appContext.appDataPath, 'workspaces'))) {
if (filePath.startsWith(await getWorkspacesBasePath())) {
logger.warn('loadDBFile: db file in app data dir');
return { error: 'DB_FILE_PATH_INVALID' };
}
@ -216,14 +215,14 @@ export async function loadDBFile(): Promise<LoadDBFileResult> {
// copy the db file to a new workspace id
const workspaceId = nanoid(10);
const internalFilePath = getWorkspaceDBPath(appContext, workspaceId);
const internalFilePath = await getWorkspaceDBPath(workspaceId);
await fs.ensureDir(path.join(appContext.appDataPath, 'workspaces'));
await fs.ensureDir(await getWorkspacesBasePath());
await fs.copy(filePath, internalFilePath);
logger.info(`loadDBFile, copy: ${filePath} -> ${internalFilePath}`);
await storeWorkspaceMeta(appContext, workspaceId, {
await storeWorkspaceMeta(workspaceId, {
id: workspaceId,
mainDBPath: internalFilePath,
secondaryDBPath: filePath,
@ -260,13 +259,12 @@ export async function moveDBFile(
let db: WorkspaceSQLiteDB | null = null;
try {
db = await ensureSQLiteDB(workspaceId);
const meta = await getWorkspaceMeta(appContext, workspaceId);
const meta = await getWorkspaceMeta(workspaceId);
const oldDir = meta.secondaryDBPath
? path.dirname(meta.secondaryDBPath)
: null;
const defaultDir = oldDir ?? app.getPath('documents');
const defaultDir = oldDir ?? (await mainRPC.getPath('documents'));
const newName = getDefaultDBFileName(db.getWorkspaceName(), workspaceId);
@ -274,7 +272,7 @@ export async function moveDBFile(
dbFileDir ??
(
getFakedResult() ??
(await dialog.showOpenDialog({
(await mainRPC.showOpenDialog({
properties: ['openDirectory'],
title: 'Move Workspace Storage',
buttonLabel: 'Move',
@ -320,7 +318,7 @@ export async function moveDBFile(
}
// update meta
await storeWorkspaceMeta(appContext, workspaceId, {
await storeWorkspaceMeta(workspaceId, {
secondaryDBPath: newFilePath,
});
@ -337,7 +335,7 @@ export async function moveDBFile(
}
async function dbFileAlreadyLoaded(path: string) {
const meta = await listWorkspaces(appContext);
const meta = await listWorkspaces();
const paths = meta.map(m => m[1].secondaryDBPath);
return paths.includes(path);
}

View File

@ -1,4 +1,3 @@
import type { NamespaceHandlers } from '../type';
import {
loadDBFile,
moveDBFile,
@ -9,25 +8,24 @@ import {
} from './dialog';
export const dialogHandlers = {
revealDBFile: async (_, workspaceId: string) => {
revealDBFile: async (workspaceId: string) => {
return revealDBFile(workspaceId);
},
loadDBFile: async () => {
return loadDBFile();
},
saveDBFileAs: async (_, workspaceId: string) => {
saveDBFileAs: async (workspaceId: string) => {
return saveDBFileAs(workspaceId);
},
moveDBFile: (_, workspaceId: string, dbFileLocation?: string) => {
moveDBFile: (workspaceId: string, dbFileLocation?: string) => {
return moveDBFile(workspaceId, dbFileLocation);
},
selectDBFileLocation: async () => {
return selectDBFileLocation();
},
setFakeDialogResult: async (
_,
result: Parameters<typeof setFakeDialogResult>[0]
) => {
return setFakeDialogResult(result);
},
} satisfies NamespaceHandlers;
};

View File

@ -0,0 +1,33 @@
import { dbEvents, dbHandlers } from './db';
import { dialogHandlers } from './dialog';
import { workspaceEvents, workspaceHandlers } from './workspace';
export const handlers = {
db: dbHandlers,
workspace: workspaceHandlers,
dialog: dialogHandlers,
};
export const events = {
db: dbEvents,
workspace: workspaceEvents,
};
export const getExposedMeta = () => {
const handlersMeta = Object.entries(handlers).map(
([namespace, namespaceHandlers]) => {
return [namespace, Object.keys(namespaceHandlers)] as [string, string[]];
}
);
const eventsMeta = Object.entries(events).map(
([namespace, namespaceHandlers]) => {
return [namespace, Object.keys(namespaceHandlers)] as [string, string[]];
}
);
return {
handlers: handlersMeta,
events: eventsMeta,
};
};

View File

@ -0,0 +1,86 @@
import type { EventBasedChannel } from 'async-call-rpc';
import { AsyncCall } from 'async-call-rpc';
import { events, handlers } from './exposed';
import { logger } from './logger';
const createMessagePortMainChannel = (
connection: Electron.MessagePortMain
): EventBasedChannel => {
return {
on(listener) {
const f = (e: Electron.MessageEvent) => {
listener(e.data);
};
connection.on('message', f);
// MUST start the connection to receive messages
connection.start();
return () => {
connection.off('message', f);
};
},
send(data) {
connection.postMessage(data);
},
};
};
function setupRendererConnection(rendererPort: Electron.MessagePortMain) {
const flattenedHandlers = Object.entries(handlers).flatMap(
([namespace, namespaceHandlers]) => {
return Object.entries(namespaceHandlers).map(([name, handler]) => {
const handlerWithLog = async (...args: any[]) => {
try {
const start = performance.now();
const result = await handler(...args);
logger.info(
'[async-api]',
`${namespace}.${name}`,
args.filter(
arg => typeof arg !== 'function' && typeof arg !== 'object'
),
'-',
(performance.now() - start).toFixed(2),
'ms'
);
return result;
} catch (error) {
logger.error('[async-api]', `${namespace}.${name}`, error);
}
};
return [`${namespace}:${name}`, handlerWithLog];
});
}
);
const rpc = AsyncCall<PeersAPIs.RendererToHelper>(
Object.fromEntries(flattenedHandlers),
{
channel: createMessagePortMainChannel(rendererPort),
log: false,
}
);
for (const [namespace, namespaceEvents] of Object.entries(events)) {
for (const [key, eventRegister] of Object.entries(namespaceEvents)) {
const subscription = eventRegister((...args: any[]) => {
const chan = `${namespace}:${key}`;
rpc.postEvent(chan, ...args);
});
process.on('exit', () => {
subscription();
});
}
}
}
function main() {
process.parentPort.on('message', e => {
if (e.data.channel === 'renderer-connect' && e.ports.length === 1) {
const rendererPort = e.ports[0];
setupRendererConnection(rendererPort);
logger.info('[helper] renderer connected');
}
});
}
main();

View File

@ -0,0 +1,3 @@
import log from 'electron-log';
export const logger = log.scope('helper');

View File

@ -0,0 +1,33 @@
import { AsyncCall, type EventBasedChannel } from 'async-call-rpc';
import { getExposedMeta } from './exposed';
function createMessagePortMainChannel(
connection: Electron.ParentPort
): EventBasedChannel {
return {
on(listener) {
const f = (e: Electron.MessageEvent) => {
listener(e.data);
};
connection.on('message', f);
return () => {
connection.off('message', f);
};
},
send(data) {
connection.postMessage(data);
},
};
}
const helperToMainServer: PeersAPIs.HelperToMain = {
getMeta: () => getExposedMeta(),
};
export const mainRPC = AsyncCall<PeersAPIs.MainToHelper>(helperToMainServer, {
strict: {
unknownMessage: false,
},
channel: createMessagePortMainChannel(process.parentPort),
});

View File

@ -0,0 +1,9 @@
export interface WorkspaceMeta {
id: string;
mainDBPath: string;
secondaryDBPath?: string; // assume there will be only one
}
export type YOrigin = 'self' | 'external' | 'upstream' | 'renderer';
export type MainEventRegister = (...args: any[]) => () => void;

View File

@ -4,18 +4,8 @@ import fs from 'fs-extra';
import { v4 } from 'uuid';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { AppContext } from '../../context';
const tmpDir = path.join(__dirname, 'tmp');
const testAppContext: AppContext = {
appDataPath: path.join(tmpDir, 'test-data'),
appName: 'test',
};
vi.doMock('../../context', () => ({
appContext: testAppContext,
}));
const appDataPath = path.join(tmpDir, 'app-data');
vi.doMock('../../db/ensure-db', () => ({
ensureSQLiteDB: async () => ({
@ -23,6 +13,12 @@ vi.doMock('../../db/ensure-db', () => ({
}),
}));
vi.doMock('../../main-rpc', () => ({
mainRPC: {
getPath: async () => appDataPath,
},
}));
afterEach(async () => {
await fs.remove(tmpDir);
});
@ -31,30 +27,22 @@ describe('list workspaces', () => {
test('listWorkspaces (valid)', async () => {
const { listWorkspaces } = await import('../handlers');
const workspaceId = v4();
const workspacePath = path.join(
testAppContext.appDataPath,
'workspaces',
workspaceId
);
const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
const meta = {
id: workspaceId,
};
await fs.ensureDir(workspacePath);
await fs.writeJSON(path.join(workspacePath, 'meta.json'), meta);
const workspaces = await listWorkspaces(testAppContext);
const workspaces = await listWorkspaces();
expect(workspaces).toEqual([[workspaceId, meta]]);
});
test('listWorkspaces (without meta json file)', async () => {
const { listWorkspaces } = await import('../handlers');
const workspaceId = v4();
const workspacePath = path.join(
testAppContext.appDataPath,
'workspaces',
workspaceId
);
const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
await fs.ensureDir(workspacePath);
const workspaces = await listWorkspaces(testAppContext);
const workspaces = await listWorkspaces();
expect(workspaces).toEqual([
[
workspaceId,
@ -69,18 +57,14 @@ describe('delete workspace', () => {
test('deleteWorkspace', async () => {
const { deleteWorkspace } = await import('../handlers');
const workspaceId = v4();
const workspacePath = path.join(
testAppContext.appDataPath,
'workspaces',
workspaceId
);
const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
await fs.ensureDir(workspacePath);
await deleteWorkspace(testAppContext, workspaceId);
await deleteWorkspace(workspaceId);
expect(await fs.pathExists(workspacePath)).toBe(false);
// removed workspace will be moved to delete-workspaces
// removed workspace will be moved to deleted-workspaces
expect(
await fs.pathExists(
path.join(testAppContext.appDataPath, 'delete-workspaces', workspaceId)
path.join(appDataPath, 'deleted-workspaces', workspaceId)
)
).toBe(true);
});
@ -90,29 +74,21 @@ describe('getWorkspaceMeta', () => {
test('can get meta', async () => {
const { getWorkspaceMeta } = await import('../handlers');
const workspaceId = v4();
const workspacePath = path.join(
testAppContext.appDataPath,
'workspaces',
workspaceId
);
const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
const meta = {
id: workspaceId,
};
await fs.ensureDir(workspacePath);
await fs.writeJSON(path.join(workspacePath, 'meta.json'), meta);
expect(await getWorkspaceMeta(testAppContext, workspaceId)).toEqual(meta);
expect(await getWorkspaceMeta(workspaceId)).toEqual(meta);
});
test('can create meta if not exists', async () => {
const { getWorkspaceMeta } = await import('../handlers');
const workspaceId = v4();
const workspacePath = path.join(
testAppContext.appDataPath,
'workspaces',
workspaceId
);
const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
await fs.ensureDir(workspacePath);
expect(await getWorkspaceMeta(testAppContext, workspaceId)).toEqual({
expect(await getWorkspaceMeta(workspaceId)).toEqual({
id: workspaceId,
mainDBPath: path.join(workspacePath, 'storage.db'),
});
@ -124,18 +100,14 @@ describe('getWorkspaceMeta', () => {
test('can migrate meta if db file is a link', async () => {
const { getWorkspaceMeta } = await import('../handlers');
const workspaceId = v4();
const workspacePath = path.join(
testAppContext.appDataPath,
'workspaces',
workspaceId
);
const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
await fs.ensureDir(workspacePath);
const sourcePath = path.join(tmpDir, 'source.db');
await fs.writeFile(sourcePath, 'test');
await fs.ensureSymlink(sourcePath, path.join(workspacePath, 'storage.db'));
expect(await getWorkspaceMeta(testAppContext, workspaceId)).toEqual({
expect(await getWorkspaceMeta(workspaceId)).toEqual({
id: workspaceId,
mainDBPath: path.join(workspacePath, 'storage.db'),
secondaryDBPath: sourcePath,
@ -150,21 +122,17 @@ describe('getWorkspaceMeta', () => {
test('storeWorkspaceMeta', async () => {
const { storeWorkspaceMeta } = await import('../handlers');
const workspaceId = v4();
const workspacePath = path.join(
testAppContext.appDataPath,
'workspaces',
workspaceId
);
const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
await fs.ensureDir(workspacePath);
const meta = {
id: workspaceId,
mainDBPath: path.join(workspacePath, 'storage.db'),
};
await storeWorkspaceMeta(testAppContext, workspaceId, meta);
await storeWorkspaceMeta(workspaceId, meta);
expect(await fs.readJSON(path.join(workspacePath, 'meta.json'))).toEqual(
meta
);
await storeWorkspaceMeta(testAppContext, workspaceId, {
await storeWorkspaceMeta(workspaceId, {
secondaryDBPath: path.join(tmpDir, 'test.db'),
});
expect(await fs.readJSON(path.join(workspacePath, 'meta.json'))).toEqual({

View File

@ -2,26 +2,38 @@ import path from 'node:path';
import fs from 'fs-extra';
import { type AppContext } from '../context';
import { ensureSQLiteDB } from '../db/ensure-db';
import { logger } from '../logger';
import { mainRPC } from '../main-rpc';
import type { WorkspaceMeta } from '../type';
import { workspaceSubjects } from './subjects';
export async function listWorkspaces(
context: AppContext
): Promise<[workspaceId: string, meta: WorkspaceMeta][]> {
const basePath = getWorkspacesBasePath(context);
let _appDataPath = '';
async function getAppDataPath() {
if (_appDataPath) {
return _appDataPath;
}
_appDataPath = await mainRPC.getPath('sessionData');
return _appDataPath;
}
export async function listWorkspaces(): Promise<
[workspaceId: string, meta: WorkspaceMeta][]
> {
const basePath = await getWorkspacesBasePath();
try {
await fs.ensureDir(basePath);
const dirs = await fs.readdir(basePath, {
const dirs = (
await fs.readdir(basePath, {
withFileTypes: true,
});
})
).filter(d => d.isDirectory());
const metaList = (
await Promise.all(
dirs.map(async dir => {
// ? shall we put all meta in a single file instead of one file per workspace?
return await getWorkspaceMeta(context, dir.name);
return await getWorkspaceMeta(dir.name);
})
)
).filter((w): w is WorkspaceMeta => !!w);
@ -32,13 +44,9 @@ export async function listWorkspaces(
}
}
export async function deleteWorkspace(context: AppContext, id: string) {
const basePath = getWorkspaceBasePath(context, id);
const movedPath = path.join(
context.appDataPath,
'delete-workspaces',
`${id}`
);
export async function deleteWorkspace(id: string) {
const basePath = await getWorkspaceBasePath(id);
const movedPath = path.join(await getDeletedWorkspacesBasePath(), `${id}`);
try {
const db = await ensureSQLiteDB(id);
await db.destroy();
@ -50,22 +58,24 @@ export async function deleteWorkspace(context: AppContext, id: string) {
}
}
export function getWorkspacesBasePath(context: AppContext) {
return path.join(context.appDataPath, 'workspaces');
export async function getWorkspacesBasePath() {
return path.join(await getAppDataPath(), 'workspaces');
}
export function getWorkspaceBasePath(context: AppContext, workspaceId: string) {
return path.join(context.appDataPath, 'workspaces', workspaceId);
export async function getWorkspaceBasePath(workspaceId: string) {
return path.join(await getAppDataPath(), 'workspaces', workspaceId);
}
export function getWorkspaceDBPath(context: AppContext, workspaceId: string) {
const basePath = getWorkspaceBasePath(context, workspaceId);
return path.join(basePath, 'storage.db');
async function getDeletedWorkspacesBasePath() {
return path.join(await getAppDataPath(), 'deleted-workspaces');
}
export function getWorkspaceMetaPath(context: AppContext, workspaceId: string) {
const basePath = getWorkspaceBasePath(context, workspaceId);
return path.join(basePath, 'meta.json');
export async function getWorkspaceDBPath(workspaceId: string) {
return path.join(await getWorkspaceBasePath(workspaceId), 'storage.db');
}
export async function getWorkspaceMetaPath(workspaceId: string) {
return path.join(await getWorkspaceBasePath(workspaceId), 'meta.json');
}
/**
@ -73,16 +83,15 @@ export function getWorkspaceMetaPath(context: AppContext, workspaceId: string) {
* This function will also migrate the workspace if needed
*/
export async function getWorkspaceMeta(
context: AppContext,
workspaceId: string
): Promise<WorkspaceMeta> {
try {
const basePath = getWorkspaceBasePath(context, workspaceId);
const metaPath = getWorkspaceMetaPath(context, workspaceId);
const basePath = await getWorkspaceBasePath(workspaceId);
const metaPath = await getWorkspaceMetaPath(workspaceId);
if (!(await fs.exists(metaPath))) {
// since not meta is found, we will migrate symlinked db file if needed
await fs.ensureDir(basePath);
const dbPath = getWorkspaceDBPath(context, workspaceId);
const dbPath = await getWorkspaceDBPath(workspaceId);
// todo: remove this after migration (in stable version)
const realDBPath = (await fs.exists(dbPath))
@ -111,15 +120,14 @@ export async function getWorkspaceMeta(
}
export async function storeWorkspaceMeta(
context: AppContext,
workspaceId: string,
meta: Partial<WorkspaceMeta>
) {
try {
const basePath = getWorkspaceBasePath(context, workspaceId);
const basePath = await getWorkspaceBasePath(workspaceId);
await fs.ensureDir(basePath);
const metaPath = path.join(basePath, 'meta.json');
const currentMeta = await getWorkspaceMeta(context, workspaceId);
const currentMeta = await getWorkspaceMeta(workspaceId);
const newMeta = {
...currentMeta,
...meta,

View File

@ -1,9 +1,4 @@
import { appContext } from '../context';
import type {
MainEventListener,
NamespaceHandlers,
WorkspaceMeta,
} from '../type';
import type { MainEventRegister, WorkspaceMeta } from '../type';
import { deleteWorkspace, getWorkspaceMeta, listWorkspaces } from './handlers';
import { workspaceSubjects } from './subjects';
@ -19,12 +14,12 @@ export const workspaceEvents = {
sub.unsubscribe();
};
},
} satisfies Record<string, MainEventListener>;
} satisfies Record<string, MainEventRegister>;
export const workspaceHandlers = {
list: async () => listWorkspaces(appContext),
delete: async (_, id: string) => deleteWorkspace(appContext, id),
getMeta: async (_, id: string) => {
return getWorkspaceMeta(appContext, id);
list: async () => listWorkspaces(),
delete: async (id: string) => deleteWorkspace(id),
getMeta: async (id: string) => {
return getWorkspaceMeta(id);
},
} satisfies NamespaceHandlers;
};

View File

@ -0,0 +1,173 @@
import assert from 'node:assert';
import path from 'node:path';
import { setTimeout } from 'node:timers/promises';
import fs from 'fs-extra';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import type { MainIPCHandlerMap } from '../exposed';
const registeredHandlers = new Map<
string,
((...args: any[]) => Promise<any>)[]
>();
type WithoutFirstParameter<T> = T extends (_: any, ...args: infer P) => infer R
? (...args: P) => R
: T;
// common mock dispatcher for ipcMain.handle AND app.on
// alternatively, we can use single parameter for T & F, eg, dispatch('workspace:list'),
// however this is too hard to be typed correctly
async function dispatch<
T extends keyof MainIPCHandlerMap,
F extends keyof MainIPCHandlerMap[T]
>(
namespace: T,
functionName: F,
...args: Parameters<WithoutFirstParameter<MainIPCHandlerMap[T][F]>>
): // @ts-expect-error
ReturnType<MainIPCHandlerMap[T][F]> {
// @ts-expect-error
const handlers = registeredHandlers.get(namespace + ':' + functionName);
assert(handlers);
// we only care about the first handler here
return await handlers[0](null, ...args);
}
const SESSION_DATA_PATH = path.join(__dirname, './tmp', 'affine-test');
const DOCUMENTS_PATH = path.join(__dirname, './tmp', 'affine-test-documents');
const browserWindow = {
isDestroyed: () => {
return false;
},
setWindowButtonVisibility: (_v: boolean) => {
// will be stubbed later
},
webContents: {
send: (_type: string, ..._args: any[]) => {
// will be stubbed later
},
},
};
const ipcMain = {
handle: (key: string, callback: (...args: any[]) => Promise<any>) => {
const handlers = registeredHandlers.get(key) || [];
handlers.push(callback);
registeredHandlers.set(key, handlers);
},
setMaxListeners: (_n: number) => {
// noop
},
};
const nativeTheme = {
themeSource: 'light',
};
const electronModule = {
app: {
getPath: (name: string) => {
if (name === 'sessionData') {
return SESSION_DATA_PATH;
} else if (name === 'documents') {
return DOCUMENTS_PATH;
}
throw new Error('not implemented');
},
name: 'affine-test',
on: (name: string, callback: (...args: any[]) => any) => {
const handlers = registeredHandlers.get(name) || [];
handlers.push(callback);
registeredHandlers.set(name, handlers);
},
addListener: (...args: any[]) => {
// @ts-expect-error
electronModule.app.on(...args);
},
removeListener: () => {},
},
BrowserWindow: {
getAllWindows: () => {
return [browserWindow];
},
},
nativeTheme: nativeTheme,
ipcMain,
shell: {} as Partial<Electron.Shell>,
dialog: {} as Partial<Electron.Dialog>,
};
// dynamically import handlers so that we can inject local variables to mocks
vi.doMock('electron', () => {
return electronModule;
});
beforeEach(async () => {
const { registerHandlers } = await import('../handlers');
registerHandlers();
// should also register events
const { registerEvents } = await import('../events');
registerEvents();
await fs.mkdirp(SESSION_DATA_PATH);
registeredHandlers.get('ready')?.forEach(fn => fn());
});
afterEach(async () => {
// reset registered handlers
registeredHandlers.get('before-quit')?.forEach(fn => fn());
// wait for the db to be closed on Windows
if (process.platform === 'win32') {
await setTimeout(200);
}
await fs.remove(SESSION_DATA_PATH);
});
describe('UI handlers', () => {
test('theme-change', async () => {
await dispatch('ui', 'handleThemeChange', 'dark');
expect(nativeTheme.themeSource).toBe('dark');
await dispatch('ui', 'handleThemeChange', 'light');
expect(nativeTheme.themeSource).toBe('light');
});
test('sidebar-visibility-change (macOS)', async () => {
vi.stubGlobal('process', { platform: 'darwin' });
const setWindowButtonVisibility = vi.fn();
browserWindow.setWindowButtonVisibility = setWindowButtonVisibility;
await dispatch('ui', 'handleSidebarVisibilityChange', true);
expect(setWindowButtonVisibility).toBeCalledWith(true);
await dispatch('ui', 'handleSidebarVisibilityChange', false);
expect(setWindowButtonVisibility).toBeCalledWith(false);
vi.unstubAllGlobals();
});
test('sidebar-visibility-change (non-macOS)', async () => {
vi.stubGlobal('process', { platform: 'linux' });
const setWindowButtonVisibility = vi.fn();
browserWindow.setWindowButtonVisibility = setWindowButtonVisibility;
await dispatch('ui', 'handleSidebarVisibilityChange', true);
expect(setWindowButtonVisibility).not.toBeCalled();
vi.unstubAllGlobals();
});
});
describe('applicationMenu', () => {
// test some basic IPC events
test('applicationMenu event', async () => {
const { applicationMenuSubjects } = await import('../application-menu');
const sendStub = vi.fn();
browserWindow.webContents.send = sendStub;
applicationMenuSubjects.newPageAction.next();
expect(sendStub).toHaveBeenCalledWith(
'applicationMenu:onNewPageAction',
undefined
);
browserWindow.webContents.send = () => {};
});
});

View File

@ -1,4 +1,4 @@
import type { MainEventListener } from '../type';
import type { MainEventRegister } from '../type';
import { applicationMenuSubjects } from './subject';
export * from './create';
@ -17,4 +17,4 @@ export const applicationMenuEvents = {
sub.unsubscribe();
};
},
} satisfies Record<string, MainEventListener>;
} satisfies Record<string, MainEventRegister>;

View File

@ -1,16 +1,12 @@
import { app, BrowserWindow } from 'electron';
import { applicationMenuEvents } from './application-menu';
import { dbEvents } from './db';
import { logger } from './logger';
import { updaterEvents } from './updater/event';
import { workspaceEvents } from './workspace';
export const allEvents = {
applicationMenu: applicationMenuEvents,
db: dbEvents,
updater: updaterEvents,
workspace: workspaceEvents,
};
function getActiveWindows() {

View File

@ -9,19 +9,13 @@ export { events, handlers };
export const getExposedMeta = () => {
const handlersMeta = Object.entries(handlers).map(
([namespace, namespaceHandlers]) => {
return [
namespace,
Object.keys(namespaceHandlers).map(handlerName => handlerName),
];
return [namespace, Object.keys(namespaceHandlers)];
}
);
const eventsMeta = Object.entries(events).map(
([namespace, namespaceHandlers]) => {
return [
namespace,
Object.keys(namespaceHandlers).map(handlerName => handlerName),
];
return [namespace, Object.keys(namespaceHandlers)];
}
);
@ -32,5 +26,4 @@ export const getExposedMeta = () => {
};
export type MainIPCHandlerMap = typeof handlers;
export type MainIPCEventMap = typeof events;

View File

@ -1,22 +1,16 @@
import type {
DBHandlerManager,
DebugHandlerManager,
DialogHandlerManager,
ExportHandlerManager,
UIHandlerManager,
UnwrapManagerHandlerToServerSide,
UpdaterHandlerManager,
WorkspaceHandlerManager,
} from '@toeverything/infra';
import { ipcMain } from 'electron';
import { dbHandlers } from './db';
import { dialogHandlers } from './dialog';
import { exportHandlers } from './export';
import { getLogFilePath, logger, revealLogFile } from './logger';
import { uiHandlers } from './ui';
import { updaterHandlers } from './updater';
import { workspaceHandlers } from './workspace';
export const debugHandlers = {
revealLogFile: async () => {
@ -28,18 +22,10 @@ export const debugHandlers = {
};
type AllHandlers = {
db: UnwrapManagerHandlerToServerSide<
Electron.IpcMainInvokeEvent,
DBHandlerManager
>;
debug: UnwrapManagerHandlerToServerSide<
Electron.IpcMainInvokeEvent,
DebugHandlerManager
>;
dialog: UnwrapManagerHandlerToServerSide<
Electron.IpcMainInvokeEvent,
DialogHandlerManager
>;
export: UnwrapManagerHandlerToServerSide<
Electron.IpcMainInvokeEvent,
ExportHandlerManager
@ -52,21 +38,14 @@ type AllHandlers = {
Electron.IpcMainInvokeEvent,
UpdaterHandlerManager
>;
workspace: UnwrapManagerHandlerToServerSide<
Electron.IpcMainInvokeEvent,
WorkspaceHandlerManager
>;
};
// Note: all of these handlers will be the single-source-of-truth for the apis exposed to the renderer process
export const allHandlers = {
db: dbHandlers,
debug: debugHandlers,
dialog: dialogHandlers,
ui: uiHandlers,
export: exportHandlers,
updater: updaterHandlers,
workspace: workspaceHandlers,
} satisfies AllHandlers;
export const registerHandlers = () => {
@ -78,6 +57,7 @@ export const registerHandlers = () => {
ipcMain.handle(chan, async (e, ...args) => {
const start = performance.now();
try {
// @ts-expect-error - TODO: fix this
const result = await handler(e, ...args);
logger.info(
'[ipc-api]',

View File

@ -0,0 +1,111 @@
import path from 'node:path';
import { type _AsyncVersionOf, AsyncCall } from 'async-call-rpc';
import {
app,
dialog,
MessageChannelMain,
shell,
type UtilityProcess,
utilityProcess,
type WebContents,
} from 'electron';
import { logger } from './logger';
import { MessageEventChannel } from './utils';
const HELPER_PROCESS_PATH = path.join(__dirname, './helper.js');
function pickAndBind<T extends object, U extends keyof T>(
obj: T,
keys: U[]
): { [K in U]: T[K] } {
return keys.reduce((acc, key) => {
const prop = obj[key];
acc[key] =
typeof prop === 'function'
? // @ts-expect-error - a hack to bind the function
prop.bind(obj)
: prop;
return acc;
}, {} as any);
}
class HelperProcessManager {
ready: Promise<void>;
#process: UtilityProcess;
// a rpc server for the main process -> helper process
rpc?: _AsyncVersionOf<PeersAPIs.HelperToMain>;
static instance = new HelperProcessManager();
private constructor() {
const helperProcess = utilityProcess.fork(HELPER_PROCESS_PATH);
this.#process = helperProcess;
this.ready = new Promise((resolve, reject) => {
helperProcess.once('spawn', () => {
try {
this.#connectMain();
resolve();
} catch (err) {
logger.error('[helper] connectMain error', err);
reject(err);
}
});
});
app.on('before-quit', () => {
this.#process.kill();
});
}
// bridge renderer <-> helper process
connectRenderer(renderer: WebContents) {
// connect to the helper process
const { port1: helperPort, port2: rendererPort } = new MessageChannelMain();
this.#process.postMessage({ channel: 'renderer-connect' }, [helperPort]);
renderer.postMessage('helper-connection', null, [rendererPort]);
return () => {
helperPort.close();
rendererPort.close();
};
}
// bridge main <-> helper process
// also set up the RPC to the helper process
#connectMain() {
const dialogMethods = pickAndBind(dialog, [
'showOpenDialog',
'showSaveDialog',
]);
const shellMethods = pickAndBind(shell, [
'openExternal',
'showItemInFolder',
]);
const appMethods = pickAndBind(app, ['getPath']);
const mainToHelperServer: PeersAPIs.MainToHelper = {
...dialogMethods,
...shellMethods,
...appMethods,
};
const server = AsyncCall<PeersAPIs.HelperToMain>(mainToHelperServer, {
strict: {
// the channel is shared for other purposes as well so that we do not want to
// restrict to only JSONRPC messages
unknownMessage: false,
},
channel: new MessageEventChannel(this.#process),
});
this.rpc = server;
}
}
export async function ensureHelperProcess() {
const helperProcessManager = HelperProcessManager.instance;
await helperProcessManager.ready;
return helperProcessManager;
}

View File

@ -5,6 +5,7 @@ import { app } from 'electron';
import { createApplicationMenu } from './application-menu/create';
import { registerEvents } from './events';
import { registerHandlers } from './handlers';
import { ensureHelperProcess } from './helper-process';
import { logger } from './logger';
import { restoreOrCreateWindow } from './main-window';
import { registerPlugin } from './plugin';
@ -62,7 +63,9 @@ app
.then(registerHandlers)
.then(registerEvents)
.then(registerPlugin)
.then(ensureHelperProcess)
.then(restoreOrCreateWindow)
.then(createApplicationMenu)
.then()
.then(registerUpdater)
.catch(e => console.error('Failed create window:', e));

View File

@ -1,7 +1,8 @@
import { shell } from 'electron';
import log from 'electron-log';
export const logger = log;
export const logger = log.scope('main');
log.initialize();
export function getLogFilePath() {
return log.transports.file.getFile().path;

View File

@ -1,8 +1,11 @@
import assert from 'node:assert';
import { BrowserWindow, nativeTheme } from 'electron';
import electronWindowState from 'electron-window-state';
import { join } from 'path';
import { getExposedMeta } from './exposed';
import { ensureHelperProcess } from './helper-process';
import { logger } from './logger';
import { isMacOS, isWindows } from './utils';
@ -18,7 +21,12 @@ async function createWindow() {
defaultHeight: 800,
});
const exposedMeta = getExposedMeta();
const helperProcessManager = await ensureHelperProcess();
const helperExposedMeta = await helperProcessManager.rpc?.getMeta();
assert(helperExposedMeta, 'helperExposedMeta should be defined');
const mainExposedMeta = getExposedMeta();
const browserWindow = new BrowserWindow({
titleBarStyle: isMacOS()
@ -42,9 +50,12 @@ async function createWindow() {
sandbox: false,
webviewTag: false, // The webview tag is not recommended. Consider alternatives like iframe or Electron's BrowserView. https://www.electronjs.org/docs/latest/api/webview-tag#warning
spellcheck: false, // FIXME: enable?
preload: join(__dirname, '../preload/index.js'),
preload: join(__dirname, './preload.js'),
// serialize exposed meta that to be used in preload
additionalArguments: [`--exposed-meta=` + JSON.stringify(exposedMeta)],
additionalArguments: [
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
`--helper-exposed-meta=` + JSON.stringify(helperExposedMeta),
],
},
});
@ -52,6 +63,8 @@ async function createWindow() {
mainWindowState.manage(browserWindow);
let helperConnectionUnsub: (() => void) | undefined;
/**
* If you install `show: true` then it can cause issues when trying to close the window.
* Use `show: false` and listener events `ready-to-show` to fix these issues.
@ -65,6 +78,9 @@ async function createWindow() {
} else {
browserWindow.show();
}
helperConnectionUnsub = helperProcessManager.connectRenderer(
browserWindow.webContents
);
logger.info('main window is ready to show');
@ -78,6 +94,7 @@ async function createWindow() {
browserWindow.on('close', e => {
e.preventDefault();
browserWindow.destroy();
helperConnectionUnsub?.();
// TODO: gracefully close the app, for example, ask user to save unsaved changes
});

View File

@ -1,10 +1,10 @@
import { join } from 'node:path';
import { join, resolve } from 'node:path';
import { Worker } from 'node:worker_threads';
import { AsyncCall } from 'async-call-rpc';
import { ipcMain } from 'electron';
import { ThreadWorkerChannel } from './utils';
import { MessageEventChannel } from './utils';
declare global {
// fixme(himself65):
@ -20,17 +20,17 @@ export async function registerPlugin() {
>(
{},
{
channel: new ThreadWorkerChannel(new Worker(pluginWorkerPath)),
channel: new MessageEventChannel(new Worker(pluginWorkerPath)),
}
);
globalThis.asyncCall = asyncCall;
await import('@toeverything/plugin-infra/manager').then(
({ rootStore, affinePluginsAtom }) => {
const bookmarkPluginPath = join(
process.env.PLUGIN_DIR ?? '../../plugins',
process.env.PLUGIN_DIR ?? resolve(__dirname, './plugins'),
'./bookmark-block/index.mjs'
);
import(bookmarkPluginPath);
import('file://' + bookmarkPluginPath);
let dispose: () => void = () => {
// noop
};

View File

@ -16,7 +16,7 @@ protocol.registerSchemesAsPrivileged([
function toAbsolutePath(url: string) {
let realpath = decodeURIComponent(url);
const webStaticDir = join(__dirname, '../../../resources/web-static');
const webStaticDir = join(__dirname, '../resources/web-static');
if (url.startsWith('./')) {
// if is a file type, load the file in resources
if (url.split('/').at(-1)?.includes('.')) {
@ -34,6 +34,7 @@ export function registerProtocol() {
const url = request.url.replace(/^file:\/\//, '');
const realpath = toAbsolutePath(url);
callback(realpath);
console.log('interceptFileProtocol realpath', request.url, realpath);
return true;
});

View File

@ -0,0 +1,10 @@
export type MainEventRegister = (...args: any[]) => () => void;
export type IsomorphicHandler = (
e: Electron.IpcMainInvokeEvent,
...args: any[]
) => Promise<any>;
export type NamespaceHandlers = {
[key: string]: IsomorphicHandler;
};

View File

@ -1,6 +1,6 @@
import { BehaviorSubject, Subject } from 'rxjs';
import type { MainEventListener } from '../type';
import type { MainEventRegister } from '../type';
export interface UpdateMeta {
version: string;
@ -33,4 +33,4 @@ export const updaterEvents = {
sub.unsubscribe();
};
},
} satisfies Record<string, MainEventListener>;
} satisfies Record<string, MainEventRegister>;

View File

@ -0,0 +1,40 @@
import type { EventBasedChannel } from 'async-call-rpc';
export function getTime() {
return new Date().getTime();
}
export const isMacOS = () => {
return process.platform === 'darwin';
};
export const isWindows = () => {
return process.platform === 'win32';
};
interface MessagePortLike {
postMessage: (data: unknown) => void;
addListener: (event: 'message', listener: (...args: any[]) => void) => void;
removeListener: (
event: 'message',
listener: (...args: any[]) => void
) => void;
}
export class MessageEventChannel implements EventBasedChannel {
constructor(private worker: MessagePortLike) {}
on(listener: (data: unknown) => void) {
const f = (data: unknown) => {
listener(data);
};
this.worker.addListener('message', f);
return () => {
this.worker.removeListener('message', f);
};
}
send(data: unknown) {
this.worker.postMessage(data);
}
}

View File

@ -1,9 +1,9 @@
import { join } from 'node:path';
import { join, resolve } from 'node:path';
import { parentPort } from 'node:worker_threads';
import { AsyncCall } from 'async-call-rpc';
import { MessagePortChannel } from '../utils';
import { MessageEventChannel } from '../utils';
const commandProxy: Record<string, (...args: any[]) => Promise<any>> = {};
@ -12,17 +12,17 @@ if (!parentPort) {
}
AsyncCall(commandProxy, {
channel: new MessagePortChannel(parentPort),
channel: new MessageEventChannel(parentPort),
});
import('@toeverything/plugin-infra/manager').then(
({ rootStore, affinePluginsAtom }) => {
const bookmarkPluginPath = join(
process.env.PLUGIN_DIR ?? '../../../plugins',
process.env.PLUGIN_DIR ?? resolve(__dirname, '../plugins'),
'./bookmark-block/index.mjs'
);
import(bookmarkPluginPath);
import('file://' + bookmarkPluginPath);
rootStore.sub(affinePluginsAtom, () => {
const plugins = rootStore.get(affinePluginsAtom);
Object.values(plugins).forEach(plugin => {

View File

@ -0,0 +1,193 @@
// NOTE: we will generate preload types from this file
import { AsyncCall, type EventBasedChannel } from 'async-call-rpc';
import { ipcRenderer } from 'electron';
import { Subject } from 'rxjs';
type ExposedMeta = {
handlers: [namespace: string, handlerNames: string[]][];
events: [namespace: string, eventNames: string[]][];
};
export function getAffineAPIs() {
const mainAPIs = getMainAPIs();
const helperAPIs = getHelperAPIs();
return {
apis: {
...mainAPIs.apis,
...helperAPIs.apis,
},
events: {
...mainAPIs.events,
...helperAPIs.events,
},
};
}
export const appInfo = {
electron: true,
};
function getMainAPIs() {
const meta: ExposedMeta = (() => {
const val = process.argv
.find(arg => arg.startsWith('--main-exposed-meta='))
?.split('=')[1];
return val ? JSON.parse(val) : null;
})();
// main handlers that can be invoked from the renderer process
const apis: any = (() => {
const { handlers: handlersMeta } = meta;
const all = handlersMeta.map(([namespace, functionNames]) => {
const namespaceApis = functionNames.map(name => {
const channel = `${namespace}:${name}`;
return [
name,
(...args: any[]) => {
return ipcRenderer.invoke(channel, ...args);
},
];
});
return [namespace, Object.fromEntries(namespaceApis)];
});
return Object.fromEntries(all);
})();
// main events that can be listened to from the renderer process
const events: any = (() => {
const { events: eventsMeta } = meta;
// NOTE: ui may try to listen to a lot of the same events, so we increase the limit...
ipcRenderer.setMaxListeners(100);
const all = eventsMeta.map(([namespace, eventNames]) => {
const namespaceEvents = eventNames.map(name => {
const channel = `${namespace}:${name}`;
return [
name,
(callback: (...args: any[]) => void) => {
const fn: (
event: Electron.IpcRendererEvent,
...args: any[]
) => void = (_, ...args) => {
callback(...args);
};
ipcRenderer.on(channel, fn);
return () => {
ipcRenderer.off(channel, fn);
};
},
];
});
return [namespace, Object.fromEntries(namespaceEvents)];
});
return Object.fromEntries(all);
})();
return { apis, events };
}
const helperPort$ = new Promise<MessagePort>(resolve =>
ipcRenderer.on('helper-connection', async e => {
console.info('[preload] helper-connection', e);
resolve(e.ports[0]);
})
);
const createMessagePortChannel = (port: MessagePort): EventBasedChannel => {
return {
on(listener) {
port.onmessage = e => {
listener(e.data);
};
port.start();
return () => {
port.onmessage = null;
port.close();
};
},
send(data) {
port.postMessage(data);
},
};
};
function getHelperAPIs() {
const events$ = new Subject<{ channel: string; args: any[] }>();
const meta: ExposedMeta = (() => {
const val = process.argv
.find(arg => arg.startsWith('--helper-exposed-meta='))
?.split('=')[1];
return val ? JSON.parse(val) : null;
})();
const rendererToHelperServer: PeersAPIs.RendererToHelper = {
postEvent: (channel, ...args) => {
events$.next({ channel, args });
},
};
const rpc = AsyncCall<PeersAPIs.HelperToRenderer>(rendererToHelperServer, {
channel: helperPort$.then(helperPort =>
createMessagePortChannel(helperPort)
),
log: false,
});
const toHelperHandler = (namespace: string, name: string) => {
return rpc[`${namespace}:${name}`];
};
const toHelperEventSubscriber = (namespace: string, name: string) => {
return (callback: (...args: any[]) => void) => {
const subscription = events$.subscribe(({ channel, args }) => {
if (channel === `${namespace}:${name}`) {
callback(...args);
}
});
return () => {
subscription.unsubscribe();
};
};
};
const setup = (meta: ExposedMeta) => {
const { handlers: handlersMeta, events: eventsMeta } = meta;
const helperHandlers = Object.fromEntries(
handlersMeta.map(([namespace, functionNames]) => {
return [
namespace,
Object.fromEntries(
functionNames.map(name => {
return [name, toHelperHandler(namespace, name)];
})
),
];
})
);
const helperEvents = Object.fromEntries(
eventsMeta.map(([namespace, eventNames]) => {
return [
namespace,
Object.fromEntries(
eventNames.map(name => {
return [name, toHelperEventSubscriber(namespace, name)];
})
),
];
})
);
return [helperHandlers, helperEvents];
};
const [apis, events] = setup(meta);
return { apis, events };
}

View File

@ -1,10 +1,12 @@
import { contextBridge, ipcRenderer } from 'electron';
(async () => {
const affineApis = await import('./affine-apis');
contextBridge.exposeInMainWorld('apis', affineApis.apis);
contextBridge.exposeInMainWorld('events', affineApis.events);
contextBridge.exposeInMainWorld('appInfo', affineApis.appInfo);
const { appInfo, getAffineAPIs } = await import('./affine-apis');
const { apis, events } = getAffineAPIs();
contextBridge.exposeInMainWorld('appInfo', appInfo);
contextBridge.exposeInMainWorld('apis', apis);
contextBridge.exposeInMainWorld('events', events);
// Credit to microsoft/vscode
const globals = {

35
apps/electron/src/types.d.ts vendored Normal file
View File

@ -0,0 +1,35 @@
declare namespace PeersAPIs {
import type { app, dialog, shell } from 'electron';
interface ExposedMeta {
handlers: [string, string[]][];
events: [string, string[]][];
}
// render <-> helper
interface RendererToHelper {
postEvent: (channel: string, ...args: any[]) => void;
}
interface HelperToRenderer {
[key: string]: (...args: any[]) => Promise<any>;
}
// helper <-> main
interface HelperToMain {
getMeta: () => ExposedMeta;
}
type MainToHelper = Pick<
typeof dialog & typeof shell & typeof app,
| 'showOpenDialog'
| 'showSaveDialog'
| 'openExternal'
| 'showItemInFolder'
| 'getPath'
>;
// render <-> main
// these are handled via IPC
// TODO: fix type
}

View File

@ -80,8 +80,21 @@ export const test = base.extend<{
// a random id to avoid conflicts between tests
const id = generateUUID();
const ext = process.platform === 'win32' ? '.cmd' : '';
const dist = resolve(__dirname, '..', 'dist');
const clonedDist = resolve(__dirname, '../e2e-dist-' + id);
await fs.copy(dist, clonedDist);
const packageJson = await fs.readJSON(
resolve(__dirname, '..', 'package.json')
);
// overwrite the app name
packageJson.name = 'affine-test-' + id;
// overwrite the path to the main script
packageJson.main = './main.js';
// write to the cloned dist
await fs.writeJSON(resolve(clonedDist, 'package.json'), packageJson);
const electronApp = await electron.launch({
args: [resolve(__dirname, '..'), '--app-name', 'affine-test-' + id],
args: [clonedDist],
executablePath: resolve(
__dirname,
'..',
@ -95,11 +108,11 @@ export const test = base.extend<{
colorScheme: 'light',
});
await use(electronApp);
// FIXME: the following does not work well on CI
// const sessionDataPath = await electronApp.evaluate(async ({ app }) => {
// return app.getPath('sessionData');
// });
// await fs.rm(sessionDataPath, { recursive: true, force: true });
try {
await fs.rm(clonedDist, { recursive: true, force: true });
} catch (error) {
console.log(error);
}
},
appInfo: async ({ electronApp }, use) => {
const appInfo = await electronApp.evaluate(async ({ app }) => {

View File

@ -13,7 +13,7 @@
"resolveJsonModule": true,
"noImplicitOverride": true
},
"include": ["./layers"],
"include": ["./src"],
"exclude": ["node_modules", "out", "dist"],
"references": [
{

View File

@ -2,7 +2,7 @@
// not using import because it will break the declare module line below
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
/// <reference path='../../../electron/layers/preload/preload.d.ts' />
/// <reference path='../../../electron/src/preload/preload.d.ts' />
declare module '*.md' {
const text: string;

View File

@ -17,7 +17,6 @@ if (typeof window !== 'undefined') {
debug.enable('*');
console.warn('Debug logs enabled');
}
} else {
if (process.env.NODE_ENV === 'development') {
debug.enable('*');
console.warn('Debug logs enabled');

View File

@ -81,7 +81,7 @@
"@affine/native/*": ["./packages/native/*"],
// Development only
"@affine/electron/layers/*": ["./apps/electron/layers/*"]
"@affine/electron/*": ["./apps/electron/src/*"]
}
},
"include": [],

View File

@ -29,7 +29,7 @@ export default defineConfig({
],
// split tests that include native addons or not
include: process.env.NATIVE_TEST
? ['apps/electron/layers/**/*.spec.ts']
? ['apps/electron/src/**/*.spec.ts']
: [
'packages/**/*.spec.ts',
'packages/**/*.spec.tsx',