feat(electron): use affine native (#2329)

This commit is contained in:
LongYinan
2023-05-17 12:36:51 +08:00
committed by GitHub
parent 017b9c8615
commit 93116c24f2
25 changed files with 488 additions and 259 deletions

View File

@@ -2,6 +2,8 @@ import assert from 'node:assert';
import path from 'node:path';
import fs from 'fs-extra';
import type { Subscription } from 'rxjs';
import { v4 } from 'uuid';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import * as Y from 'yjs';
@@ -99,6 +101,11 @@ const electronModule = {
handlers.push(callback);
registeredHandlers.set(name, handlers);
},
addEventListener: (...args: any[]) => {
// @ts-ignore
electronModule.app.on(...args);
},
removeEventListener: () => {},
},
BrowserWindow: {
getAllWindows: () => {
@@ -116,6 +123,8 @@ vi.doMock('electron', () => {
return electronModule;
});
let connectableSubscription: Subscription;
beforeEach(async () => {
const { registerHandlers } = await import('../register');
registerHandlers();
@@ -123,20 +132,24 @@ beforeEach(async () => {
// should also register events
const { registerEvents } = await import('../../events');
registerEvents();
await fs.mkdirp(SESSION_DATA_PATH);
const { database$ } = await import('../db/ensure-db');
connectableSubscription = database$.connect();
});
afterEach(async () => {
const { cleanupSQLiteDBs } = await import('../db/ensure-db');
await cleanupSQLiteDBs();
await fs.remove(SESSION_DATA_PATH);
// reset registered handlers
registeredHandlers.get('before-quit')?.forEach(fn => fn());
connectableSubscription.unsubscribe();
await fs.remove(SESSION_DATA_PATH);
});
describe('ensureSQLiteDB', () => {
test('should create db file on connection if it does not exist', async () => {
const id = 'test-workspace-id';
const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db');
const workspaceDB = await ensureSQLiteDB(id);
const file = workspaceDB.path;
@@ -146,70 +159,76 @@ describe('ensureSQLiteDB', () => {
test('when db file is removed', async () => {
// stub webContents.send
const sendStub = vi.fn();
browserWindow.webContents.send = sendStub;
const id = 'test-workspace-id';
const sendSpy = vi.spyOn(browserWindow.webContents, 'send');
const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db');
let workspaceDB = await ensureSQLiteDB(id);
const file = workspaceDB.path;
const fileExists = await fs.pathExists(file);
expect(fileExists).toBe(true);
// Can't remove file on Windows, because the sqlite is still holding the file handle
if (process.platform === 'win32') {
return;
}
await fs.remove(file);
// wait for 1000ms for file watcher to detect file removal
// wait for 2000ms for file watcher to detect file removal
await delay(2000);
expect(sendStub).toBeCalledWith('db:onDBFileMissing', id);
expect(sendSpy).toBeCalledWith('db:onDBFileMissing', id);
// ensureSQLiteDB should recreate the db file
workspaceDB = await ensureSQLiteDB(id);
const fileExists2 = await fs.pathExists(file);
expect(fileExists2).toBe(true);
sendSpy.mockRestore();
});
test('when db file is updated', async () => {
// stub webContents.send
const sendStub = vi.fn();
browserWindow.webContents.send = sendStub;
const id = 'test-workspace-id';
const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db');
const { dbSubjects } = await import('../../events/db');
const workspaceDB = await ensureSQLiteDB(id);
const file = workspaceDB.path;
const fileExists = await fs.pathExists(file);
expect(fileExists).toBe(true);
// wait to make sure
await delay(500);
const dbUpdateSpy = vi.spyOn(dbSubjects.dbFileUpdate, 'next');
await delay(100);
// writes some data to the db file
await fs.appendFile(file, 'random-data', { encoding: 'binary' });
// write again
await fs.appendFile(file, 'random-data', { encoding: 'binary' });
// wait for 200ms for file watcher to detect file change
// wait for 2000ms for file watcher to detect file change
await delay(2000);
expect(sendStub).toBeCalledWith('db:onDBFileUpdate', id);
expect(dbUpdateSpy).toBeCalledWith(id);
dbUpdateSpy.mockRestore();
});
});
describe('workspace handlers', () => {
test('list all workspace ids', async () => {
const ids = ['test-workspace-id', 'test-workspace-id-2'];
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)).toEqual(ids);
expect(list.map(([id]) => id).sort()).toEqual(ids.sort());
});
test('delete workspace', async () => {
const ids = ['test-workspace-id', 'test-workspace-id-2'];
// @TODO dispatch is hanging on Windows
if (process.platform === 'win32') {
return;
}
const ids = [v4(), v4()];
const { ensureSQLiteDB } = await import('../db/ensure-db');
await Promise.all(ids.map(id => ensureSQLiteDB(id)));
await dispatch('workspace', 'delete', 'test-workspace-id-2');
await dispatch('workspace', 'delete', ids[1]);
const list = await dispatch('workspace', 'list');
expect(list.map(([id]) => id)).toEqual(['test-workspace-id']);
expect(list.map(([id]) => id)).toEqual([ids[0]]);
});
});
@@ -244,7 +263,7 @@ describe('UI handlers', () => {
describe('db handlers', () => {
test('apply doc and get doc updates', async () => {
const workspaceId = 'test-workspace-id';
const workspaceId = v4();
const bin = await dispatch('db', 'getDocAsUpdates', workspaceId);
// ? is this a good test?
expect(bin.every((byte: number) => byte === 0)).toBe(true);
@@ -264,13 +283,13 @@ describe('db handlers', () => {
});
test('get non existent blob', async () => {
const workspaceId = 'test-workspace-id';
const workspaceId = v4();
const bin = await dispatch('db', 'getBlob', workspaceId, 'non-existent-id');
expect(bin).toBeNull();
});
test('list blobs (empty)', async () => {
const workspaceId = 'test-workspace-id';
const workspaceId = v4();
const list = await dispatch('db', 'getPersistedBlobs', workspaceId);
expect(list).toEqual([]);
});
@@ -318,7 +337,7 @@ describe('dialog handlers', () => {
const mockShowItemInFolder = vi.fn();
electronModule.shell.showItemInFolder = mockShowItemInFolder;
const id = 'test-workspace-id';
const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db');
const db = await ensureSQLiteDB(id);
@@ -334,13 +353,15 @@ describe('dialog handlers', () => {
electronModule.dialog.showSaveDialog = mockShowSaveDialog;
electronModule.shell.showItemInFolder = mockShowItemInFolder;
const id = 'test-workspace-id';
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 () => {
@@ -352,7 +373,7 @@ describe('dialog handlers', () => {
electronModule.dialog.showSaveDialog = mockShowSaveDialog;
electronModule.shell.showItemInFolder = mockShowItemInFolder;
const id = 'test-workspace-id';
const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db');
await ensureSQLiteDB(id);
@@ -403,11 +424,13 @@ describe('dialog handlers', () => {
const res = await dispatch('dialog', 'loadDBFile');
expect(mockShowOpenDialog).toBeCalled();
expect(res.error).toBe('DB_FILE_INVALID');
electronModule.dialog = {};
});
test('loadDBFile', async () => {
// we use ensureSQLiteDB to create a valid db file
const id = 'test-workspace-id';
const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db');
const db = await ensureSQLiteDB(id);
@@ -417,6 +440,11 @@ describe('dialog handlers', () => {
await fs.ensureDir(basePath);
await fs.copyFile(db.path, originDBFilePath);
// on Windows, we skip this test because we can't delete the db file
if (process.platform === 'win32') {
return;
}
// remove db
await fs.remove(db.path);
@@ -440,19 +468,19 @@ describe('dialog handlers', () => {
});
test('moveDBFile', async () => {
const newPath = path.join(SESSION_DATA_PATH, 'affine-test', 'xxx');
const newPath = path.join(SESSION_DATA_PATH, 'xxx');
const mockShowSaveDialog = vi.fn(() => {
return { filePath: newPath };
}) as any;
electronModule.dialog.showSaveDialog = mockShowSaveDialog;
const id = 'test-workspace-id';
const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db');
await ensureSQLiteDB(id);
const res = await dispatch('dialog', 'moveDBFile', id);
expect(mockShowSaveDialog).toBeCalled();
expect(res.filePath).toBe(newPath);
electronModule.dialog = {};
});
test('moveDBFile (skipped)', async () => {
@@ -461,12 +489,13 @@ describe('dialog handlers', () => {
}) as any;
electronModule.dialog.showSaveDialog = mockShowSaveDialog;
const id = 'test-workspace-id';
const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db');
await ensureSQLiteDB(id);
const res = await dispatch('dialog', 'moveDBFile', id);
expect(mockShowSaveDialog).toBeCalled();
expect(res.filePath).toBe(undefined);
electronModule.dialog = {};
});
});

View File

@@ -1,94 +1,160 @@
import { watch } from 'chokidar';
import type { NotifyEvent } from '@affine/native/event';
import { createFSWatcher } from '@affine/native/fs-watcher';
import { app } from 'electron';
import {
connectable,
defer,
from,
fromEvent,
identity,
lastValueFrom,
Observable,
ReplaySubject,
Subject,
} from 'rxjs';
import {
debounceTime,
exhaustMap,
filter,
groupBy,
ignoreElements,
mergeMap,
shareReplay,
startWith,
switchMap,
take,
takeUntil,
tap,
} from 'rxjs/operators';
import { appContext } from '../../context';
import { subjects } from '../../events';
import { logger } from '../../logger';
import { debounce, ts } from '../../utils';
import { ts } from '../../utils';
import type { WorkspaceSQLiteDB } from './sqlite';
import { openWorkspaceDatabase } from './sqlite';
const dbMapping = new Map<string, Promise<WorkspaceSQLiteDB>>();
const dbWatchers = new Map<string, () => void>();
const databaseInput$ = new Subject<string>();
export const databaseConnector$ = new ReplaySubject<WorkspaceSQLiteDB>();
const groupedDatabaseInput$ = databaseInput$.pipe(groupBy(identity));
export const database$ = connectable(
groupedDatabaseInput$.pipe(
mergeMap(workspaceDatabase$ =>
workspaceDatabase$.pipe(
// only open the first db with the same workspaceId, and emit it to the downstream
exhaustMap(workspaceId => {
logger.info('[ensureSQLiteDB] open db connection', workspaceId);
return from(openWorkspaceDatabase(appContext, workspaceId)).pipe(
switchMap(db => {
return startWatchingDBFile(db).pipe(
// ignore all events and only emit the db to the downstream
ignoreElements(),
startWith(db)
);
})
);
}),
shareReplay(1)
)
),
tap({
complete: () => {
logger.info('[FSWatcher] close all watchers');
createFSWatcher().close();
},
})
),
{
connector: () => databaseConnector$,
resetOnDisconnect: true,
}
);
export const databaseConnectableSubscription = database$.connect();
// 1. File delete
// 2. File move
// - on Linux, it's `type: { modify: { kind: 'rename', mode: 'from' } }`
// - on Windows, it's `type: { remove: { kind: 'any' } }`
// - on macOS, it's `type: { modify: { kind: 'rename', mode: 'any' } }`
export function isRemoveOrMoveEvent(event: NotifyEvent) {
return (
typeof event.type === 'object' &&
('remove' in event.type ||
('modify' in event.type &&
event.type.modify.kind === 'rename' &&
(event.type.modify.mode === 'from' ||
event.type.modify.mode === 'any')))
);
}
// if we removed the file, we will stop watching it
function startWatchingDBFile(db: WorkspaceSQLiteDB) {
if (dbWatchers.has(db.workspaceId)) {
return dbWatchers.get(db.workspaceId);
}
logger.info('watch db file', db.path);
const watcher = watch(db.path);
const debounceOnChange = debounce(() => {
logger.info(
'db file changed on disk',
db.workspaceId,
ts() - db.lastUpdateTime,
'ms'
const FSWatcher = createFSWatcher();
return new Observable<NotifyEvent>(subscriber => {
logger.info('[FSWatcher] start watching db file', db.workspaceId);
const subscription = FSWatcher.watch(db.path, {
recursive: false,
}).subscribe(
event => {
logger.info('[FSWatcher]', event);
subscriber.next(event);
// remove file or move file, complete the observable and close db
if (isRemoveOrMoveEvent(event)) {
subscriber.complete();
}
},
err => {
subscriber.error(err);
}
);
// reconnect db
db.reconnectDB();
subjects.db.dbFileUpdate.next(db.workspaceId);
}, 1000);
watcher.on('change', () => {
const currentTime = ts();
if (currentTime - db.lastUpdateTime > 100) {
debounceOnChange();
}
});
dbWatchers.set(db.workspaceId, () => {
watcher.close();
});
// todo: there is still a possibility that the file is deleted
// but we didn't get the event soon enough and another event tries to
// access the db
watcher.on('unlink', () => {
logger.info('db file missing', db.workspaceId);
subjects.db.dbFileMissing.next(db.workspaceId);
// cleanup
watcher.close().then(() => {
return () => {
// destroy on unsubscribe
logger.info('[FSWatcher] cleanup db file watcher', db.workspaceId);
db.destroy();
dbWatchers.delete(db.workspaceId);
dbMapping.delete(db.workspaceId);
});
});
subscription.unsubscribe();
};
}).pipe(
debounceTime(1000),
filter(event => !isRemoveOrMoveEvent(event)),
tap({
next: () => {
logger.info(
'[FSWatcher] db file changed on disk',
db.workspaceId,
ts() - db.lastUpdateTime,
'ms'
);
db.reconnectDB();
subjects.db.dbFileUpdate.next(db.workspaceId);
},
complete: () => {
// todo: there is still a possibility that the file is deleted
// but we didn't get the event soon enough and another event tries to
// access the db
logger.info('[FSWatcher] db file missing', db.workspaceId);
subjects.db.dbFileMissing.next(db.workspaceId);
db.destroy();
},
}),
takeUntil(defer(() => fromEvent(app, 'before-quit')))
);
}
export async function ensureSQLiteDB(id: string) {
let workspaceDB = dbMapping.get(id);
if (!workspaceDB) {
logger.info('[ensureSQLiteDB] open db connection', id);
workspaceDB = openWorkspaceDatabase(appContext, id);
dbMapping.set(id, workspaceDB);
startWatchingDBFile(await workspaceDB);
}
return await workspaceDB;
export function ensureSQLiteDB(id: string) {
const deferValue = lastValueFrom(
database$.pipe(
filter(db => db.workspaceId === id && db.db.open),
take(1),
tap({
error: err => {
logger.error('[ensureSQLiteDB] error', err);
},
})
)
);
databaseInput$.next(id);
return deferValue;
}
export async function disconnectSQLiteDB(id: string) {
const dbp = dbMapping.get(id);
if (dbp) {
const db = await dbp;
logger.info('close db connection', id);
db.destroy();
dbWatchers.get(id)?.();
dbWatchers.delete(id);
dbMapping.delete(id);
}
}
export async function cleanupSQLiteDBs() {
for (const [id] of dbMapping) {
logger.info('close db connection', id);
await disconnectSQLiteDB(id);
}
dbMapping.clear();
dbWatchers.clear();
}
app?.on('before-quit', async () => {
await cleanupSQLiteDBs();
});

View File

@@ -42,6 +42,7 @@ export class WorkspaceSQLiteDB {
ydoc = new Y.Doc();
firstConnect = false;
lastUpdateTime = ts();
destroyed = false;
constructor(public path: string, public workspaceId: string) {
this.db = this.reconnectDB();
@@ -58,7 +59,7 @@ export class WorkspaceSQLiteDB {
};
reconnectDB = () => {
logger.log('open db', this.workspaceId);
logger.log('[WorkspaceSQLiteDB] open db', this.workspaceId);
if (this.db) {
this.db.close();
}
@@ -224,8 +225,9 @@ export async function openWorkspaceDatabase(
}
export function isValidDBFile(path: string) {
let db: Database | null = null;
try {
const db = sqlite(path);
db = sqlite(path);
// check if db has two tables, one for updates and onefor blobs
const statement = db.prepare(
`SELECT name FROM sqlite_schema WHERE type='table'`
@@ -239,6 +241,7 @@ export function isValidDBFile(path: string) {
return true;
} catch (error) {
logger.error('isValidDBFile', error);
db?.close();
return false;
}
}

View File

@@ -6,7 +6,8 @@ import { nanoid } from 'nanoid';
import { appContext } from '../../context';
import { logger } from '../../logger';
import { ensureSQLiteDB } from '../db/ensure-db';
import { ensureSQLiteDB, isRemoveOrMoveEvent } from '../db/ensure-db';
import type { WorkspaceSQLiteDB } from '../db/sqlite';
import { getWorkspaceDBPath, isValidDBFile } from '../db/sqlite';
import { listWorkspaces } from '../workspace/workspace';
@@ -232,17 +233,29 @@ export async function moveDBFile(
workspaceId: string,
dbFileLocation?: string
): Promise<MoveDBFileResult> {
let db: WorkspaceSQLiteDB | null = null;
try {
const db = await ensureSQLiteDB(workspaceId);
const { moveFile, FsWatcher } = await import('@affine/native');
db = await ensureSQLiteDB(workspaceId);
// get the real file path of db
const realpath = await fs.realpath(db.path);
const isLink = realpath !== db.path;
const watcher = FsWatcher.watch(realpath, { recursive: false });
const waitForRemove = new Promise<void>(resolve => {
const subscription = watcher.subscribe(event => {
if (isRemoveOrMoveEvent(event)) {
subscription.unsubscribe();
// resolve after FSWatcher in `database$` is fired
setImmediate(() => {
resolve();
});
}
});
});
const newFilePath =
dbFileLocation ||
dbFileLocation ??
(
getFakedResult() ||
getFakedResult() ??
(await dialog.showSaveDialog({
properties: ['showOverwriteConfirmation'],
title: 'Move Workspace Storage',
@@ -263,32 +276,39 @@ export async function moveDBFile(
};
}
db.db.close();
if (await fs.pathExists(newFilePath)) {
return {
error: 'FILE_ALREADY_EXISTS',
};
}
db.db.close();
if (isLink) {
// remove the old link to unblock new link
await fs.unlink(db.path);
}
await fs.move(realpath, newFilePath, {
overwrite: true,
});
logger.info(`[moveDBFile] move ${realpath} -> ${newFilePath}`);
await moveFile(realpath, newFilePath);
await fs.ensureSymlink(newFilePath, db.path, 'file');
logger.info(`openMoveDBFileDialog symlink: ${realpath} -> ${newFilePath}`);
db.reconnectDB();
logger.info(`[moveDBFile] symlink: ${realpath} -> ${newFilePath}`);
// wait for the file move event emits to the FileWatcher in database$ in ensure-db.ts
// so that the db will be destroyed and we can call the `ensureSQLiteDB` in the next step
// or the FileWatcher will continue listen on the `realpath` and emit file change events
// then the database will reload while receiving these events; and the moved database file will be recreated while reloading database
await waitForRemove;
logger.info(`removed`);
await ensureSQLiteDB(workspaceId);
return {
filePath: newFilePath,
};
} catch (err) {
logger.error('moveDBFile', err);
db?.destroy();
logger.error('[moveDBFile]', err);
return {
error: 'UNKNOWN_ERROR',
};

View File

@@ -18,7 +18,7 @@ export const dialogHandlers = {
saveDBFileAs: async (_, workspaceId: string) => {
return saveDBFileAs(workspaceId);
},
moveDBFile: async (_, workspaceId: string, dbFileLocation?: string) => {
moveDBFile: (_, workspaceId: string, dbFileLocation?: string) => {
return moveDBFile(workspaceId, dbFileLocation);
},
selectDBFileLocation: async () => {

View File

@@ -44,6 +44,7 @@
"@electron/remote": "2.0.9",
"@types/better-sqlite3": "^7.6.4",
"@types/fs-extra": "^11.0.1",
"@types/uuid": "^9.0.1",
"cross-env": "7.0.3",
"electron": "24.3.0",
"electron-log": "^5.0.0-beta.23",
@@ -54,9 +55,11 @@
"playwright": "^1.33.0",
"ts-node": "^10.9.1",
"undici": "^5.22.1",
"uuid": "^9.0.0",
"zx": "^7.2.2"
},
"dependencies": {
"@affine/native": "workspace:*",
"better-sqlite3": "^8.3.0",
"chokidar": "^3.5.3",
"electron-updater": "^5.3.0",

View File

@@ -8,6 +8,11 @@ import { config } from './common.mjs';
const NODE_ENV =
process.env.NODE_ENV === 'development' ? 'development' : 'production';
if (process.platform === 'win32') {
$.shell = true;
$.prefix = '';
}
async function buildLayers() {
const common = config();
await esbuild.build(common.preload);

View File

@@ -12,16 +12,6 @@ const DEV_SERVER_URL = process.env.DEV_SERVER_URL;
/** @type 'production' | 'development'' */
const mode = (process.env.NODE_ENV = process.env.NODE_ENV || 'development');
const nativeNodeModulesPlugin = {
name: 'native-node-modules',
setup(build) {
// Mark native Node.js modules as external
build.onResolve({ filter: /\.node$/, namespace: 'file' }, args => {
return { path: args.path, external: true };
});
},
};
// List of env that will be replaced by esbuild
const ENV_MACROS = ['AFFINE_GOOGLE_CLIENT_ID', 'AFFINE_GOOGLE_CLIENT_SECRET'];
@@ -49,10 +39,19 @@ export const config = () => {
bundle: true,
target: `node${NODE_MAJOR_VERSION}`,
platform: 'node',
external: ['electron', 'yjs', 'better-sqlite3', 'electron-updater'],
plugins: [nativeNodeModulesPlugin],
external: [
'electron',
'yjs',
'better-sqlite3',
'electron-updater',
'@affine/native-*',
],
define: define,
format: 'cjs',
loader: {
'.node': 'copy',
},
assetNames: '[name]',
},
preload: {
entryPoints: [resolve(root, './layers/preload/src/index.ts')],
@@ -61,7 +60,6 @@ export const config = () => {
target: `node${NODE_MAJOR_VERSION}`,
platform: 'node',
external: ['electron', '../main/exposed-meta'],
plugins: [nativeNodeModulesPlugin],
define: define,
},
};

View File

@@ -1,7 +1,3 @@
#!/usr/bin/env zx
/* eslint-disable @typescript-eslint/no-restricted-imports */
import 'zx/globals';
const mainDistDir = path.resolve(__dirname, '../dist/layers/main');
// be careful and avoid any side effects in

View File

@@ -9,14 +9,16 @@
"types": ["node"],
"outDir": "dist",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true
"resolveJsonModule": true
},
"include": ["**/*.ts", "**/*.tsx", "package.json"],
"exclude": ["out", "dist", "node_modules"],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "../../packages/native"
}
],
"ts-node": {