fix: some improvements to electron app (#2089)

This commit is contained in:
Peng Xiao
2023-04-25 01:53:21 +08:00
committed by GitHub
parent b73e9189ef
commit c27c241482
18 changed files with 543 additions and 199 deletions
+18
View File
@@ -14,6 +14,24 @@ yarn generate-assets
yarn dev # or yarn prod for production build
```
## Troubleshooting
### better-sqlite3 error
When running tests or starting electron, you may encounter the following error:
> Error: The module 'apps/electron/node_modules/better-sqlite3/build/Release/better_sqlite3.node'
This is due to the fact that the `better-sqlite3` package is built for the Node.js version in Electron & in your machine. To fix this, run the following command based on different cases:
```sh
# for running unit tests, we are not using Electron's node:
yarn rebuild better-sqlite3
# for running Electron, we are using Electron's node:
yarn postinstall
```
## Credits
Most of the boilerplate code is generously borrowed from the following
+5
View File
@@ -0,0 +1,5 @@
// This file contains the main process events
// It will guide preload and main process on the correct event types and payloads
export interface MainEventMap {
'main:on-db-update': (workspaceId: string) => void;
}
@@ -0,0 +1 @@
tmp
@@ -0,0 +1,226 @@
import assert from 'node:assert';
import path from 'node:path';
import fs from 'fs-extra';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import * as Y from 'yjs';
const registeredHandlers = new Map<string, (...args: any[]) => any>();
// common mock dispatcher for ipcMain.handle and app.on
async function dispatch(key: string, ...args: any[]) {
const handler = registeredHandlers.get(key);
assert(handler);
return await handler(null, ...args);
}
const APP_PATH = path.join(__dirname, './tmp');
const browserWindow = {
isDestroyed: () => {
return false;
},
setWindowButtonVisibility: (v: boolean) => {
// will be stubbed later
},
webContents: {
send: (type: string, ...args: any[]) => {
// ...
},
},
};
const ipcMain = {
handle: (key: string, callback: (...args: any[]) => any) => {
registeredHandlers.set(key, callback);
},
};
const nativeTheme = {
themeSource: 'light',
};
function compareBuffer(a: Uint8Array, b: Uint8Array) {
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}
// dynamically import handlers so that we can inject local variables to mocks
vi.doMock('electron', () => {
return {
app: {
getPath: (name: string) => {
assert(name === 'appData');
return APP_PATH;
},
name: 'affine-test',
on: (name: string, callback: (...args: any[]) => any) => {
registeredHandlers.set(name, callback);
},
},
BrowserWindow: {
getAllWindows: () => {
return [browserWindow];
},
},
nativeTheme: nativeTheme,
ipcMain,
};
});
beforeEach(async () => {
// clean up tmp folder
const { registerHandlers } = await import('../handlers');
registerHandlers();
});
afterEach(async () => {
const { cleanupWorkspaceDBs } = await import('../handlers');
cleanupWorkspaceDBs();
await fs.remove(APP_PATH);
});
describe('ensureWorkspaceDB', () => {
test('should create db file on connection if it does not exist', async () => {
const id = 'test-workspace-id';
const { ensureWorkspaceDB } = await import('../handlers');
const workspaceDB = await ensureWorkspaceDB(id);
const file = workspaceDB.path;
const fileExists = await fs.pathExists(file);
expect(fileExists).toBe(true);
});
});
describe('workspace handlers', () => {
test('list all workspace ids', async () => {
const ids = ['test-workspace-id', 'test-workspace-id-2'];
const { ensureWorkspaceDB } = await import('../handlers');
await Promise.all(ids.map(id => ensureWorkspaceDB(id)));
const list = await dispatch('workspace:list');
expect(list).toEqual(ids);
});
test('delete workspace', async () => {
const ids = ['test-workspace-id', 'test-workspace-id-2'];
const { ensureWorkspaceDB } = await import('../handlers');
await Promise.all(ids.map(id => ensureWorkspaceDB(id)));
await dispatch('workspace:delete', 'test-workspace-id-2');
const list = await dispatch('workspace:list');
expect(list).toEqual(['test-workspace-id']);
});
});
describe('UI handlers', () => {
test('theme-change', async () => {
await dispatch('ui:theme-change', 'dark');
expect(nativeTheme.themeSource).toBe('dark');
await dispatch('ui:theme-change', '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:sidebar-visibility-change', true);
expect(setWindowButtonVisibility).toBeCalledWith(true);
await dispatch('ui:sidebar-visibility-change', 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:sidebar-visibility-change', true);
expect(setWindowButtonVisibility).not.toBeCalled();
vi.unstubAllGlobals();
});
});
describe('db handlers', () => {
test('will reconnect on activate', async () => {
const { ensureWorkspaceDB } = await import('../handlers');
const workspaceDB = await ensureWorkspaceDB('test-workspace-id');
const instance = vi.spyOn(workspaceDB, 'reconnectDB');
await dispatch('activate');
expect(instance).toBeCalled();
});
test('apply doc and get doc updates', async () => {
const workspaceId = 'test-workspace-id';
const bin = await dispatch('db:get-doc', 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:apply-doc-update', workspaceId, bin2);
const bin3 = await dispatch('db:get-doc', 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 doc', async () => {
const workspaceId = 'test-workspace-id';
const bin = await dispatch('db:get-blob', workspaceId, 'non-existent-id');
expect(bin).toBeNull();
});
test('list blobs (empty)', async () => {
const workspaceId = 'test-workspace-id';
const list = await dispatch('db:get-persisted-blobs', 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:add-blob', workspaceId, 'testBin', testBin);
// get blob
expect(
compareBuffer(
await dispatch('db:get-blob', workspaceId, 'testBin'),
testBin
)
).toBe(true);
// add another blob
await dispatch('db:add-blob', workspaceId, 'testBin2', testBin2);
expect(
compareBuffer(
await dispatch('db:get-blob', workspaceId, 'testBin2'),
testBin2
)
).toBe(true);
// list blobs
let lists = await dispatch('db:get-persisted-blobs', workspaceId);
expect(lists).toHaveLength(2);
expect(lists).toContain('testBin');
expect(lists).toContain('testBin2');
// delete blob
await dispatch('db:delete-blob', workspaceId, 'testBin');
lists = await dispatch('db:get-persisted-blobs', workspaceId);
expect(lists).toEqual(['testBin2']);
});
});
@@ -0,0 +1,7 @@
import type { WatchListener } from 'fs-extra';
import fs from 'fs-extra';
export function watchFile(path: string, callback: WatchListener<string>) {
const watcher = fs.watch(path, callback);
return () => watcher.close();
}
+18 -22
View File
@@ -36,24 +36,10 @@ interface BlobRow {
export class WorkspaceDatabase {
sqliteDB: Database;
ydoc = new Y.Doc();
ready: Promise<Uint8Array>;
firstConnect = false;
constructor(public path: string) {
this.sqliteDB = this.reconnectDB();
logger.log('open db', path);
this.ydoc.on('update', update => {
this.addUpdateToSQLite(update);
});
this.ready = (async () => {
const updates = await this.getUpdates();
updates.forEach(update => {
Y.applyUpdate(this.ydoc, update.data);
});
return this.getEncodedDocUpdates();
})();
}
// release resources
@@ -67,15 +53,22 @@ export class WorkspaceDatabase {
if (this.sqliteDB) {
this.sqliteDB.close();
}
// use cached version?
const db = sqlite(this.path);
// const db = new sqlite.Database(this.path, error => {
// if (error) {
// logger.error('open db error', error);
// }
// });
this.sqliteDB = db;
const db = (this.sqliteDB = sqlite(this.path));
db.exec(schemas.join(';'));
if (!this.firstConnect) {
this.ydoc.on('update', this.addUpdateToSQLite);
}
const updates = this.getUpdates();
updates.forEach(update => {
Y.applyUpdate(this.ydoc, update.data);
});
this.firstConnect = true;
return db;
};
@@ -153,12 +146,15 @@ export class WorkspaceDatabase {
}
};
// batch write instead write per key stroke?
private addUpdateToSQLite = (data: Uint8Array) => {
try {
const start = performance.now();
const statement = this.sqliteDB.prepare(
'INSERT INTO updates (data) VALUES (?)'
);
statement.run(data);
logger.debug('addUpdateToSQLite', performance.now() - start, 'ms');
} catch (error) {
logger.error('addUpdateToSQLite', error);
}
+45 -2
View File
@@ -12,26 +12,69 @@ import { logger } from '../../logger';
import { isMacOS } from '../../utils';
import { appContext } from './context';
import { exportDatabase } from './data/export';
import { watchFile } from './data/fs-watch';
import type { WorkspaceDatabase } from './data/sqlite';
import { openWorkspaceDatabase } from './data/sqlite';
import { deleteWorkspace, listWorkspaces } from './data/workspace';
import { getExchangeTokenParams, oauthEndpoint } from './google-auth';
import { sendMainEvent } from './send-main-event';
let currentWorkspaceId = '';
const dbMapping = new Map<string, WorkspaceDatabase>();
const dbWatchers = new Map<string, () => void>();
const dBLastUse = new Map<string, number>();
async function ensureWorkspaceDB(id: string) {
export async function ensureWorkspaceDB(id: string) {
let workspaceDB = dbMapping.get(id);
if (!workspaceDB) {
// hmm... potential race condition?
workspaceDB = await openWorkspaceDatabase(appContext, id);
dbMapping.set(id, workspaceDB);
logger.info('watch db file', workspaceDB.path);
dbWatchers.set(
id,
watchFile(workspaceDB.path, (event, filename) => {
const minTime = 1000;
logger.debug(
'db file changed',
event,
filename,
Date.now() - dBLastUse.get(id)!
);
if (Date.now() - dBLastUse.get(id)! < minTime || !filename) {
logger.debug('skip db update');
return;
}
sendMainEvent('main:on-db-update', id);
// handle DB file update by other process
dbWatchers.get(id)?.();
dbMapping.delete(id);
dbWatchers.delete(id);
ensureWorkspaceDB(id);
})
);
}
await workspaceDB.ready;
dBLastUse.set(id, Date.now());
return workspaceDB;
}
export async function cleanupWorkspaceDBs() {
for (const [id, db] of dbMapping) {
logger.info('close db connection', id);
db.destroy();
dbWatchers.get(id)?.();
}
dbMapping.clear();
dbWatchers.clear();
dBLastUse.clear();
}
function registerWorkspaceHandlers() {
ipcMain.handle('workspace:list', async _ => {
logger.info('list workspaces');
@@ -0,0 +1,14 @@
import { BrowserWindow } from 'electron';
import type { MainEventMap } from '../../main-events';
function getActiveWindows() {
return BrowserWindow.getAllWindows().filter(win => !win.isDestroyed());
}
export function sendMainEvent<T extends keyof MainEventMap>(
type: T,
...args: Parameters<MainEventMap[T]>
) {
getActiveWindows().forEach(win => win.webContents.send(type, ...args));
}
+4 -10
View File
@@ -1,12 +1,6 @@
/* eslint-disable @typescript-eslint/consistent-type-imports */
interface Window {
/**
* After analyzing the `exposeInMainWorld` calls,
* `packages/preload/exposedInMainWorld.d.ts` file will be generated.
* It contains all interfaces.
* `packages/preload/exposedInMainWorld.d.ts` file is required for TS is `renderer`
*
* @see https://github.com/cawa-93/dts-for-context-bridge
*/
readonly apis: { db: { getDoc: (id: string) => Promise<Uint8Array>; applyDocUpdate: (id: string, update: Uint8Array) => Promise<any>; addBlob: (workspaceId: string, key: string, data: Uint8Array) => Promise<any>; getBlob: (workspaceId: string, key: string) => Promise<Uint8Array>; deleteBlob: (workspaceId: string, key: string) => Promise<any>; getPersistedBlobs: (workspaceId: string) => Promise<string[]>; }; workspace: { list: () => Promise<string[]>; delete: (id: string) => Promise<void>; }; openLoadDBFileDialog: () => Promise<any>; openSaveDBFileDialog: () => Promise<any>; onThemeChange: (theme: string) => Promise<any>; onSidebarVisibilityChange: (visible: boolean) => Promise<any>; onWorkspaceChange: (workspaceId: string) => Promise<any>; openDBFolder: () => Promise<any>; getGoogleOauthCode: () => Promise<{ requestInit: RequestInit; url: string; }>; updateEnv: (env: string, value: string) => void; };
readonly appInfo: { electron: boolean; isMacOS: boolean; };
apis: typeof import('./src/affine-apis').apis;
appInfo: typeof import('./src/affine-apis').appInfo;
}
@@ -0,0 +1,81 @@
// NOTE: we will generate preload types from this file
import { ipcRenderer } from 'electron';
import type { MainEventMap } from '../../main-events';
// main -> renderer
function onMainEvent<T extends keyof MainEventMap>(
eventName: T,
callback: MainEventMap[T]
): () => void {
// @ts-expect-error fix me later
const fn = (_, ...args) => callback(...args);
ipcRenderer.on(eventName, fn);
return () => ipcRenderer.off(eventName, fn);
}
const apis = {
db: {
// workspace providers
getDoc: (id: string): Promise<Uint8Array | null> =>
ipcRenderer.invoke('db:get-doc', id),
applyDocUpdate: (id: string, update: Uint8Array) =>
ipcRenderer.invoke('db:apply-doc-update', id, update),
addBlob: (workspaceId: string, key: string, data: Uint8Array) =>
ipcRenderer.invoke('db:add-blob', workspaceId, key, data),
getBlob: (workspaceId: string, key: string): Promise<Uint8Array | null> =>
ipcRenderer.invoke('db:get-blob', workspaceId, key),
deleteBlob: (workspaceId: string, key: string) =>
ipcRenderer.invoke('db:delete-blob', workspaceId, key),
getPersistedBlobs: (workspaceId: string): Promise<string[]> =>
ipcRenderer.invoke('db:get-persisted-blobs', workspaceId),
// listeners
onDBUpdate: (callback: (workspaceId: string) => void) => {
return onMainEvent('main:on-db-update', callback);
},
},
workspace: {
list: (): Promise<string[]> => ipcRenderer.invoke('workspace:list'),
delete: (id: string): Promise<void> =>
ipcRenderer.invoke('workspace:delete', id),
// create will be implicitly called by db functions
},
openLoadDBFileDialog: () => ipcRenderer.invoke('ui:open-load-db-file-dialog'),
openSaveDBFileDialog: () => ipcRenderer.invoke('ui:open-save-db-file-dialog'),
// ui
onThemeChange: (theme: string) =>
ipcRenderer.invoke('ui:theme-change', theme),
onSidebarVisibilityChange: (visible: boolean) =>
ipcRenderer.invoke('ui:sidebar-visibility-change', visible),
onWorkspaceChange: (workspaceId: string) =>
ipcRenderer.invoke('ui:workspace-change', workspaceId),
openDBFolder: () => ipcRenderer.invoke('ui:open-db-folder'),
/**
* Try sign in using Google and return a request object to exchange the code for a token
* Not exchange in Node side because it is easier to do it in the renderer with VPN
*/
getGoogleOauthCode: (): Promise<{ requestInit: RequestInit; url: string }> =>
ipcRenderer.invoke('ui:get-google-oauth-code'),
/**
* Secret backdoor to update environment variables in main process
*/
updateEnv: (env: string, value: string) => {
ipcRenderer.invoke('main:env-update', env, value);
},
};
const appInfo = {
electron: true,
};
export { apis, appInfo };
+4 -68
View File
@@ -2,9 +2,9 @@
* @module preload
*/
import { contextBridge, ipcRenderer } from 'electron';
import { contextBridge } from 'electron';
import { isMacOS } from '../../utils';
import * as affineApis from './affine-apis';
/**
* The "Main World" is the JavaScript context that your main renderer code runs in.
@@ -13,69 +13,5 @@ import { isMacOS } from '../../utils';
* @see https://www.electronjs.org/docs/api/context-bridge
*/
/**
* After analyzing the `exposeInMainWorld` calls,
* `packages/preload/exposedInMainWorld.d.ts` file will be generated.
* It contains all interfaces.
* `packages/preload/exposedInMainWorld.d.ts` file is required for TS is `renderer`
*
* @see https://github.com/cawa-93/dts-for-context-bridge
*/
contextBridge.exposeInMainWorld('apis', {
db: {
// workspace providers
getDoc: (id: string): Promise<Uint8Array | null> =>
ipcRenderer.invoke('db:get-doc', id),
applyDocUpdate: (id: string, update: Uint8Array) =>
ipcRenderer.invoke('db:apply-doc-update', id, update),
addBlob: (workspaceId: string, key: string, data: Uint8Array) =>
ipcRenderer.invoke('db:add-blob', workspaceId, key, data),
getBlob: (workspaceId: string, key: string): Promise<Uint8Array | null> =>
ipcRenderer.invoke('db:get-blob', workspaceId, key),
deleteBlob: (workspaceId: string, key: string) =>
ipcRenderer.invoke('db:delete-blob', workspaceId, key),
getPersistedBlobs: (workspaceId: string): Promise<string[]> =>
ipcRenderer.invoke('db:get-persisted-blobs', workspaceId),
},
workspace: {
list: (): Promise<string[]> => ipcRenderer.invoke('workspace:list'),
delete: (id: string): Promise<void> =>
ipcRenderer.invoke('workspace:delete', id),
// create will be implicitly called by db functions
},
openLoadDBFileDialog: () => ipcRenderer.invoke('ui:open-load-db-file-dialog'),
openSaveDBFileDialog: () => ipcRenderer.invoke('ui:open-save-db-file-dialog'),
// ui
onThemeChange: (theme: string) =>
ipcRenderer.invoke('ui:theme-change', theme),
onSidebarVisibilityChange: (visible: boolean) =>
ipcRenderer.invoke('ui:sidebar-visibility-change', visible),
onWorkspaceChange: (workspaceId: string) =>
ipcRenderer.invoke('ui:workspace-change', workspaceId),
openDBFolder: () => ipcRenderer.invoke('ui:open-db-folder'),
/**
* Try sign in using Google and return a request object to exchange the code for a token
* Not exchange in Node side because it is easier to do it in the renderer with VPN
*/
getGoogleOauthCode: (): Promise<{ requestInit: RequestInit; url: string }> =>
ipcRenderer.invoke('ui:get-google-oauth-code'),
/**
* Secret backdoor to update environment variables in main process
*/
updateEnv: (env: string, value: string) => {
ipcRenderer.invoke('main:env-update', env, value);
},
});
contextBridge.exposeInMainWorld('appInfo', {
electron: true,
isMacOS: isMacOS(),
});
contextBridge.exposeInMainWorld('apis', affineApis.apis);
contextBridge.exposeInMainWorld('appInfo', affineApis.appInfo);
+4 -4
View File
@@ -6,8 +6,8 @@
"description": "AFFiNE App",
"homepage": "https://github.com/toeverything/AFFiNE",
"scripts": {
"dev": "cross-env DEV_SERVER_URL=http://localhost:8080 node scripts/dev.mjs",
"prod": "node scripts/dev.mjs",
"dev": "yarn electron-rebuild && yarn cross-env DEV_SERVER_URL=http://localhost:8080 node scripts/dev.mjs",
"prod": "yarn electron-rebuild && yarn node scripts/dev.mjs",
"generate-assets": "zx scripts/generate-assets.mjs",
"package": "electron-forge package",
"make": "electron-forge make",
@@ -15,7 +15,8 @@
"make-macos-x64": "electron-forge make --platform=darwin --arch=x64",
"make-windows-x64": "electron-forge make --platform=win32 --arch=x64",
"make-linux-x64": "electron-forge make --platform=linux --arch=x64",
"electron-rebuild": "electron-rebuild"
"rebuild:for-test": "yarn rebuild better-sqlite3",
"rebuild:for-electron": "yarn electron-rebuild"
},
"config": {
"forge": "./forge.config.js"
@@ -35,7 +36,6 @@
"@types/better-sqlite3": "^7.6.4",
"@types/fs-extra": "^11.0.1",
"cross-env": "7.0.3",
"dts-for-context-bridge": "^0.7.1",
"electron": "24.1.2",
"electron-log": "^5.0.0-beta.23",
"electron-squirrel-startup": "1.0.0",
-5
View File
@@ -3,7 +3,6 @@ import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { generateAsync } from 'dts-for-context-bridge';
import electronPath from 'electron';
import * as esbuild from 'esbuild';
@@ -81,10 +80,6 @@ async function main() {
setup(build) {
let initialBuild = false;
build.onEnd(() => {
generateAsync({
input: 'layers/preload/src/**/*.ts',
output: 'layers/preload/preload.d.ts',
});
if (initialBuild) {
console.log(`[preload] has changed`);
spawnOrReloadElectron();