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
74 changed files with 1002 additions and 1048 deletions
+1 -1
View File
@@ -121,7 +121,7 @@ jobs:
path: apps/electron/resources/web-static path: apps/electron/resources/web-static
- name: Build layers - name: Build layers
run: yarn workspace @affine/electron build-layers run: yarn workspace @affine/electron build
- name: Signing By Apple Developer ID - name: Signing By Apple Developer ID
if: ${{ matrix.spec.platform == 'darwin' }} if: ${{ matrix.spec.platform == 'darwin' }}
+1 -1
View File
@@ -117,7 +117,7 @@ jobs:
path: apps/electron/resources/web-static path: apps/electron/resources/web-static
- name: Build layers - name: Build layers
run: yarn workspace @affine/electron build-layers run: yarn workspace @affine/electron build
- name: Signing By Apple Developer ID - name: Signing By Apple Developer ID
if: ${{ matrix.spec.platform == 'darwin' }} if: ${{ matrix.spec.platform == 'darwin' }}
+1 -1
View File
@@ -34,7 +34,7 @@
"packages/**/*.spec.tsx", "packages/**/*.spec.tsx",
"apps/web/**/*.spec.ts", "apps/web/**/*.spec.ts",
"apps/web/**/*.spec.tsx", "apps/web/**/*.spec.tsx",
"apps/electron/layers/**/*.spec.ts", "apps/electron/src/**/*.spec.ts",
"tests/unit/**/*.spec.ts", "tests/unit/**/*.spec.ts",
"tests/unit/**/*.spec.tsx" "tests/unit/**/*.spec.tsx"
], ],
+1
View File
@@ -1,5 +1,6 @@
*.autogen.* *.autogen.*
dist dist
e2e-dist-*
resources/web-static resources/web-static
@@ -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 = () => {};
});
});
-12
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;
-18
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';
-45
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);
}
}
@@ -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}`));
}
});
});
}
@@ -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));
@@ -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';
+2 -2
View File
@@ -17,12 +17,12 @@
"generate-assets": "zx scripts/generate-assets.mjs", "generate-assets": "zx scripts/generate-assets.mjs",
"package": "electron-forge package", "package": "electron-forge package",
"make": "electron-forge make", "make": "electron-forge make",
"test": "playwright test" "test": "DEBUG=pw:browser playwright test"
}, },
"config": { "config": {
"forge": "./forge.config.js" "forge": "./forge.config.js"
}, },
"main": "./dist/layers/main/index.js", "main": "./dist/main.js",
"exports": { "exports": {
"./scripts/plugins/build-plugins.mjs": "./scripts/plugins/build-plugins.mjs" "./scripts/plugins/build-plugins.mjs": "./scripts/plugins/build-plugins.mjs"
}, },
+3 -3
View File
@@ -18,7 +18,6 @@ if (process.platform === 'win32') {
async function buildLayers() { async function buildLayers() {
const common = config(); const common = config();
await esbuild.build(common.preload);
console.log('Build plugin infra'); console.log('Build plugin infra');
spawnSync('yarn', ['build'], { spawnSync('yarn', ['build'], {
stdio: 'inherit', stdio: 'inherit',
@@ -28,10 +27,11 @@ async function buildLayers() {
console.log('Build plugins'); console.log('Build plugins');
await import('./plugins/build-plugins.mjs'); await import('./plugins/build-plugins.mjs');
await esbuild.build(common.workers);
await esbuild.build({ await esbuild.build({
...common.main, ...common.layers,
define: { define: {
...common.main.define, ...common.define,
'process.env.NODE_ENV': `"${NODE_ENV}"`, 'process.env.NODE_ENV': `"${NODE_ENV}"`,
'process.env.BUILD_TYPE': `"${process.env.BUILD_TYPE || 'stable'}"`, 'process.env.BUILD_TYPE': `"${process.env.BUILD_TYPE || 'stable'}"`,
}, },
+21 -21
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 // List of env that will be replaced by esbuild
const ENV_MACROS = ['AFFINE_GOOGLE_CLIENT_ID', 'AFFINE_GOOGLE_CLIENT_SECRET']; 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 = () => { export const config = () => {
const define = Object.fromEntries([ const define = Object.fromEntries([
...ENV_MACROS.map(key => [ ...ENV_MACROS.map(key => [
@@ -34,27 +34,18 @@ export const config = () => {
} }
return { return {
main: { layers: {
entryPoints: [ entryPoints: [
resolve(electronDir, './layers/main/src/index.ts'), resolve(electronDir, './src/main/index.ts'),
resolve( resolve(electronDir, './src/preload/index.ts'),
electronDir, resolve(electronDir, './src/helper/index.ts'),
'./layers/main/src/workers/merge-update.worker.ts'
),
resolve(electronDir, './layers/main/src/workers/plugin.worker.ts'),
], ],
outdir: resolve(electronDir, './dist/layers/main'), entryNames: '[dir]',
outdir: resolve(electronDir, './dist'),
bundle: true, bundle: true,
target: `node${NODE_MAJOR_VERSION}`, target: `node${NODE_MAJOR_VERSION}`,
platform: 'node', platform: 'node',
external: [ external: ['electron', 'electron-updater', '@toeverything/plugin-infra'],
'electron',
'yjs',
'better-sqlite3',
'electron-updater',
'@toeverything/plugin-infra',
'async-call-rpc',
],
define: define, define: define,
format: 'cjs', format: 'cjs',
loader: { loader: {
@@ -63,14 +54,23 @@ export const config = () => {
assetNames: '[name]', assetNames: '[name]',
treeShaking: true, treeShaking: true,
}, },
preload: { workers: {
entryPoints: [resolve(electronDir, './layers/preload/src/index.ts')], entryPoints: [
outdir: resolve(electronDir, './dist/layers/preload'), resolve(electronDir, './src/main/workers/plugin.worker.ts'),
],
entryNames: '[dir]/[name]',
outdir: resolve(electronDir, './dist/workers'),
bundle: true, bundle: true,
target: `node${NODE_MAJOR_VERSION}`, target: `node${NODE_MAJOR_VERSION}`,
platform: 'node', platform: 'node',
external: ['electron'], external: ['electron', 'electron-updater', '@toeverything/plugin-infra'],
define: define, define: define,
format: 'cjs',
loader: {
'.node': 'copy',
},
assetNames: '[name]',
treeShaking: true,
}, },
}; };
}; };
+17 -17
View File
@@ -76,19 +76,20 @@ async function watchPlugins() {
await import('./plugins/dev-plugins.mjs'); await import('./plugins/dev-plugins.mjs');
} }
async function watchPreload() { async function watchLayers() {
return new Promise(async resolve => { return new Promise(async resolve => {
let initialBuild = false; let initialBuild = false;
const preloadBuild = await esbuild.context({
...common.preload, const buildContext = await esbuild.context({
...common.layers,
plugins: [ 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) { setup(build) {
build.onEnd(() => { build.onEnd(() => {
if (initialBuild) { if (initialBuild) {
console.log(`[preload] has changed, [re]launching electron...`); console.log(`[layers] has changed, [re]launching electron...`);
spawnOrReloadElectron(); spawnOrReloadElectron();
} else { } else {
resolve(); resolve();
@@ -99,25 +100,24 @@ async function watchPreload() {
}, },
], ],
}); });
// watch will trigger build.onEnd() on first run & on subsequent changes await buildContext.watch();
await preloadBuild.watch();
}); });
} }
async function watchMain() { async function watchWorkers() {
return new Promise(async resolve => { return new Promise(async resolve => {
let initialBuild = false; let initialBuild = false;
const mainBuild = await esbuild.context({ const buildContext = await esbuild.context({
...common.main, ...common.workers,
plugins: [ 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) { setup(build) {
build.onEnd(() => { build.onEnd(() => {
if (initialBuild) { if (initialBuild) {
console.log(`[main] has changed, [re]launching electron...`); console.log(`[workers] has changed, [re]launching electron...`);
spawnOrReloadElectron(); spawnOrReloadElectron();
} else { } else {
resolve(); resolve();
@@ -128,14 +128,14 @@ async function watchMain() {
}, },
], ],
}); });
await mainBuild.watch(); await buildContext.watch();
}); });
} }
async function main() { async function main() {
await watchPlugins(); await watchPlugins();
await watchMain(); await watchLayers();
await watchPreload(); await watchWorkers();
if (watchMode) { if (watchMode) {
console.log(`Watching for changes...`); console.log(`Watching for changes...`);
+1 -2
View File
@@ -85,7 +85,6 @@ async function cleanup() {
if (!process.env.SKIP_WEB_BUILD) { if (!process.env.SKIP_WEB_BUILD) {
await fs.emptyDir(publicAffineOutDir); await fs.emptyDir(publicAffineOutDir);
} }
await fs.emptyDir(path.join(electronRootDir, 'layers', 'main', 'dist')); await fs.remove(path.join(electronRootDir, 'dist'));
await fs.emptyDir(path.join(electronRootDir, 'layers', 'preload', 'dist'));
await fs.remove(path.join(electronRootDir, 'out')); await fs.remove(path.join(electronRootDir, 'out'));
} }
@@ -6,56 +6,22 @@ import { v4 } from 'uuid';
import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { afterEach, beforeEach, expect, test, vi } from 'vitest';
const tmpDir = path.join(__dirname, 'tmp'); const tmpDir = path.join(__dirname, 'tmp');
const appDataPath = path.join(tmpDir, 'app-data');
const registeredHandlers = new Map< vi.doMock('../../main-rpc', () => ({
string, mainRPC: {
((...args: any[]) => Promise<any>)[] getPath: async () => appDataPath,
>();
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');
},
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 constructorStub = vi.fn();
const destroyStub = vi.fn(); const destroyStub = vi.fn();
destroyStub.mockReturnValue(Promise.resolve()); destroyStub.mockReturnValue(Promise.resolve());
function existProcess() {
process.emit('beforeExit', 0);
}
vi.doMock('../secondary-db', () => { vi.doMock('../secondary-db', () => {
return { return {
SecondaryWorkspaceSQLiteDB: class { SecondaryWorkspaceSQLiteDB: class {
@@ -77,7 +43,7 @@ beforeEach(() => {
}); });
afterEach(async () => { afterEach(async () => {
await runHandler('before-quit'); existProcess();
// wait for the db to be closed on Windows // wait for the db to be closed on Windows
if (process.platform === 'win32') { if (process.platform === 'win32') {
await setTimeout(200); await setTimeout(200);
@@ -110,7 +76,7 @@ test('db should be destroyed when app quits', async () => {
expect(db0.db).not.toBeNull(); expect(db0.db).not.toBeNull();
expect(db1.db).not.toBeNull(); expect(db1.db).not.toBeNull();
await runHandler('before-quit'); existProcess();
// wait the async `db.destroy()` to be called // wait the async `db.destroy()` to be called
await setTimeout(100); 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 () => { test('if db has a secondary db path, we should also poll that', async () => {
const { ensureSQLiteDB } = await import('../ensure-db'); const { ensureSQLiteDB } = await import('../ensure-db');
const { appContext } = await import('../../context');
const { storeWorkspaceMeta } = await import('../../workspace'); const { storeWorkspaceMeta } = await import('../../workspace');
const workspaceId = v4(); const workspaceId = v4();
await storeWorkspaceMeta(appContext, workspaceId, { await storeWorkspaceMeta(workspaceId, {
secondaryDBPath: path.join(tmpDir, 'secondary.db'), 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); expect(constructorStub).toBeCalledWith(path.join(tmpDir, 'secondary.db'), db);
// if secondary meta is changed // if secondary meta is changed
await storeWorkspaceMeta(appContext, workspaceId, { await storeWorkspaceMeta(workspaceId, {
secondaryDBPath: path.join(tmpDir, 'secondary2.db'), 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); expect(destroyStub).toBeCalledTimes(1);
// if secondary meta is changed (but another workspace) // if secondary meta is changed (but another workspace)
await storeWorkspaceMeta(appContext, v4(), { await storeWorkspaceMeta(v4(), {
secondaryDBPath: path.join(tmpDir, 'secondary3.db'), secondaryDBPath: path.join(tmpDir, 'secondary3.db'),
}); });
await vi.advanceTimersByTimeAsync(1500); await vi.advanceTimersByTimeAsync(1500);
@@ -5,15 +5,16 @@ import { v4 } from 'uuid';
import { afterEach, expect, test, vi } from 'vitest'; import { afterEach, expect, test, vi } from 'vitest';
import * as Y from 'yjs'; import * as Y from 'yjs';
import type { AppContext } from '../../context';
import { dbSubjects } from '../subjects'; import { dbSubjects } from '../subjects';
const tmpDir = path.join(__dirname, 'tmp'); const tmpDir = path.join(__dirname, 'tmp');
const appDataPath = path.join(tmpDir, 'app-data');
const testAppContext: AppContext = { vi.doMock('../../main-rpc', () => ({
appDataPath: path.join(tmpDir, 'test-data'), mainRPC: {
appName: 'test', getPath: async () => appDataPath,
}; },
}));
afterEach(async () => { afterEach(async () => {
await fs.remove(tmpDir); await fs.remove(tmpDir);
@@ -30,9 +31,9 @@ function getTestUpdates() {
test('can create new db file if not exists', async () => { test('can create new db file if not exists', async () => {
const { openWorkspaceDatabase } = await import('../workspace-db-adapter'); const { openWorkspaceDatabase } = await import('../workspace-db-adapter');
const workspaceId = v4(); const workspaceId = v4();
const db = await openWorkspaceDatabase(testAppContext, workspaceId); const db = await openWorkspaceDatabase(workspaceId);
const dbPath = path.join( const dbPath = path.join(
testAppContext.appDataPath, appDataPath,
`workspaces/${workspaceId}`, `workspaces/${workspaceId}`,
`storage.db` `storage.db`
); );
@@ -45,7 +46,7 @@ test('on applyUpdate (from self), will not trigger update', async () => {
const workspaceId = v4(); const workspaceId = v4();
const onUpdate = vi.fn(); const onUpdate = vi.fn();
const db = await openWorkspaceDatabase(testAppContext, workspaceId); const db = await openWorkspaceDatabase(workspaceId);
db.update$.subscribe(onUpdate); db.update$.subscribe(onUpdate);
db.applyUpdate(getTestUpdates(), 'self'); db.applyUpdate(getTestUpdates(), 'self');
expect(onUpdate).not.toHaveBeenCalled(); expect(onUpdate).not.toHaveBeenCalled();
@@ -58,7 +59,7 @@ test('on applyUpdate (from renderer), will trigger update', async () => {
const onUpdate = vi.fn(); const onUpdate = vi.fn();
const onExternalUpdate = vi.fn(); const onExternalUpdate = vi.fn();
const db = await openWorkspaceDatabase(testAppContext, workspaceId); const db = await openWorkspaceDatabase(workspaceId);
db.update$.subscribe(onUpdate); db.update$.subscribe(onUpdate);
const sub = dbSubjects.externalUpdate.subscribe(onExternalUpdate); const sub = dbSubjects.externalUpdate.subscribe(onExternalUpdate);
db.applyUpdate(getTestUpdates(), 'renderer'); db.applyUpdate(getTestUpdates(), 'renderer');
@@ -73,7 +74,7 @@ test('on applyUpdate (from external), will trigger update & send external update
const onUpdate = vi.fn(); const onUpdate = vi.fn();
const onExternalUpdate = vi.fn(); const onExternalUpdate = vi.fn();
const db = await openWorkspaceDatabase(testAppContext, workspaceId); const db = await openWorkspaceDatabase(workspaceId);
db.update$.subscribe(onUpdate); db.update$.subscribe(onUpdate);
const sub = dbSubjects.externalUpdate.subscribe(onExternalUpdate); const sub = dbSubjects.externalUpdate.subscribe(onExternalUpdate);
db.applyUpdate(getTestUpdates(), 'external'); 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 () => { test('on destroy, check if resources have been released', async () => {
const { openWorkspaceDatabase } = await import('../workspace-db-adapter'); const { openWorkspaceDatabase } = await import('../workspace-db-adapter');
const workspaceId = v4(); const workspaceId = v4();
const db = await openWorkspaceDatabase(testAppContext, workspaceId); const db = await openWorkspaceDatabase(workspaceId);
const updateSub = { const updateSub = {
complete: vi.fn(), complete: vi.fn(),
next: vi.fn(), next: vi.fn(),
@@ -93,7 +93,7 @@ export abstract class BaseSQLiteAdapter {
} }
// add a single update to SQLite // add a single update to SQLite
async addUpdateToSQLite(db: SqliteConnection, updates: Uint8Array[]) { async addUpdateToSQLite(updates: Uint8Array[]) {
// batch write instead write per key stroke? // batch write instead write per key stroke?
try { try {
if (!this.db) { if (!this.db) {
@@ -101,7 +101,7 @@ export abstract class BaseSQLiteAdapter {
return; return;
} }
const start = performance.now(); const start = performance.now();
await db.insertUpdates(updates); await this.db.insertUpdates(updates);
logger.debug( logger.debug(
`[SQLiteAdapter][${this.role}] addUpdateToSQLite`, `[SQLiteAdapter][${this.role}] addUpdateToSQLite`,
'length:', 'length:',
@@ -110,7 +110,7 @@ export abstract class BaseSQLiteAdapter {
'ms' 'ms'
); );
} catch (error) { } catch (error) {
logger.error('addUpdateToSQLite', error); logger.error('addUpdateToSQLite', this.path, error);
} }
} }
} }
@@ -1,4 +1,3 @@
import { app } from 'electron';
import type { Subject } from 'rxjs'; import type { Subject } from 'rxjs';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { import {
@@ -25,7 +24,6 @@ import {
tap, tap,
} from 'rxjs/operators'; } from 'rxjs/operators';
import { appContext } from '../context';
import { logger } from '../logger'; import { logger } from '../logger';
import { getWorkspaceMeta, workspaceSubjects } from '../workspace'; import { getWorkspaceMeta, workspaceSubjects } from '../workspace';
import { SecondaryWorkspaceSQLiteDB } from './secondary-db'; import { SecondaryWorkspaceSQLiteDB } from './secondary-db';
@@ -36,7 +34,7 @@ import { openWorkspaceDatabase } from './workspace-db-adapter';
export const db$Map = new Map<string, Observable<WorkspaceSQLiteDB>>(); export const db$Map = new Map<string, Observable<WorkspaceSQLiteDB>>();
// use defer to prevent `app` is undefined while running tests // 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 // return a stream that emit a single event when the subject completes
function completed<T>(subject: Subject<T>) { function completed<T>(subject: Subject<T>) {
@@ -55,7 +53,7 @@ function getWorkspaceDB$(id: string) {
if (!db$Map.has(id)) { if (!db$Map.has(id)) {
db$Map.set( db$Map.set(
id, id,
from(openWorkspaceDatabase(appContext, id)).pipe( from(openWorkspaceDatabase(id)).pipe(
tap({ tap({
next: db => { next: db => {
logger.info( logger.info(
@@ -103,7 +101,7 @@ function getWorkspaceDB$(id: string) {
function startPollingSecondaryDB(db: WorkspaceSQLiteDB) { function startPollingSecondaryDB(db: WorkspaceSQLiteDB) {
return merge( return merge(
getWorkspaceMeta(appContext, db.workspaceId), getWorkspaceMeta(db.workspaceId),
workspaceSubjects.meta.pipe( workspaceSubjects.meta.pipe(
map(({ meta }) => meta), map(({ meta }) => meta),
filter(meta => meta.id === db.workspaceId) filter(meta => meta.id === db.workspaceId)
@@ -1,5 +1,5 @@
import { appContext } from '../context'; import { mainRPC } from '../main-rpc';
import type { MainEventListener, NamespaceHandlers } from '../type'; import type { MainEventRegister } from '../type';
import { ensureSQLiteDB } from './ensure-db'; import { ensureSQLiteDB } from './ensure-db';
import { dbSubjects } from './subjects'; import { dbSubjects } from './subjects';
@@ -7,34 +7,34 @@ export * from './ensure-db';
export * from './subjects'; export * from './subjects';
export const dbHandlers = { export const dbHandlers = {
getDocAsUpdates: async (_, id: string) => { getDocAsUpdates: async (id: string) => {
const workspaceDB = await ensureSQLiteDB(id); const workspaceDB = await ensureSQLiteDB(id);
return workspaceDB.getDocAsUpdates(); return workspaceDB.getDocAsUpdates();
}, },
applyDocUpdate: async (_, id: string, update: Uint8Array) => { applyDocUpdate: async (id: string, update: Uint8Array) => {
const workspaceDB = await ensureSQLiteDB(id); const workspaceDB = await ensureSQLiteDB(id);
return workspaceDB.applyUpdate(update); 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); const workspaceDB = await ensureSQLiteDB(workspaceId);
return workspaceDB.addBlob(key, data); return workspaceDB.addBlob(key, data);
}, },
getBlob: async (_, workspaceId: string, key: string) => { getBlob: async (workspaceId: string, key: string) => {
const workspaceDB = await ensureSQLiteDB(workspaceId); const workspaceDB = await ensureSQLiteDB(workspaceId);
return workspaceDB.getBlob(key); return workspaceDB.getBlob(key);
}, },
deleteBlob: async (_, workspaceId: string, key: string) => { deleteBlob: async (workspaceId: string, key: string) => {
const workspaceDB = await ensureSQLiteDB(workspaceId); const workspaceDB = await ensureSQLiteDB(workspaceId);
return workspaceDB.deleteBlob(key); return workspaceDB.deleteBlob(key);
}, },
getBlobKeys: async (_, workspaceId: string) => { getBlobKeys: async (workspaceId: string) => {
const workspaceDB = await ensureSQLiteDB(workspaceId); const workspaceDB = await ensureSQLiteDB(workspaceId);
return workspaceDB.getBlobKeys(); return workspaceDB.getBlobKeys();
}, },
getDefaultStorageLocation: async () => { getDefaultStorageLocation: async () => {
return appContext.appDataPath; return await mainRPC.getPath('sessionData');
}, },
} satisfies NamespaceHandlers; };
export const dbEvents = { export const dbEvents = {
onExternalUpdate: ( onExternalUpdate: (
@@ -45,4 +45,4 @@ export const dbEvents = {
sub.unsubscribe(); sub.unsubscribe();
}; };
}, },
} satisfies Record<string, MainEventListener>; } satisfies Record<string, MainEventRegister>;
@@ -4,12 +4,11 @@ import type { SqliteConnection } from '@affine/native';
import { debounce } from 'lodash-es'; import { debounce } from 'lodash-es';
import * as Y from 'yjs'; import * as Y from 'yjs';
import type { AppContext } from '../context';
import { logger } from '../logger'; import { logger } from '../logger';
import type { YOrigin } from '../type'; import type { YOrigin } from '../type';
import { mergeUpdateWorker } from '../workers';
import { getWorkspaceMeta } from '../workspace'; import { getWorkspaceMeta } from '../workspace';
import { BaseSQLiteAdapter } from './base-db-adapter'; import { BaseSQLiteAdapter } from './base-db-adapter';
import { mergeUpdate } from './merge-update';
import type { WorkspaceSQLiteDB } from './workspace-db-adapter'; import type { WorkspaceSQLiteDB } from './workspace-db-adapter';
const FLUSH_WAIT_TIME = 5000; const FLUSH_WAIT_TIME = 5000;
@@ -35,8 +34,7 @@ export class SecondaryWorkspaceSQLiteDB extends BaseSQLiteAdapter {
} }
override async destroy() { override async destroy() {
const { db } = this; await this.flushUpdateQueue();
await this.flushUpdateQueue(db);
this.unsubscribers.forEach(unsub => unsub()); this.unsubscribers.forEach(unsub => unsub());
this.yDoc.destroy(); this.yDoc.destroy();
await super.destroy(); await super.destroy();
@@ -51,12 +49,12 @@ export class SecondaryWorkspaceSQLiteDB extends BaseSQLiteAdapter {
// and flush the queue in a future time // and flush the queue in a future time
async addUpdateToUpdateQueue(db: SqliteConnection, update: Uint8Array) { async addUpdateToUpdateQueue(db: SqliteConnection, update: Uint8Array) {
this.updateQueue.push(update); this.updateQueue.push(update);
await this.debouncedFlush(db); await this.debouncedFlush();
} }
async flushUpdateQueue(db = this.db) { async flushUpdateQueue() {
if (!db) { if (this.destroyed) {
return; // skip if db is not connected return;
} }
logger.debug( logger.debug(
'flushUpdateQueue', 'flushUpdateQueue',
@@ -67,7 +65,7 @@ export class SecondaryWorkspaceSQLiteDB extends BaseSQLiteAdapter {
const updates = [...this.updateQueue]; const updates = [...this.updateQueue];
this.updateQueue = []; this.updateQueue = [];
await this.run(async () => { await this.run(async () => {
await this.addUpdateToSQLite(db, updates); await this.addUpdateToSQLite(updates);
}); });
} }
@@ -198,7 +196,7 @@ export class SecondaryWorkspaceSQLiteDB extends BaseSQLiteAdapter {
return; return;
} }
const merged = await mergeUpdateWorker(updates); const merged = mergeUpdate(updates);
this.applyUpdate(merged, 'self'); this.applyUpdate(merged, 'self');
logger.debug( logger.debug(
@@ -211,10 +209,7 @@ export class SecondaryWorkspaceSQLiteDB extends BaseSQLiteAdapter {
} }
} }
export async function getSecondaryWorkspaceDBPath( export async function getSecondaryWorkspaceDBPath(workspaceId: string) {
context: AppContext, const meta = await getWorkspaceMeta(workspaceId);
workspaceId: string
) {
const meta = await getWorkspaceMeta(context, workspaceId);
return meta?.secondaryDBPath; return meta?.secondaryDBPath;
} }
@@ -1,7 +1,5 @@
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
export const dbSubjects = { export const dbSubjects = {
// emit workspace id when the db file is missing
fileMissing: new Subject<string>(),
externalUpdate: new Subject<{ workspaceId: string; update: Uint8Array }>(), externalUpdate: new Subject<{ workspaceId: string; update: Uint8Array }>(),
}; };
@@ -1,13 +1,11 @@
import type { SqliteConnection } from '@affine/native';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import * as Y from 'yjs'; import * as Y from 'yjs';
import type { AppContext } from '../context';
import { logger } from '../logger'; import { logger } from '../logger';
import type { YOrigin } from '../type'; import type { YOrigin } from '../type';
import { mergeUpdateWorker } from '../workers';
import { getWorkspaceMeta } from '../workspace'; import { getWorkspaceMeta } from '../workspace';
import { BaseSQLiteAdapter } from './base-db-adapter'; import { BaseSQLiteAdapter } from './base-db-adapter';
import { mergeUpdate } from './merge-update';
import { dbSubjects } from './subjects'; import { dbSubjects } from './subjects';
export class WorkspaceSQLiteDB extends BaseSQLiteAdapter { export class WorkspaceSQLiteDB extends BaseSQLiteAdapter {
@@ -40,20 +38,20 @@ export class WorkspaceSQLiteDB extends BaseSQLiteAdapter {
if (!this.firstConnected) { if (!this.firstConnected) {
this.yDoc.on('update', async (update: Uint8Array, origin: YOrigin) => { this.yDoc.on('update', async (update: Uint8Array, origin: YOrigin) => {
if (origin === 'renderer') { if (origin === 'renderer') {
await this.addUpdateToSQLite(db, [update]); await this.addUpdateToSQLite([update]);
} else if (origin === 'external') { } else if (origin === 'external') {
dbSubjects.externalUpdate.next({ dbSubjects.externalUpdate.next({
workspaceId: this.workspaceId, workspaceId: this.workspaceId,
update, update,
}); });
await this.addUpdateToSQLite(db, [update]); await this.addUpdateToSQLite([update]);
logger.debug('external update', this.workspaceId); logger.debug('external update', this.workspaceId);
} }
}); });
} }
const updates = await this.getUpdates(); 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 // to initialize the yDoc, we need to apply all updates from the db
this.applyUpdate(merged, 'self'); this.applyUpdate(merged, 'self');
@@ -89,17 +87,14 @@ export class WorkspaceSQLiteDB extends BaseSQLiteAdapter {
await super.deleteBlob(key); await super.deleteBlob(key);
} }
override async addUpdateToSQLite(db: SqliteConnection, data: Uint8Array[]) { override async addUpdateToSQLite(data: Uint8Array[]) {
this.update$.next(); this.update$.next();
await super.addUpdateToSQLite(db, data); await super.addUpdateToSQLite(data);
} }
} }
export async function openWorkspaceDatabase( export async function openWorkspaceDatabase(workspaceId: string) {
context: AppContext, const meta = await getWorkspaceMeta(workspaceId);
workspaceId: string
) {
const meta = await getWorkspaceMeta(context, workspaceId);
const db = new WorkspaceSQLiteDB(meta.mainDBPath, workspaceId); const db = new WorkspaceSQLiteDB(meta.mainDBPath, workspaceId);
await db.init(); await db.init();
logger.info(`openWorkspaceDatabase [${workspaceId}]`); logger.info(`openWorkspaceDatabase [${workspaceId}]`);
@@ -1,17 +1,16 @@
import path from 'node:path'; import path from 'node:path';
import { app } from 'electron';
import { dialog, shell } from 'electron';
import fs from 'fs-extra'; import fs from 'fs-extra';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { appContext } from '../context';
import { ensureSQLiteDB } from '../db/ensure-db'; import { ensureSQLiteDB } from '../db/ensure-db';
import type { WorkspaceSQLiteDB } from '../db/workspace-db-adapter'; import type { WorkspaceSQLiteDB } from '../db/workspace-db-adapter';
import { logger } from '../logger'; import { logger } from '../logger';
import { mainRPC } from '../main-rpc';
import { import {
getWorkspaceDBPath, getWorkspaceDBPath,
getWorkspaceMeta, getWorkspaceMeta,
getWorkspacesBasePath,
listWorkspaces, listWorkspaces,
storeWorkspaceMeta, storeWorkspaceMeta,
} from '../workspace'; } from '../workspace';
@@ -20,11 +19,11 @@ import {
// we are using native dialogs because HTML dialogs do not give full file paths // we are using native dialogs because HTML dialogs do not give full file paths
export async function revealDBFile(workspaceId: string) { export async function revealDBFile(workspaceId: string) {
const meta = await getWorkspaceMeta(appContext, workspaceId); const meta = await getWorkspaceMeta(workspaceId);
if (!meta) { if (!meta) {
return; 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 // 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 db = await ensureSQLiteDB(workspaceId);
const ret = const ret =
getFakedResult() ?? getFakedResult() ??
(await dialog.showSaveDialog({ (await mainRPC.showSaveDialog({
properties: ['showOverwriteConfirmation'], properties: ['showOverwriteConfirmation'],
title: 'Save Workspace', title: 'Save Workspace',
showsTagField: false, showsTagField: false,
@@ -111,7 +110,7 @@ export async function saveDBFileAs(
await fs.copyFile(db.path, filePath); await fs.copyFile(db.path, filePath);
logger.log('saved', filePath); logger.log('saved', filePath);
shell.showItemInFolder(filePath); mainRPC.showItemInFolder(filePath);
return { filePath }; return { filePath };
} catch (err) { } catch (err) {
logger.error('saveDBFileAs', err); logger.error('saveDBFileAs', err);
@@ -131,11 +130,11 @@ export async function selectDBFileLocation(): Promise<SelectDBFileLocationResult
try { try {
const ret = const ret =
getFakedResult() ?? getFakedResult() ??
(await dialog.showOpenDialog({ (await mainRPC.showOpenDialog({
properties: ['openDirectory'], properties: ['openDirectory'],
title: 'Set Workspace Storage Location', title: 'Set Workspace Storage Location',
buttonLabel: 'Select', buttonLabel: 'Select',
defaultPath: app.getPath('documents'), defaultPath: await mainRPC.getPath('documents'),
message: "Select a location to store the workspace's database file", message: "Select a location to store the workspace's database file",
})); }));
const dir = ret.filePaths?.[0]; const dir = ret.filePaths?.[0];
@@ -177,7 +176,7 @@ export async function loadDBFile(): Promise<LoadDBFileResult> {
try { try {
const ret = const ret =
getFakedResult() ?? getFakedResult() ??
(await dialog.showOpenDialog({ (await mainRPC.showOpenDialog({
properties: ['openFile'], properties: ['openFile'],
title: 'Load Workspace', title: 'Load Workspace',
buttonLabel: 'Load', buttonLabel: 'Load',
@@ -197,7 +196,7 @@ export async function loadDBFile(): Promise<LoadDBFileResult> {
} }
// the imported file should not be in app data dir // 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'); logger.warn('loadDBFile: db file in app data dir');
return { error: 'DB_FILE_PATH_INVALID' }; 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 // copy the db file to a new workspace id
const workspaceId = nanoid(10); 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); await fs.copy(filePath, internalFilePath);
logger.info(`loadDBFile, copy: ${filePath} -> ${internalFilePath}`); logger.info(`loadDBFile, copy: ${filePath} -> ${internalFilePath}`);
await storeWorkspaceMeta(appContext, workspaceId, { await storeWorkspaceMeta(workspaceId, {
id: workspaceId, id: workspaceId,
mainDBPath: internalFilePath, mainDBPath: internalFilePath,
secondaryDBPath: filePath, secondaryDBPath: filePath,
@@ -260,13 +259,12 @@ export async function moveDBFile(
let db: WorkspaceSQLiteDB | null = null; let db: WorkspaceSQLiteDB | null = null;
try { try {
db = await ensureSQLiteDB(workspaceId); db = await ensureSQLiteDB(workspaceId);
const meta = await getWorkspaceMeta(workspaceId);
const meta = await getWorkspaceMeta(appContext, workspaceId);
const oldDir = meta.secondaryDBPath const oldDir = meta.secondaryDBPath
? path.dirname(meta.secondaryDBPath) ? path.dirname(meta.secondaryDBPath)
: null; : null;
const defaultDir = oldDir ?? app.getPath('documents'); const defaultDir = oldDir ?? (await mainRPC.getPath('documents'));
const newName = getDefaultDBFileName(db.getWorkspaceName(), workspaceId); const newName = getDefaultDBFileName(db.getWorkspaceName(), workspaceId);
@@ -274,7 +272,7 @@ export async function moveDBFile(
dbFileDir ?? dbFileDir ??
( (
getFakedResult() ?? getFakedResult() ??
(await dialog.showOpenDialog({ (await mainRPC.showOpenDialog({
properties: ['openDirectory'], properties: ['openDirectory'],
title: 'Move Workspace Storage', title: 'Move Workspace Storage',
buttonLabel: 'Move', buttonLabel: 'Move',
@@ -320,7 +318,7 @@ export async function moveDBFile(
} }
// update meta // update meta
await storeWorkspaceMeta(appContext, workspaceId, { await storeWorkspaceMeta(workspaceId, {
secondaryDBPath: newFilePath, secondaryDBPath: newFilePath,
}); });
@@ -337,7 +335,7 @@ export async function moveDBFile(
} }
async function dbFileAlreadyLoaded(path: string) { async function dbFileAlreadyLoaded(path: string) {
const meta = await listWorkspaces(appContext); const meta = await listWorkspaces();
const paths = meta.map(m => m[1].secondaryDBPath); const paths = meta.map(m => m[1].secondaryDBPath);
return paths.includes(path); return paths.includes(path);
} }
@@ -1,4 +1,3 @@
import type { NamespaceHandlers } from '../type';
import { import {
loadDBFile, loadDBFile,
moveDBFile, moveDBFile,
@@ -9,25 +8,24 @@ import {
} from './dialog'; } from './dialog';
export const dialogHandlers = { export const dialogHandlers = {
revealDBFile: async (_, workspaceId: string) => { revealDBFile: async (workspaceId: string) => {
return revealDBFile(workspaceId); return revealDBFile(workspaceId);
}, },
loadDBFile: async () => { loadDBFile: async () => {
return loadDBFile(); return loadDBFile();
}, },
saveDBFileAs: async (_, workspaceId: string) => { saveDBFileAs: async (workspaceId: string) => {
return saveDBFileAs(workspaceId); return saveDBFileAs(workspaceId);
}, },
moveDBFile: (_, workspaceId: string, dbFileLocation?: string) => { moveDBFile: (workspaceId: string, dbFileLocation?: string) => {
return moveDBFile(workspaceId, dbFileLocation); return moveDBFile(workspaceId, dbFileLocation);
}, },
selectDBFileLocation: async () => { selectDBFileLocation: async () => {
return selectDBFileLocation(); return selectDBFileLocation();
}, },
setFakeDialogResult: async ( setFakeDialogResult: async (
_,
result: Parameters<typeof setFakeDialogResult>[0] result: Parameters<typeof setFakeDialogResult>[0]
) => { ) => {
return setFakeDialogResult(result); return setFakeDialogResult(result);
}, },
} satisfies NamespaceHandlers; };
+33
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,
};
};
+86
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();
+3
View File
@@ -0,0 +1,3 @@
import log from 'electron-log';
export const logger = log.scope('helper');
+33
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),
});
+9
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;
@@ -4,18 +4,8 @@ import fs from 'fs-extra';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import { afterEach, describe, expect, test, vi } from 'vitest'; import { afterEach, describe, expect, test, vi } from 'vitest';
import type { AppContext } from '../../context';
const tmpDir = path.join(__dirname, 'tmp'); 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('../../context', () => ({
appContext: testAppContext,
}));
vi.doMock('../../db/ensure-db', () => ({ vi.doMock('../../db/ensure-db', () => ({
ensureSQLiteDB: async () => ({ ensureSQLiteDB: async () => ({
@@ -23,6 +13,12 @@ vi.doMock('../../db/ensure-db', () => ({
}), }),
})); }));
vi.doMock('../../main-rpc', () => ({
mainRPC: {
getPath: async () => appDataPath,
},
}));
afterEach(async () => { afterEach(async () => {
await fs.remove(tmpDir); await fs.remove(tmpDir);
}); });
@@ -31,30 +27,22 @@ describe('list workspaces', () => {
test('listWorkspaces (valid)', async () => { test('listWorkspaces (valid)', async () => {
const { listWorkspaces } = await import('../handlers'); const { listWorkspaces } = await import('../handlers');
const workspaceId = v4(); const workspaceId = v4();
const workspacePath = path.join( const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
testAppContext.appDataPath,
'workspaces',
workspaceId
);
const meta = { const meta = {
id: workspaceId, id: workspaceId,
}; };
await fs.ensureDir(workspacePath); await fs.ensureDir(workspacePath);
await fs.writeJSON(path.join(workspacePath, 'meta.json'), meta); await fs.writeJSON(path.join(workspacePath, 'meta.json'), meta);
const workspaces = await listWorkspaces(testAppContext); const workspaces = await listWorkspaces();
expect(workspaces).toEqual([[workspaceId, meta]]); expect(workspaces).toEqual([[workspaceId, meta]]);
}); });
test('listWorkspaces (without meta json file)', async () => { test('listWorkspaces (without meta json file)', async () => {
const { listWorkspaces } = await import('../handlers'); const { listWorkspaces } = await import('../handlers');
const workspaceId = v4(); const workspaceId = v4();
const workspacePath = path.join( const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
testAppContext.appDataPath,
'workspaces',
workspaceId
);
await fs.ensureDir(workspacePath); await fs.ensureDir(workspacePath);
const workspaces = await listWorkspaces(testAppContext); const workspaces = await listWorkspaces();
expect(workspaces).toEqual([ expect(workspaces).toEqual([
[ [
workspaceId, workspaceId,
@@ -69,18 +57,14 @@ describe('delete workspace', () => {
test('deleteWorkspace', async () => { test('deleteWorkspace', async () => {
const { deleteWorkspace } = await import('../handlers'); const { deleteWorkspace } = await import('../handlers');
const workspaceId = v4(); const workspaceId = v4();
const workspacePath = path.join( const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
testAppContext.appDataPath,
'workspaces',
workspaceId
);
await fs.ensureDir(workspacePath); await fs.ensureDir(workspacePath);
await deleteWorkspace(testAppContext, workspaceId); await deleteWorkspace(workspaceId);
expect(await fs.pathExists(workspacePath)).toBe(false); expect(await fs.pathExists(workspacePath)).toBe(false);
// removed workspace will be moved to delete-workspaces // removed workspace will be moved to deleted-workspaces
expect( expect(
await fs.pathExists( await fs.pathExists(
path.join(testAppContext.appDataPath, 'delete-workspaces', workspaceId) path.join(appDataPath, 'deleted-workspaces', workspaceId)
) )
).toBe(true); ).toBe(true);
}); });
@@ -90,29 +74,21 @@ describe('getWorkspaceMeta', () => {
test('can get meta', async () => { test('can get meta', async () => {
const { getWorkspaceMeta } = await import('../handlers'); const { getWorkspaceMeta } = await import('../handlers');
const workspaceId = v4(); const workspaceId = v4();
const workspacePath = path.join( const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
testAppContext.appDataPath,
'workspaces',
workspaceId
);
const meta = { const meta = {
id: workspaceId, id: workspaceId,
}; };
await fs.ensureDir(workspacePath); await fs.ensureDir(workspacePath);
await fs.writeJSON(path.join(workspacePath, 'meta.json'), meta); 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 () => { test('can create meta if not exists', async () => {
const { getWorkspaceMeta } = await import('../handlers'); const { getWorkspaceMeta } = await import('../handlers');
const workspaceId = v4(); const workspaceId = v4();
const workspacePath = path.join( const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
testAppContext.appDataPath,
'workspaces',
workspaceId
);
await fs.ensureDir(workspacePath); await fs.ensureDir(workspacePath);
expect(await getWorkspaceMeta(testAppContext, workspaceId)).toEqual({ expect(await getWorkspaceMeta(workspaceId)).toEqual({
id: workspaceId, id: workspaceId,
mainDBPath: path.join(workspacePath, 'storage.db'), mainDBPath: path.join(workspacePath, 'storage.db'),
}); });
@@ -124,18 +100,14 @@ describe('getWorkspaceMeta', () => {
test('can migrate meta if db file is a link', async () => { test('can migrate meta if db file is a link', async () => {
const { getWorkspaceMeta } = await import('../handlers'); const { getWorkspaceMeta } = await import('../handlers');
const workspaceId = v4(); const workspaceId = v4();
const workspacePath = path.join( const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
testAppContext.appDataPath,
'workspaces',
workspaceId
);
await fs.ensureDir(workspacePath); await fs.ensureDir(workspacePath);
const sourcePath = path.join(tmpDir, 'source.db'); const sourcePath = path.join(tmpDir, 'source.db');
await fs.writeFile(sourcePath, 'test'); await fs.writeFile(sourcePath, 'test');
await fs.ensureSymlink(sourcePath, path.join(workspacePath, 'storage.db')); await fs.ensureSymlink(sourcePath, path.join(workspacePath, 'storage.db'));
expect(await getWorkspaceMeta(testAppContext, workspaceId)).toEqual({ expect(await getWorkspaceMeta(workspaceId)).toEqual({
id: workspaceId, id: workspaceId,
mainDBPath: path.join(workspacePath, 'storage.db'), mainDBPath: path.join(workspacePath, 'storage.db'),
secondaryDBPath: sourcePath, secondaryDBPath: sourcePath,
@@ -150,21 +122,17 @@ describe('getWorkspaceMeta', () => {
test('storeWorkspaceMeta', async () => { test('storeWorkspaceMeta', async () => {
const { storeWorkspaceMeta } = await import('../handlers'); const { storeWorkspaceMeta } = await import('../handlers');
const workspaceId = v4(); const workspaceId = v4();
const workspacePath = path.join( const workspacePath = path.join(appDataPath, 'workspaces', workspaceId);
testAppContext.appDataPath,
'workspaces',
workspaceId
);
await fs.ensureDir(workspacePath); await fs.ensureDir(workspacePath);
const meta = { const meta = {
id: workspaceId, id: workspaceId,
mainDBPath: path.join(workspacePath, 'storage.db'), 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( expect(await fs.readJSON(path.join(workspacePath, 'meta.json'))).toEqual(
meta meta
); );
await storeWorkspaceMeta(testAppContext, workspaceId, { await storeWorkspaceMeta(workspaceId, {
secondaryDBPath: path.join(tmpDir, 'test.db'), secondaryDBPath: path.join(tmpDir, 'test.db'),
}); });
expect(await fs.readJSON(path.join(workspacePath, 'meta.json'))).toEqual({ expect(await fs.readJSON(path.join(workspacePath, 'meta.json'))).toEqual({
@@ -2,26 +2,38 @@ import path from 'node:path';
import fs from 'fs-extra'; import fs from 'fs-extra';
import { type AppContext } from '../context';
import { ensureSQLiteDB } from '../db/ensure-db'; import { ensureSQLiteDB } from '../db/ensure-db';
import { logger } from '../logger'; import { logger } from '../logger';
import { mainRPC } from '../main-rpc';
import type { WorkspaceMeta } from '../type'; import type { WorkspaceMeta } from '../type';
import { workspaceSubjects } from './subjects'; import { workspaceSubjects } from './subjects';
export async function listWorkspaces( let _appDataPath = '';
context: AppContext
): Promise<[workspaceId: string, meta: WorkspaceMeta][]> { async function getAppDataPath() {
const basePath = getWorkspacesBasePath(context); 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 { try {
await fs.ensureDir(basePath); await fs.ensureDir(basePath);
const dirs = await fs.readdir(basePath, { const dirs = (
withFileTypes: true, await fs.readdir(basePath, {
}); withFileTypes: true,
})
).filter(d => d.isDirectory());
const metaList = ( const metaList = (
await Promise.all( await Promise.all(
dirs.map(async dir => { dirs.map(async dir => {
// ? shall we put all meta in a single file instead of one file per workspace? // ? 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); ).filter((w): w is WorkspaceMeta => !!w);
@@ -32,13 +44,9 @@ export async function listWorkspaces(
} }
} }
export async function deleteWorkspace(context: AppContext, id: string) { export async function deleteWorkspace(id: string) {
const basePath = getWorkspaceBasePath(context, id); const basePath = await getWorkspaceBasePath(id);
const movedPath = path.join( const movedPath = path.join(await getDeletedWorkspacesBasePath(), `${id}`);
context.appDataPath,
'delete-workspaces',
`${id}`
);
try { try {
const db = await ensureSQLiteDB(id); const db = await ensureSQLiteDB(id);
await db.destroy(); await db.destroy();
@@ -50,22 +58,24 @@ export async function deleteWorkspace(context: AppContext, id: string) {
} }
} }
export function getWorkspacesBasePath(context: AppContext) { export async function getWorkspacesBasePath() {
return path.join(context.appDataPath, 'workspaces'); return path.join(await getAppDataPath(), 'workspaces');
} }
export function getWorkspaceBasePath(context: AppContext, workspaceId: string) { export async function getWorkspaceBasePath(workspaceId: string) {
return path.join(context.appDataPath, 'workspaces', workspaceId); return path.join(await getAppDataPath(), 'workspaces', workspaceId);
} }
export function getWorkspaceDBPath(context: AppContext, workspaceId: string) { async function getDeletedWorkspacesBasePath() {
const basePath = getWorkspaceBasePath(context, workspaceId); return path.join(await getAppDataPath(), 'deleted-workspaces');
return path.join(basePath, 'storage.db');
} }
export function getWorkspaceMetaPath(context: AppContext, workspaceId: string) { export async function getWorkspaceDBPath(workspaceId: string) {
const basePath = getWorkspaceBasePath(context, workspaceId); return path.join(await getWorkspaceBasePath(workspaceId), 'storage.db');
return path.join(basePath, 'meta.json'); }
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 * This function will also migrate the workspace if needed
*/ */
export async function getWorkspaceMeta( export async function getWorkspaceMeta(
context: AppContext,
workspaceId: string workspaceId: string
): Promise<WorkspaceMeta> { ): Promise<WorkspaceMeta> {
try { try {
const basePath = getWorkspaceBasePath(context, workspaceId); const basePath = await getWorkspaceBasePath(workspaceId);
const metaPath = getWorkspaceMetaPath(context, workspaceId); const metaPath = await getWorkspaceMetaPath(workspaceId);
if (!(await fs.exists(metaPath))) { if (!(await fs.exists(metaPath))) {
// since not meta is found, we will migrate symlinked db file if needed // since not meta is found, we will migrate symlinked db file if needed
await fs.ensureDir(basePath); await fs.ensureDir(basePath);
const dbPath = getWorkspaceDBPath(context, workspaceId); const dbPath = await getWorkspaceDBPath(workspaceId);
// todo: remove this after migration (in stable version) // todo: remove this after migration (in stable version)
const realDBPath = (await fs.exists(dbPath)) const realDBPath = (await fs.exists(dbPath))
@@ -111,15 +120,14 @@ export async function getWorkspaceMeta(
} }
export async function storeWorkspaceMeta( export async function storeWorkspaceMeta(
context: AppContext,
workspaceId: string, workspaceId: string,
meta: Partial<WorkspaceMeta> meta: Partial<WorkspaceMeta>
) { ) {
try { try {
const basePath = getWorkspaceBasePath(context, workspaceId); const basePath = await getWorkspaceBasePath(workspaceId);
await fs.ensureDir(basePath); await fs.ensureDir(basePath);
const metaPath = path.join(basePath, 'meta.json'); const metaPath = path.join(basePath, 'meta.json');
const currentMeta = await getWorkspaceMeta(context, workspaceId); const currentMeta = await getWorkspaceMeta(workspaceId);
const newMeta = { const newMeta = {
...currentMeta, ...currentMeta,
...meta, ...meta,
@@ -1,9 +1,4 @@
import { appContext } from '../context'; import type { MainEventRegister, WorkspaceMeta } from '../type';
import type {
MainEventListener,
NamespaceHandlers,
WorkspaceMeta,
} from '../type';
import { deleteWorkspace, getWorkspaceMeta, listWorkspaces } from './handlers'; import { deleteWorkspace, getWorkspaceMeta, listWorkspaces } from './handlers';
import { workspaceSubjects } from './subjects'; import { workspaceSubjects } from './subjects';
@@ -19,12 +14,12 @@ export const workspaceEvents = {
sub.unsubscribe(); sub.unsubscribe();
}; };
}, },
} satisfies Record<string, MainEventListener>; } satisfies Record<string, MainEventRegister>;
export const workspaceHandlers = { export const workspaceHandlers = {
list: async () => listWorkspaces(appContext), list: async () => listWorkspaces(),
delete: async (_, id: string) => deleteWorkspace(appContext, id), delete: async (id: string) => deleteWorkspace(id),
getMeta: async (_, id: string) => { getMeta: async (id: string) => {
return getWorkspaceMeta(appContext, id); return getWorkspaceMeta(id);
}, },
} satisfies NamespaceHandlers; };
@@ -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 = () => {};
});
});
@@ -1,4 +1,4 @@
import type { MainEventListener } from '../type'; import type { MainEventRegister } from '../type';
import { applicationMenuSubjects } from './subject'; import { applicationMenuSubjects } from './subject';
export * from './create'; export * from './create';
@@ -17,4 +17,4 @@ export const applicationMenuEvents = {
sub.unsubscribe(); sub.unsubscribe();
}; };
}, },
} satisfies Record<string, MainEventListener>; } satisfies Record<string, MainEventRegister>;
@@ -1,16 +1,12 @@
import { app, BrowserWindow } from 'electron'; import { app, BrowserWindow } from 'electron';
import { applicationMenuEvents } from './application-menu'; import { applicationMenuEvents } from './application-menu';
import { dbEvents } from './db';
import { logger } from './logger'; import { logger } from './logger';
import { updaterEvents } from './updater/event'; import { updaterEvents } from './updater/event';
import { workspaceEvents } from './workspace';
export const allEvents = { export const allEvents = {
applicationMenu: applicationMenuEvents, applicationMenu: applicationMenuEvents,
db: dbEvents,
updater: updaterEvents, updater: updaterEvents,
workspace: workspaceEvents,
}; };
function getActiveWindows() { function getActiveWindows() {
@@ -9,19 +9,13 @@ export { events, handlers };
export const getExposedMeta = () => { export const getExposedMeta = () => {
const handlersMeta = Object.entries(handlers).map( const handlersMeta = Object.entries(handlers).map(
([namespace, namespaceHandlers]) => { ([namespace, namespaceHandlers]) => {
return [ return [namespace, Object.keys(namespaceHandlers)];
namespace,
Object.keys(namespaceHandlers).map(handlerName => handlerName),
];
} }
); );
const eventsMeta = Object.entries(events).map( const eventsMeta = Object.entries(events).map(
([namespace, namespaceHandlers]) => { ([namespace, namespaceHandlers]) => {
return [ return [namespace, Object.keys(namespaceHandlers)];
namespace,
Object.keys(namespaceHandlers).map(handlerName => handlerName),
];
} }
); );
@@ -32,5 +26,4 @@ export const getExposedMeta = () => {
}; };
export type MainIPCHandlerMap = typeof handlers; export type MainIPCHandlerMap = typeof handlers;
export type MainIPCEventMap = typeof events; export type MainIPCEventMap = typeof events;
@@ -1,22 +1,16 @@
import type { import type {
DBHandlerManager,
DebugHandlerManager, DebugHandlerManager,
DialogHandlerManager,
ExportHandlerManager, ExportHandlerManager,
UIHandlerManager, UIHandlerManager,
UnwrapManagerHandlerToServerSide, UnwrapManagerHandlerToServerSide,
UpdaterHandlerManager, UpdaterHandlerManager,
WorkspaceHandlerManager,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { ipcMain } from 'electron'; import { ipcMain } from 'electron';
import { dbHandlers } from './db';
import { dialogHandlers } from './dialog';
import { exportHandlers } from './export'; import { exportHandlers } from './export';
import { getLogFilePath, logger, revealLogFile } from './logger'; import { getLogFilePath, logger, revealLogFile } from './logger';
import { uiHandlers } from './ui'; import { uiHandlers } from './ui';
import { updaterHandlers } from './updater'; import { updaterHandlers } from './updater';
import { workspaceHandlers } from './workspace';
export const debugHandlers = { export const debugHandlers = {
revealLogFile: async () => { revealLogFile: async () => {
@@ -28,18 +22,10 @@ export const debugHandlers = {
}; };
type AllHandlers = { type AllHandlers = {
db: UnwrapManagerHandlerToServerSide<
Electron.IpcMainInvokeEvent,
DBHandlerManager
>;
debug: UnwrapManagerHandlerToServerSide< debug: UnwrapManagerHandlerToServerSide<
Electron.IpcMainInvokeEvent, Electron.IpcMainInvokeEvent,
DebugHandlerManager DebugHandlerManager
>; >;
dialog: UnwrapManagerHandlerToServerSide<
Electron.IpcMainInvokeEvent,
DialogHandlerManager
>;
export: UnwrapManagerHandlerToServerSide< export: UnwrapManagerHandlerToServerSide<
Electron.IpcMainInvokeEvent, Electron.IpcMainInvokeEvent,
ExportHandlerManager ExportHandlerManager
@@ -52,21 +38,14 @@ type AllHandlers = {
Electron.IpcMainInvokeEvent, Electron.IpcMainInvokeEvent,
UpdaterHandlerManager 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 // Note: all of these handlers will be the single-source-of-truth for the apis exposed to the renderer process
export const allHandlers = { export const allHandlers = {
db: dbHandlers,
debug: debugHandlers, debug: debugHandlers,
dialog: dialogHandlers,
ui: uiHandlers, ui: uiHandlers,
export: exportHandlers, export: exportHandlers,
updater: updaterHandlers, updater: updaterHandlers,
workspace: workspaceHandlers,
} satisfies AllHandlers; } satisfies AllHandlers;
export const registerHandlers = () => { export const registerHandlers = () => {
@@ -78,6 +57,7 @@ export const registerHandlers = () => {
ipcMain.handle(chan, async (e, ...args) => { ipcMain.handle(chan, async (e, ...args) => {
const start = performance.now(); const start = performance.now();
try { try {
// @ts-expect-error - TODO: fix this
const result = await handler(e, ...args); const result = await handler(e, ...args);
logger.info( logger.info(
'[ipc-api]', '[ipc-api]',
+111
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;
}
@@ -5,6 +5,7 @@ import { app } from 'electron';
import { createApplicationMenu } from './application-menu/create'; import { createApplicationMenu } from './application-menu/create';
import { registerEvents } from './events'; import { registerEvents } from './events';
import { registerHandlers } from './handlers'; import { registerHandlers } from './handlers';
import { ensureHelperProcess } from './helper-process';
import { logger } from './logger'; import { logger } from './logger';
import { restoreOrCreateWindow } from './main-window'; import { restoreOrCreateWindow } from './main-window';
import { registerPlugin } from './plugin'; import { registerPlugin } from './plugin';
@@ -62,7 +63,9 @@ app
.then(registerHandlers) .then(registerHandlers)
.then(registerEvents) .then(registerEvents)
.then(registerPlugin) .then(registerPlugin)
.then(ensureHelperProcess)
.then(restoreOrCreateWindow) .then(restoreOrCreateWindow)
.then(createApplicationMenu) .then(createApplicationMenu)
.then()
.then(registerUpdater) .then(registerUpdater)
.catch(e => console.error('Failed create window:', e)); .catch(e => console.error('Failed create window:', e));
@@ -1,7 +1,8 @@
import { shell } from 'electron'; import { shell } from 'electron';
import log from 'electron-log'; import log from 'electron-log';
export const logger = log; export const logger = log.scope('main');
log.initialize();
export function getLogFilePath() { export function getLogFilePath() {
return log.transports.file.getFile().path; return log.transports.file.getFile().path;
@@ -1,8 +1,11 @@
import assert from 'node:assert';
import { BrowserWindow, nativeTheme } from 'electron'; import { BrowserWindow, nativeTheme } from 'electron';
import electronWindowState from 'electron-window-state'; import electronWindowState from 'electron-window-state';
import { join } from 'path'; import { join } from 'path';
import { getExposedMeta } from './exposed'; import { getExposedMeta } from './exposed';
import { ensureHelperProcess } from './helper-process';
import { logger } from './logger'; import { logger } from './logger';
import { isMacOS, isWindows } from './utils'; import { isMacOS, isWindows } from './utils';
@@ -18,7 +21,12 @@ async function createWindow() {
defaultHeight: 800, 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({ const browserWindow = new BrowserWindow({
titleBarStyle: isMacOS() titleBarStyle: isMacOS()
@@ -42,9 +50,12 @@ async function createWindow() {
sandbox: false, 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 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? spellcheck: false, // FIXME: enable?
preload: join(__dirname, '../preload/index.js'), preload: join(__dirname, './preload.js'),
// serialize exposed meta that to be used in preload // 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); mainWindowState.manage(browserWindow);
let helperConnectionUnsub: (() => void) | undefined;
/** /**
* If you install `show: true` then it can cause issues when trying to close the window. * 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. * Use `show: false` and listener events `ready-to-show` to fix these issues.
@@ -65,6 +78,9 @@ async function createWindow() {
} else { } else {
browserWindow.show(); browserWindow.show();
} }
helperConnectionUnsub = helperProcessManager.connectRenderer(
browserWindow.webContents
);
logger.info('main window is ready to show'); logger.info('main window is ready to show');
@@ -78,6 +94,7 @@ async function createWindow() {
browserWindow.on('close', e => { browserWindow.on('close', e => {
e.preventDefault(); e.preventDefault();
browserWindow.destroy(); browserWindow.destroy();
helperConnectionUnsub?.();
// TODO: gracefully close the app, for example, ask user to save unsaved changes // TODO: gracefully close the app, for example, ask user to save unsaved changes
}); });
@@ -1,10 +1,10 @@
import { join } from 'node:path'; import { join, resolve } from 'node:path';
import { Worker } from 'node:worker_threads'; import { Worker } from 'node:worker_threads';
import { AsyncCall } from 'async-call-rpc'; import { AsyncCall } from 'async-call-rpc';
import { ipcMain } from 'electron'; import { ipcMain } from 'electron';
import { ThreadWorkerChannel } from './utils'; import { MessageEventChannel } from './utils';
declare global { declare global {
// fixme(himself65): // 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; globalThis.asyncCall = asyncCall;
await import('@toeverything/plugin-infra/manager').then( await import('@toeverything/plugin-infra/manager').then(
({ rootStore, affinePluginsAtom }) => { ({ rootStore, affinePluginsAtom }) => {
const bookmarkPluginPath = join( const bookmarkPluginPath = join(
process.env.PLUGIN_DIR ?? '../../plugins', process.env.PLUGIN_DIR ?? resolve(__dirname, './plugins'),
'./bookmark-block/index.mjs' './bookmark-block/index.mjs'
); );
import(bookmarkPluginPath); import('file://' + bookmarkPluginPath);
let dispose: () => void = () => { let dispose: () => void = () => {
// noop // noop
}; };
@@ -16,7 +16,7 @@ protocol.registerSchemesAsPrivileged([
function toAbsolutePath(url: string) { function toAbsolutePath(url: string) {
let realpath = decodeURIComponent(url); let realpath = decodeURIComponent(url);
const webStaticDir = join(__dirname, '../../../resources/web-static'); const webStaticDir = join(__dirname, '../resources/web-static');
if (url.startsWith('./')) { if (url.startsWith('./')) {
// if is a file type, load the file in resources // if is a file type, load the file in resources
if (url.split('/').at(-1)?.includes('.')) { if (url.split('/').at(-1)?.includes('.')) {
@@ -34,6 +34,7 @@ export function registerProtocol() {
const url = request.url.replace(/^file:\/\//, ''); const url = request.url.replace(/^file:\/\//, '');
const realpath = toAbsolutePath(url); const realpath = toAbsolutePath(url);
callback(realpath); callback(realpath);
console.log('interceptFileProtocol realpath', request.url, realpath);
return true; return true;
}); });
+10
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;
};
@@ -1,6 +1,6 @@
import { BehaviorSubject, Subject } from 'rxjs'; import { BehaviorSubject, Subject } from 'rxjs';
import type { MainEventListener } from '../type'; import type { MainEventRegister } from '../type';
export interface UpdateMeta { export interface UpdateMeta {
version: string; version: string;
@@ -33,4 +33,4 @@ export const updaterEvents = {
sub.unsubscribe(); sub.unsubscribe();
}; };
}, },
} satisfies Record<string, MainEventListener>; } satisfies Record<string, MainEventRegister>;
+40
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);
}
}
@@ -1,9 +1,9 @@
import { join } from 'node:path'; import { join, resolve } from 'node:path';
import { parentPort } from 'node:worker_threads'; import { parentPort } from 'node:worker_threads';
import { AsyncCall } from 'async-call-rpc'; import { AsyncCall } from 'async-call-rpc';
import { MessagePortChannel } from '../utils'; import { MessageEventChannel } from '../utils';
const commandProxy: Record<string, (...args: any[]) => Promise<any>> = {}; const commandProxy: Record<string, (...args: any[]) => Promise<any>> = {};
@@ -12,17 +12,17 @@ if (!parentPort) {
} }
AsyncCall(commandProxy, { AsyncCall(commandProxy, {
channel: new MessagePortChannel(parentPort), channel: new MessageEventChannel(parentPort),
}); });
import('@toeverything/plugin-infra/manager').then( import('@toeverything/plugin-infra/manager').then(
({ rootStore, affinePluginsAtom }) => { ({ rootStore, affinePluginsAtom }) => {
const bookmarkPluginPath = join( const bookmarkPluginPath = join(
process.env.PLUGIN_DIR ?? '../../../plugins', process.env.PLUGIN_DIR ?? resolve(__dirname, '../plugins'),
'./bookmark-block/index.mjs' './bookmark-block/index.mjs'
); );
import(bookmarkPluginPath); import('file://' + bookmarkPluginPath);
rootStore.sub(affinePluginsAtom, () => { rootStore.sub(affinePluginsAtom, () => {
const plugins = rootStore.get(affinePluginsAtom); const plugins = rootStore.get(affinePluginsAtom);
Object.values(plugins).forEach(plugin => { Object.values(plugins).forEach(plugin => {
+193
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 };
}
@@ -1,10 +1,12 @@
import { contextBridge, ipcRenderer } from 'electron'; import { contextBridge, ipcRenderer } from 'electron';
(async () => { (async () => {
const affineApis = await import('./affine-apis'); const { appInfo, getAffineAPIs } = await import('./affine-apis');
contextBridge.exposeInMainWorld('apis', affineApis.apis); const { apis, events } = getAffineAPIs();
contextBridge.exposeInMainWorld('events', affineApis.events);
contextBridge.exposeInMainWorld('appInfo', affineApis.appInfo); contextBridge.exposeInMainWorld('appInfo', appInfo);
contextBridge.exposeInMainWorld('apis', apis);
contextBridge.exposeInMainWorld('events', events);
// Credit to microsoft/vscode // Credit to microsoft/vscode
const globals = { const globals = {
+35
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
}
+19 -6
View File
@@ -80,8 +80,21 @@ export const test = base.extend<{
// a random id to avoid conflicts between tests // a random id to avoid conflicts between tests
const id = generateUUID(); const id = generateUUID();
const ext = process.platform === 'win32' ? '.cmd' : ''; 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({ const electronApp = await electron.launch({
args: [resolve(__dirname, '..'), '--app-name', 'affine-test-' + id], args: [clonedDist],
executablePath: resolve( executablePath: resolve(
__dirname, __dirname,
'..', '..',
@@ -95,11 +108,11 @@ export const test = base.extend<{
colorScheme: 'light', colorScheme: 'light',
}); });
await use(electronApp); await use(electronApp);
// FIXME: the following does not work well on CI try {
// const sessionDataPath = await electronApp.evaluate(async ({ app }) => { await fs.rm(clonedDist, { recursive: true, force: true });
// return app.getPath('sessionData'); } catch (error) {
// }); console.log(error);
// await fs.rm(sessionDataPath, { recursive: true, force: true }); }
}, },
appInfo: async ({ electronApp }, use) => { appInfo: async ({ electronApp }, use) => {
const appInfo = await electronApp.evaluate(async ({ app }) => { const appInfo = await electronApp.evaluate(async ({ app }) => {
+1 -1
View File
@@ -13,7 +13,7 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"noImplicitOverride": true "noImplicitOverride": true
}, },
"include": ["./layers"], "include": ["./src"],
"exclude": ["node_modules", "out", "dist"], "exclude": ["node_modules", "out", "dist"],
"references": [ "references": [
{ {
+1 -1
View File
@@ -2,7 +2,7 @@
// not using import because it will break the declare module line below // not using import because it will break the declare module line below
// eslint-disable-next-line @typescript-eslint/triple-slash-reference // 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' { declare module '*.md' {
const text: string; const text: string;
-1
View File
@@ -17,7 +17,6 @@ if (typeof window !== 'undefined') {
debug.enable('*'); debug.enable('*');
console.warn('Debug logs enabled'); console.warn('Debug logs enabled');
} }
} else {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
debug.enable('*'); debug.enable('*');
console.warn('Debug logs enabled'); console.warn('Debug logs enabled');
+1 -1
View File
@@ -81,7 +81,7 @@
"@affine/native/*": ["./packages/native/*"], "@affine/native/*": ["./packages/native/*"],
// Development only // Development only
"@affine/electron/layers/*": ["./apps/electron/layers/*"] "@affine/electron/*": ["./apps/electron/src/*"]
} }
}, },
"include": [], "include": [],
+1 -1
View File
@@ -29,7 +29,7 @@ export default defineConfig({
], ],
// split tests that include native addons or not // split tests that include native addons or not
include: process.env.NATIVE_TEST include: process.env.NATIVE_TEST
? ['apps/electron/layers/**/*.spec.ts'] ? ['apps/electron/src/**/*.spec.ts']
: [ : [
'packages/**/*.spec.ts', 'packages/**/*.spec.ts',
'packages/**/*.spec.tsx', 'packages/**/*.spec.tsx',