From 5ba2dff00807b14ea0d8dc1fa92c40b16c8477a9 Mon Sep 17 00:00:00 2001 From: Peng Xiao Date: Tue, 13 Jun 2023 10:01:43 +0800 Subject: [PATCH] feat: add helper process (#2753) --- .github/workflows/nightly-build.yml | 2 +- .github/workflows/release-desktop-app.yml | 2 +- .vscode/settings.template.json | 2 +- apps/electron/.gitignore | 1 + .../main/src/__tests__/integration.spec.ts | 497 ------------------ apps/electron/layers/main/src/context.ts | 12 - apps/electron/layers/main/src/type.ts | 18 - apps/electron/layers/main/src/utils.ts | 45 -- .../electron/layers/main/src/workers/index.ts | 35 -- .../main/src/workers/merge-update.worker.ts | 14 - .../layers/preload/src/affine-apis.ts | 75 --- apps/electron/package.json | 4 +- apps/electron/scripts/build-layers.mjs | 6 +- apps/electron/scripts/common.mjs | 42 +- apps/electron/scripts/dev.mjs | 34 +- apps/electron/scripts/generate-assets.mjs | 3 +- .../helper/db}/__tests__/.gitignore | 0 .../helper}/db/__tests__/ensure-db.spec.ts | 63 +-- .../db/__tests__/workspace-db-adapter.spec.ts | 23 +- .../src => src/helper}/db/base-db-adapter.ts | 6 +- .../main/src => src/helper}/db/ensure-db.ts | 8 +- .../main/src => src/helper}/db/index.ts | 22 +- .../workers => src/helper/db}/merge-update.ts | 0 .../src => src/helper}/db/secondary-db.ts | 25 +- .../main/src => src/helper}/db/subjects.ts | 2 - .../helper}/db/workspace-db-adapter.ts | 21 +- .../main/src => src/helper}/dialog/dialog.ts | 38 +- .../main/src => src/helper}/dialog/index.ts | 10 +- apps/electron/src/helper/exposed.ts | 33 ++ apps/electron/src/helper/index.ts | 86 +++ apps/electron/src/helper/logger.ts | 3 + apps/electron/src/helper/main-rpc.ts | 33 ++ apps/electron/src/helper/type.ts | 9 + .../helper/workspace}/__tests__/.gitignore | 0 .../workspace/__tests__/handlers.spec.ts | 80 +-- .../src => src/helper}/workspace/handlers.ts | 74 +-- .../src => src/helper}/workspace/index.ts | 19 +- .../src => src/helper}/workspace/subjects.ts | 0 .../main}/__tests__/.gitignore | 0 .../src/main/__tests__/integration.spec.ts | 173 ++++++ .../main}/application-menu/create.ts | 0 .../main}/application-menu/index.ts | 4 +- .../main}/application-menu/subject.ts | 0 .../{layers/main/src => src/main}/events.ts | 4 - .../main/src => src/main}/export/index.ts | 0 .../main/src => src/main}/export/pdf.ts | 0 .../main/src => src/main}/export/utils.ts | 0 .../{layers/main/src => src/main}/exposed.ts | 11 +- .../{layers/main/src => src/main}/handlers.ts | 22 +- apps/electron/src/main/helper-process.ts | 111 ++++ .../{layers/main/src => src/main}/index.ts | 3 + .../{layers/main/src => src/main}/logger.ts | 3 +- .../main/src => src/main}/main-window.ts | 23 +- .../{layers/main/src => src/main}/plugin.ts | 10 +- .../{layers/main/src => src/main}/protocol.ts | 3 +- .../src => src/main}/security-restrictions.ts | 0 apps/electron/src/main/type.ts | 10 + .../main/src => src/main}/ui/google-auth.ts | 0 .../{layers/main/src => src/main}/ui/index.ts | 0 .../main}/updater/electron-updater.ts | 0 .../main/src => src/main}/updater/event.ts | 4 +- .../main/src => src/main}/updater/index.ts | 0 apps/electron/src/main/utils.ts | 40 ++ .../src => src/main}/workers/plugin.worker.ts | 10 +- apps/electron/src/preload/affine-apis.ts | 193 +++++++ .../preload/src => src/preload}/bootstrap.ts | 10 +- .../preload/src => src/preload}/index.ts | 0 apps/electron/src/types.d.ts | 35 ++ apps/electron/tests/fixture.ts | 25 +- apps/electron/tsconfig.json | 2 +- apps/web/src/types/types.d.ts | 2 +- packages/debug/src/index.ts | 1 - tsconfig.json | 2 +- vitest.config.ts | 2 +- 74 files changed, 1002 insertions(+), 1048 deletions(-) delete mode 100644 apps/electron/layers/main/src/__tests__/integration.spec.ts delete mode 100644 apps/electron/layers/main/src/context.ts delete mode 100644 apps/electron/layers/main/src/type.ts delete mode 100644 apps/electron/layers/main/src/utils.ts delete mode 100644 apps/electron/layers/main/src/workers/index.ts delete mode 100644 apps/electron/layers/main/src/workers/merge-update.worker.ts delete mode 100644 apps/electron/layers/preload/src/affine-apis.ts rename apps/electron/{layers/main/src => src/helper/db}/__tests__/.gitignore (100%) rename apps/electron/{layers/main/src => src/helper}/db/__tests__/ensure-db.spec.ts (69%) rename apps/electron/{layers/main/src => src/helper}/db/__tests__/workspace-db-adapter.spec.ts (82%) rename apps/electron/{layers/main/src => src/helper}/db/base-db-adapter.ts (94%) rename apps/electron/{layers/main/src => src/helper}/db/ensure-db.ts (93%) rename apps/electron/{layers/main/src => src/helper}/db/index.ts (64%) rename apps/electron/{layers/main/src/workers => src/helper/db}/merge-update.ts (100%) rename apps/electron/{layers/main/src => src/helper}/db/secondary-db.ts (90%) rename apps/electron/{layers/main/src => src/helper}/db/subjects.ts (61%) rename apps/electron/{layers/main/src => src/helper}/db/workspace-db-adapter.ts (80%) rename apps/electron/{layers/main/src => src/helper}/dialog/dialog.ts (89%) rename apps/electron/{layers/main/src => src/helper}/dialog/index.ts (69%) create mode 100644 apps/electron/src/helper/exposed.ts create mode 100644 apps/electron/src/helper/index.ts create mode 100644 apps/electron/src/helper/logger.ts create mode 100644 apps/electron/src/helper/main-rpc.ts create mode 100644 apps/electron/src/helper/type.ts rename apps/electron/{layers/main/src/db => src/helper/workspace}/__tests__/.gitignore (100%) rename apps/electron/{layers/main/src => src/helper}/workspace/__tests__/handlers.spec.ts (66%) rename apps/electron/{layers/main/src => src/helper}/workspace/handlers.ts (58%) rename apps/electron/{layers/main/src => src/helper}/workspace/index.ts (53%) rename apps/electron/{layers/main/src => src/helper}/workspace/subjects.ts (100%) rename apps/electron/{layers/main/src/workspace => src/main}/__tests__/.gitignore (100%) create mode 100644 apps/electron/src/main/__tests__/integration.spec.ts rename apps/electron/{layers/main/src => src/main}/application-menu/create.ts (100%) rename apps/electron/{layers/main/src => src/main}/application-menu/index.ts (80%) rename apps/electron/{layers/main/src => src/main}/application-menu/subject.ts (100%) rename apps/electron/{layers/main/src => src/main}/events.ts (89%) rename apps/electron/{layers/main/src => src/main}/export/index.ts (100%) rename apps/electron/{layers/main/src => src/main}/export/pdf.ts (100%) rename apps/electron/{layers/main/src => src/main}/export/utils.ts (100%) rename apps/electron/{layers/main/src => src/main}/exposed.ts (75%) rename apps/electron/{layers/main/src => src/main}/handlers.ts (78%) create mode 100644 apps/electron/src/main/helper-process.ts rename apps/electron/{layers/main/src => src/main}/index.ts (95%) rename apps/electron/{layers/main/src => src/main}/logger.ts (82%) rename apps/electron/{layers/main/src => src/main}/main-window.ts (80%) rename apps/electron/{layers/main/src => src/main}/plugin.ts (84%) rename apps/electron/{layers/main/src => src/main}/protocol.ts (92%) rename apps/electron/{layers/main/src => src/main}/security-restrictions.ts (100%) create mode 100644 apps/electron/src/main/type.ts rename apps/electron/{layers/main/src => src/main}/ui/google-auth.ts (100%) rename apps/electron/{layers/main/src => src/main}/ui/index.ts (100%) rename apps/electron/{layers/main/src => src/main}/updater/electron-updater.ts (100%) rename apps/electron/{layers/main/src => src/main}/updater/event.ts (90%) rename apps/electron/{layers/main/src => src/main}/updater/index.ts (100%) create mode 100644 apps/electron/src/main/utils.ts rename apps/electron/{layers/main/src => src/main}/workers/plugin.worker.ts (79%) create mode 100644 apps/electron/src/preload/affine-apis.ts rename apps/electron/{layers/preload/src => src/preload}/bootstrap.ts (81%) rename apps/electron/{layers/preload/src => src/preload}/index.ts (100%) create mode 100644 apps/electron/src/types.d.ts diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index bb65847d69..a2195326ad 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -121,7 +121,7 @@ jobs: path: apps/electron/resources/web-static - name: Build layers - run: yarn workspace @affine/electron build-layers + run: yarn workspace @affine/electron build - name: Signing By Apple Developer ID if: ${{ matrix.spec.platform == 'darwin' }} diff --git a/.github/workflows/release-desktop-app.yml b/.github/workflows/release-desktop-app.yml index 0457ab7895..73e86ee52e 100644 --- a/.github/workflows/release-desktop-app.yml +++ b/.github/workflows/release-desktop-app.yml @@ -117,7 +117,7 @@ jobs: path: apps/electron/resources/web-static - name: Build layers - run: yarn workspace @affine/electron build-layers + run: yarn workspace @affine/electron build - name: Signing By Apple Developer ID if: ${{ matrix.spec.platform == 'darwin' }} diff --git a/.vscode/settings.template.json b/.vscode/settings.template.json index bf1dadb1bf..2a55ac2d00 100644 --- a/.vscode/settings.template.json +++ b/.vscode/settings.template.json @@ -34,7 +34,7 @@ "packages/**/*.spec.tsx", "apps/web/**/*.spec.ts", "apps/web/**/*.spec.tsx", - "apps/electron/layers/**/*.spec.ts", + "apps/electron/src/**/*.spec.ts", "tests/unit/**/*.spec.ts", "tests/unit/**/*.spec.tsx" ], diff --git a/apps/electron/.gitignore b/apps/electron/.gitignore index ca28910495..72afe66b1b 100644 --- a/apps/electron/.gitignore +++ b/apps/electron/.gitignore @@ -1,5 +1,6 @@ *.autogen.* dist +e2e-dist-* resources/web-static diff --git a/apps/electron/layers/main/src/__tests__/integration.spec.ts b/apps/electron/layers/main/src/__tests__/integration.spec.ts deleted file mode 100644 index b8d1b97dfe..0000000000 --- a/apps/electron/layers/main/src/__tests__/integration.spec.ts +++ /dev/null @@ -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)[] ->(); - -type WithoutFirstParameter = 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> -): // @ts-expect-error -ReturnType { - // @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) => { - 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, - dialog: {} as Partial, -}; - -// 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 = () => {}; - }); -}); diff --git a/apps/electron/layers/main/src/context.ts b/apps/electron/layers/main/src/context.ts deleted file mode 100644 index 53f860ee8a..0000000000 --- a/apps/electron/layers/main/src/context.ts +++ /dev/null @@ -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; diff --git a/apps/electron/layers/main/src/type.ts b/apps/electron/layers/main/src/type.ts deleted file mode 100644 index d4a86a9f29..0000000000 --- a/apps/electron/layers/main/src/type.ts +++ /dev/null @@ -1,18 +0,0 @@ -export type MainEventListener = (...args: any[]) => () => void; - -export type IsomorphicHandler = ( - e: Electron.IpcMainInvokeEvent, - ...args: any[] -) => Promise; - -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'; diff --git a/apps/electron/layers/main/src/utils.ts b/apps/electron/layers/main/src/utils.ts deleted file mode 100644 index 2be415fc6c..0000000000 --- a/apps/electron/layers/main/src/utils.ts +++ /dev/null @@ -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); - } -} diff --git a/apps/electron/layers/main/src/workers/index.ts b/apps/electron/layers/main/src/workers/index.ts deleted file mode 100644 index 3abc41c1ec..0000000000 --- a/apps/electron/layers/main/src/workers/index.ts +++ /dev/null @@ -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((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}`)); - } - }); - }); -} diff --git a/apps/electron/layers/main/src/workers/merge-update.worker.ts b/apps/electron/layers/main/src/workers/merge-update.worker.ts deleted file mode 100644 index 8ef4c397cb..0000000000 --- a/apps/electron/layers/main/src/workers/merge-update.worker.ts +++ /dev/null @@ -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)); diff --git a/apps/electron/layers/preload/src/affine-apis.ts b/apps/electron/layers/preload/src/affine-apis.ts deleted file mode 100644 index 1a79961ab3..0000000000 --- a/apps/electron/layers/preload/src/affine-apis.ts +++ /dev/null @@ -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'; diff --git a/apps/electron/package.json b/apps/electron/package.json index b5a6c76230..dee45e47a9 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -17,12 +17,12 @@ "generate-assets": "zx scripts/generate-assets.mjs", "package": "electron-forge package", "make": "electron-forge make", - "test": "playwright test" + "test": "DEBUG=pw:browser playwright test" }, "config": { "forge": "./forge.config.js" }, - "main": "./dist/layers/main/index.js", + "main": "./dist/main.js", "exports": { "./scripts/plugins/build-plugins.mjs": "./scripts/plugins/build-plugins.mjs" }, diff --git a/apps/electron/scripts/build-layers.mjs b/apps/electron/scripts/build-layers.mjs index 3ee618f7d4..6d3f4f17c7 100644 --- a/apps/electron/scripts/build-layers.mjs +++ b/apps/electron/scripts/build-layers.mjs @@ -18,7 +18,6 @@ if (process.platform === 'win32') { async function buildLayers() { const common = config(); - await esbuild.build(common.preload); console.log('Build plugin infra'); spawnSync('yarn', ['build'], { stdio: 'inherit', @@ -28,10 +27,11 @@ async function buildLayers() { console.log('Build plugins'); await import('./plugins/build-plugins.mjs'); + await esbuild.build(common.workers); await esbuild.build({ - ...common.main, + ...common.layers, define: { - ...common.main.define, + ...common.define, 'process.env.NODE_ENV': `"${NODE_ENV}"`, 'process.env.BUILD_TYPE': `"${process.env.BUILD_TYPE || 'stable'}"`, }, diff --git a/apps/electron/scripts/common.mjs b/apps/electron/scripts/common.mjs index 66e357ab68..c190b6e8c1 100644 --- a/apps/electron/scripts/common.mjs +++ b/apps/electron/scripts/common.mjs @@ -18,7 +18,7 @@ const mode = (process.env.NODE_ENV = process.env.NODE_ENV || 'development'); // List of env that will be replaced by esbuild const ENV_MACROS = ['AFFINE_GOOGLE_CLIENT_ID', 'AFFINE_GOOGLE_CLIENT_SECRET']; -/** @return {{main: import('esbuild').BuildOptions, preload: import('esbuild').BuildOptions}} */ +/** @return {{layers: import('esbuild').BuildOptions, workers: import('esbuild').BuildOptions}} */ export const config = () => { const define = Object.fromEntries([ ...ENV_MACROS.map(key => [ @@ -34,27 +34,18 @@ export const config = () => { } return { - main: { + layers: { entryPoints: [ - resolve(electronDir, './layers/main/src/index.ts'), - resolve( - electronDir, - './layers/main/src/workers/merge-update.worker.ts' - ), - resolve(electronDir, './layers/main/src/workers/plugin.worker.ts'), + resolve(electronDir, './src/main/index.ts'), + resolve(electronDir, './src/preload/index.ts'), + resolve(electronDir, './src/helper/index.ts'), ], - outdir: resolve(electronDir, './dist/layers/main'), + entryNames: '[dir]', + outdir: resolve(electronDir, './dist'), bundle: true, target: `node${NODE_MAJOR_VERSION}`, platform: 'node', - external: [ - 'electron', - 'yjs', - 'better-sqlite3', - 'electron-updater', - '@toeverything/plugin-infra', - 'async-call-rpc', - ], + external: ['electron', 'electron-updater', '@toeverything/plugin-infra'], define: define, format: 'cjs', loader: { @@ -63,14 +54,23 @@ export const config = () => { assetNames: '[name]', treeShaking: true, }, - preload: { - entryPoints: [resolve(electronDir, './layers/preload/src/index.ts')], - outdir: resolve(electronDir, './dist/layers/preload'), + workers: { + entryPoints: [ + resolve(electronDir, './src/main/workers/plugin.worker.ts'), + ], + entryNames: '[dir]/[name]', + outdir: resolve(electronDir, './dist/workers'), bundle: true, target: `node${NODE_MAJOR_VERSION}`, platform: 'node', - external: ['electron'], + external: ['electron', 'electron-updater', '@toeverything/plugin-infra'], define: define, + format: 'cjs', + loader: { + '.node': 'copy', + }, + assetNames: '[name]', + treeShaking: true, }, }; }; diff --git a/apps/electron/scripts/dev.mjs b/apps/electron/scripts/dev.mjs index c92ce5f9c6..9960439eac 100644 --- a/apps/electron/scripts/dev.mjs +++ b/apps/electron/scripts/dev.mjs @@ -76,19 +76,20 @@ async function watchPlugins() { await import('./plugins/dev-plugins.mjs'); } -async function watchPreload() { +async function watchLayers() { return new Promise(async resolve => { let initialBuild = false; - const preloadBuild = await esbuild.context({ - ...common.preload, + + const buildContext = await esbuild.context({ + ...common.layers, plugins: [ - ...(common.preload.plugins ?? []), + ...(common.layers.plugins ?? []), { - name: 'electron-dev:reload-app-on-preload-change', + name: 'electron-dev:reload-app-on-layers-change', setup(build) { build.onEnd(() => { if (initialBuild) { - console.log(`[preload] has changed, [re]launching electron...`); + console.log(`[layers] has changed, [re]launching electron...`); spawnOrReloadElectron(); } else { resolve(); @@ -99,25 +100,24 @@ async function watchPreload() { }, ], }); - // watch will trigger build.onEnd() on first run & on subsequent changes - await preloadBuild.watch(); + await buildContext.watch(); }); } -async function watchMain() { +async function watchWorkers() { return new Promise(async resolve => { let initialBuild = false; - const mainBuild = await esbuild.context({ - ...common.main, + const buildContext = await esbuild.context({ + ...common.workers, plugins: [ - ...(common.main.plugins ?? []), + ...(common.workers.plugins ?? []), { - name: 'electron-dev:reload-app-on-main-change', + name: 'electron-dev:reload-app-on-workers-change', setup(build) { build.onEnd(() => { if (initialBuild) { - console.log(`[main] has changed, [re]launching electron...`); + console.log(`[workers] has changed, [re]launching electron...`); spawnOrReloadElectron(); } else { resolve(); @@ -128,14 +128,14 @@ async function watchMain() { }, ], }); - await mainBuild.watch(); + await buildContext.watch(); }); } async function main() { await watchPlugins(); - await watchMain(); - await watchPreload(); + await watchLayers(); + await watchWorkers(); if (watchMode) { console.log(`Watching for changes...`); diff --git a/apps/electron/scripts/generate-assets.mjs b/apps/electron/scripts/generate-assets.mjs index 85bca585a3..08965adaf4 100644 --- a/apps/electron/scripts/generate-assets.mjs +++ b/apps/electron/scripts/generate-assets.mjs @@ -85,7 +85,6 @@ async function cleanup() { if (!process.env.SKIP_WEB_BUILD) { await fs.emptyDir(publicAffineOutDir); } - await fs.emptyDir(path.join(electronRootDir, 'layers', 'main', 'dist')); - await fs.emptyDir(path.join(electronRootDir, 'layers', 'preload', 'dist')); + await fs.remove(path.join(electronRootDir, 'dist')); await fs.remove(path.join(electronRootDir, 'out')); } diff --git a/apps/electron/layers/main/src/__tests__/.gitignore b/apps/electron/src/helper/db/__tests__/.gitignore similarity index 100% rename from apps/electron/layers/main/src/__tests__/.gitignore rename to apps/electron/src/helper/db/__tests__/.gitignore diff --git a/apps/electron/layers/main/src/db/__tests__/ensure-db.spec.ts b/apps/electron/src/helper/db/__tests__/ensure-db.spec.ts similarity index 69% rename from apps/electron/layers/main/src/db/__tests__/ensure-db.spec.ts rename to apps/electron/src/helper/db/__tests__/ensure-db.spec.ts index ebc369d9b5..aa56434030 100644 --- a/apps/electron/layers/main/src/db/__tests__/ensure-db.spec.ts +++ b/apps/electron/src/helper/db/__tests__/ensure-db.spec.ts @@ -6,56 +6,22 @@ import { v4 } from 'uuid'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; const tmpDir = path.join(__dirname, 'tmp'); +const appDataPath = path.join(tmpDir, 'app-data'); -const registeredHandlers = new Map< - string, - ((...args: any[]) => Promise)[] ->(); - -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: () => {}, +vi.doMock('../../main-rpc', () => ({ + mainRPC: { + getPath: async () => appDataPath, }, - shell: {} as Partial, - dialog: {} as Partial, -}; - -const runHandler = async (key: string) => { - await Promise.all( - (registeredHandlers.get(key) ?? []).map(handler => handler()) - ); -}; - -// dynamically import handlers so that we can inject local variables to mocks -vi.doMock('electron', () => { - return electronModule; -}); +})); const constructorStub = vi.fn(); const destroyStub = vi.fn(); destroyStub.mockReturnValue(Promise.resolve()); +function existProcess() { + process.emit('beforeExit', 0); +} + vi.doMock('../secondary-db', () => { return { SecondaryWorkspaceSQLiteDB: class { @@ -77,7 +43,7 @@ beforeEach(() => { }); afterEach(async () => { - await runHandler('before-quit'); + existProcess(); // wait for the db to be closed on Windows if (process.platform === 'win32') { await setTimeout(200); @@ -110,7 +76,7 @@ test('db should be destroyed when app quits', async () => { expect(db0.db).not.toBeNull(); expect(db1.db).not.toBeNull(); - await runHandler('before-quit'); + existProcess(); // wait the async `db.destroy()` to be called await setTimeout(100); @@ -130,10 +96,9 @@ test('db should be removed in db$Map after destroyed', async () => { test('if db has a secondary db path, we should also poll that', async () => { const { ensureSQLiteDB } = await import('../ensure-db'); - const { appContext } = await import('../../context'); const { storeWorkspaceMeta } = await import('../../workspace'); const workspaceId = v4(); - await storeWorkspaceMeta(appContext, workspaceId, { + await storeWorkspaceMeta(workspaceId, { secondaryDBPath: path.join(tmpDir, 'secondary.db'), }); @@ -145,7 +110,7 @@ test('if db has a secondary db path, we should also poll that', async () => { expect(constructorStub).toBeCalledWith(path.join(tmpDir, 'secondary.db'), db); // if secondary meta is changed - await storeWorkspaceMeta(appContext, workspaceId, { + await storeWorkspaceMeta(workspaceId, { secondaryDBPath: path.join(tmpDir, 'secondary2.db'), }); @@ -155,7 +120,7 @@ test('if db has a secondary db path, we should also poll that', async () => { expect(destroyStub).toBeCalledTimes(1); // if secondary meta is changed (but another workspace) - await storeWorkspaceMeta(appContext, v4(), { + await storeWorkspaceMeta(v4(), { secondaryDBPath: path.join(tmpDir, 'secondary3.db'), }); await vi.advanceTimersByTimeAsync(1500); diff --git a/apps/electron/layers/main/src/db/__tests__/workspace-db-adapter.spec.ts b/apps/electron/src/helper/db/__tests__/workspace-db-adapter.spec.ts similarity index 82% rename from apps/electron/layers/main/src/db/__tests__/workspace-db-adapter.spec.ts rename to apps/electron/src/helper/db/__tests__/workspace-db-adapter.spec.ts index 10dd8fa8a9..348d6c9d5a 100644 --- a/apps/electron/layers/main/src/db/__tests__/workspace-db-adapter.spec.ts +++ b/apps/electron/src/helper/db/__tests__/workspace-db-adapter.spec.ts @@ -5,15 +5,16 @@ import { v4 } from 'uuid'; import { afterEach, expect, test, vi } from 'vitest'; import * as Y from 'yjs'; -import type { AppContext } from '../../context'; import { dbSubjects } from '../subjects'; const tmpDir = path.join(__dirname, 'tmp'); +const appDataPath = path.join(tmpDir, 'app-data'); -const testAppContext: AppContext = { - appDataPath: path.join(tmpDir, 'test-data'), - appName: 'test', -}; +vi.doMock('../../main-rpc', () => ({ + mainRPC: { + getPath: async () => appDataPath, + }, +})); afterEach(async () => { await fs.remove(tmpDir); @@ -30,9 +31,9 @@ function getTestUpdates() { test('can create new db file if not exists', async () => { const { openWorkspaceDatabase } = await import('../workspace-db-adapter'); const workspaceId = v4(); - const db = await openWorkspaceDatabase(testAppContext, workspaceId); + const db = await openWorkspaceDatabase(workspaceId); const dbPath = path.join( - testAppContext.appDataPath, + appDataPath, `workspaces/${workspaceId}`, `storage.db` ); @@ -45,7 +46,7 @@ test('on applyUpdate (from self), will not trigger update', async () => { const workspaceId = v4(); const onUpdate = vi.fn(); - const db = await openWorkspaceDatabase(testAppContext, workspaceId); + const db = await openWorkspaceDatabase(workspaceId); db.update$.subscribe(onUpdate); db.applyUpdate(getTestUpdates(), 'self'); expect(onUpdate).not.toHaveBeenCalled(); @@ -58,7 +59,7 @@ test('on applyUpdate (from renderer), will trigger update', async () => { const onUpdate = vi.fn(); const onExternalUpdate = vi.fn(); - const db = await openWorkspaceDatabase(testAppContext, workspaceId); + const db = await openWorkspaceDatabase(workspaceId); db.update$.subscribe(onUpdate); const sub = dbSubjects.externalUpdate.subscribe(onExternalUpdate); db.applyUpdate(getTestUpdates(), 'renderer'); @@ -73,7 +74,7 @@ test('on applyUpdate (from external), will trigger update & send external update const onUpdate = vi.fn(); const onExternalUpdate = vi.fn(); - const db = await openWorkspaceDatabase(testAppContext, workspaceId); + const db = await openWorkspaceDatabase(workspaceId); db.update$.subscribe(onUpdate); const sub = dbSubjects.externalUpdate.subscribe(onExternalUpdate); db.applyUpdate(getTestUpdates(), 'external'); @@ -86,7 +87,7 @@ test('on applyUpdate (from external), will trigger update & send external update test('on destroy, check if resources have been released', async () => { const { openWorkspaceDatabase } = await import('../workspace-db-adapter'); const workspaceId = v4(); - const db = await openWorkspaceDatabase(testAppContext, workspaceId); + const db = await openWorkspaceDatabase(workspaceId); const updateSub = { complete: vi.fn(), next: vi.fn(), diff --git a/apps/electron/layers/main/src/db/base-db-adapter.ts b/apps/electron/src/helper/db/base-db-adapter.ts similarity index 94% rename from apps/electron/layers/main/src/db/base-db-adapter.ts rename to apps/electron/src/helper/db/base-db-adapter.ts index 581d283316..f147c85109 100644 --- a/apps/electron/layers/main/src/db/base-db-adapter.ts +++ b/apps/electron/src/helper/db/base-db-adapter.ts @@ -93,7 +93,7 @@ export abstract class BaseSQLiteAdapter { } // add a single update to SQLite - async addUpdateToSQLite(db: SqliteConnection, updates: Uint8Array[]) { + async addUpdateToSQLite(updates: Uint8Array[]) { // batch write instead write per key stroke? try { if (!this.db) { @@ -101,7 +101,7 @@ export abstract class BaseSQLiteAdapter { return; } const start = performance.now(); - await db.insertUpdates(updates); + await this.db.insertUpdates(updates); logger.debug( `[SQLiteAdapter][${this.role}] addUpdateToSQLite`, 'length:', @@ -110,7 +110,7 @@ export abstract class BaseSQLiteAdapter { 'ms' ); } catch (error) { - logger.error('addUpdateToSQLite', error); + logger.error('addUpdateToSQLite', this.path, error); } } } diff --git a/apps/electron/layers/main/src/db/ensure-db.ts b/apps/electron/src/helper/db/ensure-db.ts similarity index 93% rename from apps/electron/layers/main/src/db/ensure-db.ts rename to apps/electron/src/helper/db/ensure-db.ts index 13115e2138..966adfe4e2 100644 --- a/apps/electron/layers/main/src/db/ensure-db.ts +++ b/apps/electron/src/helper/db/ensure-db.ts @@ -1,4 +1,3 @@ -import { app } from 'electron'; import type { Subject } from 'rxjs'; import { Observable } from 'rxjs'; import { @@ -25,7 +24,6 @@ import { tap, } from 'rxjs/operators'; -import { appContext } from '../context'; import { logger } from '../logger'; import { getWorkspaceMeta, workspaceSubjects } from '../workspace'; import { SecondaryWorkspaceSQLiteDB } from './secondary-db'; @@ -36,7 +34,7 @@ import { openWorkspaceDatabase } from './workspace-db-adapter'; export const db$Map = new Map>(); // use defer to prevent `app` is undefined while running tests -const beforeQuit$ = defer(() => fromEvent(app, 'before-quit')); +const beforeQuit$ = defer(() => fromEvent(process, 'beforeExit')); // return a stream that emit a single event when the subject completes function completed(subject: Subject) { @@ -55,7 +53,7 @@ function getWorkspaceDB$(id: string) { if (!db$Map.has(id)) { db$Map.set( id, - from(openWorkspaceDatabase(appContext, id)).pipe( + from(openWorkspaceDatabase(id)).pipe( tap({ next: db => { logger.info( @@ -103,7 +101,7 @@ function getWorkspaceDB$(id: string) { function startPollingSecondaryDB(db: WorkspaceSQLiteDB) { return merge( - getWorkspaceMeta(appContext, db.workspaceId), + getWorkspaceMeta(db.workspaceId), workspaceSubjects.meta.pipe( map(({ meta }) => meta), filter(meta => meta.id === db.workspaceId) diff --git a/apps/electron/layers/main/src/db/index.ts b/apps/electron/src/helper/db/index.ts similarity index 64% rename from apps/electron/layers/main/src/db/index.ts rename to apps/electron/src/helper/db/index.ts index 23d7e5d67a..05a6fd27c0 100644 --- a/apps/electron/layers/main/src/db/index.ts +++ b/apps/electron/src/helper/db/index.ts @@ -1,5 +1,5 @@ -import { appContext } from '../context'; -import type { MainEventListener, NamespaceHandlers } from '../type'; +import { mainRPC } from '../main-rpc'; +import type { MainEventRegister } from '../type'; import { ensureSQLiteDB } from './ensure-db'; import { dbSubjects } from './subjects'; @@ -7,34 +7,34 @@ export * from './ensure-db'; export * from './subjects'; export const dbHandlers = { - getDocAsUpdates: async (_, id: string) => { + getDocAsUpdates: async (id: string) => { const workspaceDB = await ensureSQLiteDB(id); return workspaceDB.getDocAsUpdates(); }, - applyDocUpdate: async (_, id: string, update: Uint8Array) => { + applyDocUpdate: async (id: string, update: Uint8Array) => { const workspaceDB = await ensureSQLiteDB(id); return workspaceDB.applyUpdate(update); }, - addBlob: async (_, workspaceId: string, key: string, data: Uint8Array) => { + addBlob: async (workspaceId: string, key: string, data: Uint8Array) => { const workspaceDB = await ensureSQLiteDB(workspaceId); return workspaceDB.addBlob(key, data); }, - getBlob: async (_, workspaceId: string, key: string) => { + getBlob: async (workspaceId: string, key: string) => { const workspaceDB = await ensureSQLiteDB(workspaceId); return workspaceDB.getBlob(key); }, - deleteBlob: async (_, workspaceId: string, key: string) => { + deleteBlob: async (workspaceId: string, key: string) => { const workspaceDB = await ensureSQLiteDB(workspaceId); return workspaceDB.deleteBlob(key); }, - getBlobKeys: async (_, workspaceId: string) => { + getBlobKeys: async (workspaceId: string) => { const workspaceDB = await ensureSQLiteDB(workspaceId); return workspaceDB.getBlobKeys(); }, getDefaultStorageLocation: async () => { - return appContext.appDataPath; + return await mainRPC.getPath('sessionData'); }, -} satisfies NamespaceHandlers; +}; export const dbEvents = { onExternalUpdate: ( @@ -45,4 +45,4 @@ export const dbEvents = { sub.unsubscribe(); }; }, -} satisfies Record; +} satisfies Record; diff --git a/apps/electron/layers/main/src/workers/merge-update.ts b/apps/electron/src/helper/db/merge-update.ts similarity index 100% rename from apps/electron/layers/main/src/workers/merge-update.ts rename to apps/electron/src/helper/db/merge-update.ts diff --git a/apps/electron/layers/main/src/db/secondary-db.ts b/apps/electron/src/helper/db/secondary-db.ts similarity index 90% rename from apps/electron/layers/main/src/db/secondary-db.ts rename to apps/electron/src/helper/db/secondary-db.ts index 5dc1c39633..e3687136d5 100644 --- a/apps/electron/layers/main/src/db/secondary-db.ts +++ b/apps/electron/src/helper/db/secondary-db.ts @@ -4,12 +4,11 @@ import type { SqliteConnection } from '@affine/native'; import { debounce } from 'lodash-es'; import * as Y from 'yjs'; -import type { AppContext } from '../context'; import { logger } from '../logger'; import type { YOrigin } from '../type'; -import { mergeUpdateWorker } from '../workers'; import { getWorkspaceMeta } from '../workspace'; import { BaseSQLiteAdapter } from './base-db-adapter'; +import { mergeUpdate } from './merge-update'; import type { WorkspaceSQLiteDB } from './workspace-db-adapter'; const FLUSH_WAIT_TIME = 5000; @@ -35,8 +34,7 @@ export class SecondaryWorkspaceSQLiteDB extends BaseSQLiteAdapter { } override async destroy() { - const { db } = this; - await this.flushUpdateQueue(db); + await this.flushUpdateQueue(); this.unsubscribers.forEach(unsub => unsub()); this.yDoc.destroy(); await super.destroy(); @@ -51,12 +49,12 @@ export class SecondaryWorkspaceSQLiteDB extends BaseSQLiteAdapter { // and flush the queue in a future time async addUpdateToUpdateQueue(db: SqliteConnection, update: Uint8Array) { this.updateQueue.push(update); - await this.debouncedFlush(db); + await this.debouncedFlush(); } - async flushUpdateQueue(db = this.db) { - if (!db) { - return; // skip if db is not connected + async flushUpdateQueue() { + if (this.destroyed) { + return; } logger.debug( 'flushUpdateQueue', @@ -67,7 +65,7 @@ export class SecondaryWorkspaceSQLiteDB extends BaseSQLiteAdapter { const updates = [...this.updateQueue]; this.updateQueue = []; await this.run(async () => { - await this.addUpdateToSQLite(db, updates); + await this.addUpdateToSQLite(updates); }); } @@ -198,7 +196,7 @@ export class SecondaryWorkspaceSQLiteDB extends BaseSQLiteAdapter { return; } - const merged = await mergeUpdateWorker(updates); + const merged = mergeUpdate(updates); this.applyUpdate(merged, 'self'); logger.debug( @@ -211,10 +209,7 @@ export class SecondaryWorkspaceSQLiteDB extends BaseSQLiteAdapter { } } -export async function getSecondaryWorkspaceDBPath( - context: AppContext, - workspaceId: string -) { - const meta = await getWorkspaceMeta(context, workspaceId); +export async function getSecondaryWorkspaceDBPath(workspaceId: string) { + const meta = await getWorkspaceMeta(workspaceId); return meta?.secondaryDBPath; } diff --git a/apps/electron/layers/main/src/db/subjects.ts b/apps/electron/src/helper/db/subjects.ts similarity index 61% rename from apps/electron/layers/main/src/db/subjects.ts rename to apps/electron/src/helper/db/subjects.ts index 3bad8d762a..05943e2331 100644 --- a/apps/electron/layers/main/src/db/subjects.ts +++ b/apps/electron/src/helper/db/subjects.ts @@ -1,7 +1,5 @@ import { Subject } from 'rxjs'; export const dbSubjects = { - // emit workspace id when the db file is missing - fileMissing: new Subject(), externalUpdate: new Subject<{ workspaceId: string; update: Uint8Array }>(), }; diff --git a/apps/electron/layers/main/src/db/workspace-db-adapter.ts b/apps/electron/src/helper/db/workspace-db-adapter.ts similarity index 80% rename from apps/electron/layers/main/src/db/workspace-db-adapter.ts rename to apps/electron/src/helper/db/workspace-db-adapter.ts index 181e986687..abf663e16b 100644 --- a/apps/electron/layers/main/src/db/workspace-db-adapter.ts +++ b/apps/electron/src/helper/db/workspace-db-adapter.ts @@ -1,13 +1,11 @@ -import type { SqliteConnection } from '@affine/native'; import { Subject } from 'rxjs'; import * as Y from 'yjs'; -import type { AppContext } from '../context'; import { logger } from '../logger'; import type { YOrigin } from '../type'; -import { mergeUpdateWorker } from '../workers'; import { getWorkspaceMeta } from '../workspace'; import { BaseSQLiteAdapter } from './base-db-adapter'; +import { mergeUpdate } from './merge-update'; import { dbSubjects } from './subjects'; export class WorkspaceSQLiteDB extends BaseSQLiteAdapter { @@ -40,20 +38,20 @@ export class WorkspaceSQLiteDB extends BaseSQLiteAdapter { if (!this.firstConnected) { this.yDoc.on('update', async (update: Uint8Array, origin: YOrigin) => { if (origin === 'renderer') { - await this.addUpdateToSQLite(db, [update]); + await this.addUpdateToSQLite([update]); } else if (origin === 'external') { dbSubjects.externalUpdate.next({ workspaceId: this.workspaceId, update, }); - await this.addUpdateToSQLite(db, [update]); + await this.addUpdateToSQLite([update]); logger.debug('external update', this.workspaceId); } }); } const updates = await this.getUpdates(); - const merged = await mergeUpdateWorker(updates.map(update => update.data)); + const merged = mergeUpdate(updates.map(update => update.data)); // to initialize the yDoc, we need to apply all updates from the db this.applyUpdate(merged, 'self'); @@ -89,17 +87,14 @@ export class WorkspaceSQLiteDB extends BaseSQLiteAdapter { await super.deleteBlob(key); } - override async addUpdateToSQLite(db: SqliteConnection, data: Uint8Array[]) { + override async addUpdateToSQLite(data: Uint8Array[]) { this.update$.next(); - await super.addUpdateToSQLite(db, data); + await super.addUpdateToSQLite(data); } } -export async function openWorkspaceDatabase( - context: AppContext, - workspaceId: string -) { - const meta = await getWorkspaceMeta(context, workspaceId); +export async function openWorkspaceDatabase(workspaceId: string) { + const meta = await getWorkspaceMeta(workspaceId); const db = new WorkspaceSQLiteDB(meta.mainDBPath, workspaceId); await db.init(); logger.info(`openWorkspaceDatabase [${workspaceId}]`); diff --git a/apps/electron/layers/main/src/dialog/dialog.ts b/apps/electron/src/helper/dialog/dialog.ts similarity index 89% rename from apps/electron/layers/main/src/dialog/dialog.ts rename to apps/electron/src/helper/dialog/dialog.ts index b39f865a3f..93a8b0b4d7 100644 --- a/apps/electron/layers/main/src/dialog/dialog.ts +++ b/apps/electron/src/helper/dialog/dialog.ts @@ -1,17 +1,16 @@ import path from 'node:path'; -import { app } from 'electron'; -import { dialog, shell } from 'electron'; import fs from 'fs-extra'; import { nanoid } from 'nanoid'; -import { appContext } from '../context'; import { ensureSQLiteDB } from '../db/ensure-db'; import type { WorkspaceSQLiteDB } from '../db/workspace-db-adapter'; import { logger } from '../logger'; +import { mainRPC } from '../main-rpc'; import { getWorkspaceDBPath, getWorkspaceMeta, + getWorkspacesBasePath, listWorkspaces, storeWorkspaceMeta, } from '../workspace'; @@ -20,11 +19,11 @@ import { // we are using native dialogs because HTML dialogs do not give full file paths export async function revealDBFile(workspaceId: string) { - const meta = await getWorkspaceMeta(appContext, workspaceId); + const meta = await getWorkspaceMeta(workspaceId); if (!meta) { return; } - shell.showItemInFolder(meta.secondaryDBPath ?? meta.mainDBPath); + await mainRPC.showItemInFolder(meta.secondaryDBPath ?? meta.mainDBPath); } // provide a backdoor to set dialog path for testing in playwright @@ -88,7 +87,7 @@ export async function saveDBFileAs( const db = await ensureSQLiteDB(workspaceId); const ret = getFakedResult() ?? - (await dialog.showSaveDialog({ + (await mainRPC.showSaveDialog({ properties: ['showOverwriteConfirmation'], title: 'Save Workspace', showsTagField: false, @@ -111,7 +110,7 @@ export async function saveDBFileAs( await fs.copyFile(db.path, filePath); logger.log('saved', filePath); - shell.showItemInFolder(filePath); + mainRPC.showItemInFolder(filePath); return { filePath }; } catch (err) { logger.error('saveDBFileAs', err); @@ -131,11 +130,11 @@ export async function selectDBFileLocation(): Promise { try { const ret = getFakedResult() ?? - (await dialog.showOpenDialog({ + (await mainRPC.showOpenDialog({ properties: ['openFile'], title: 'Load Workspace', buttonLabel: 'Load', @@ -197,7 +196,7 @@ export async function loadDBFile(): Promise { } // the imported file should not be in app data dir - if (filePath.startsWith(path.join(appContext.appDataPath, 'workspaces'))) { + if (filePath.startsWith(await getWorkspacesBasePath())) { logger.warn('loadDBFile: db file in app data dir'); return { error: 'DB_FILE_PATH_INVALID' }; } @@ -216,14 +215,14 @@ export async function loadDBFile(): Promise { // copy the db file to a new workspace id const workspaceId = nanoid(10); - const internalFilePath = getWorkspaceDBPath(appContext, workspaceId); + const internalFilePath = await getWorkspaceDBPath(workspaceId); - await fs.ensureDir(path.join(appContext.appDataPath, 'workspaces')); + await fs.ensureDir(await getWorkspacesBasePath()); await fs.copy(filePath, internalFilePath); logger.info(`loadDBFile, copy: ${filePath} -> ${internalFilePath}`); - await storeWorkspaceMeta(appContext, workspaceId, { + await storeWorkspaceMeta(workspaceId, { id: workspaceId, mainDBPath: internalFilePath, secondaryDBPath: filePath, @@ -260,13 +259,12 @@ export async function moveDBFile( let db: WorkspaceSQLiteDB | null = null; try { db = await ensureSQLiteDB(workspaceId); - - const meta = await getWorkspaceMeta(appContext, workspaceId); + const meta = await getWorkspaceMeta(workspaceId); const oldDir = meta.secondaryDBPath ? path.dirname(meta.secondaryDBPath) : null; - const defaultDir = oldDir ?? app.getPath('documents'); + const defaultDir = oldDir ?? (await mainRPC.getPath('documents')); const newName = getDefaultDBFileName(db.getWorkspaceName(), workspaceId); @@ -274,7 +272,7 @@ export async function moveDBFile( dbFileDir ?? ( getFakedResult() ?? - (await dialog.showOpenDialog({ + (await mainRPC.showOpenDialog({ properties: ['openDirectory'], title: 'Move Workspace Storage', buttonLabel: 'Move', @@ -320,7 +318,7 @@ export async function moveDBFile( } // update meta - await storeWorkspaceMeta(appContext, workspaceId, { + await storeWorkspaceMeta(workspaceId, { secondaryDBPath: newFilePath, }); @@ -337,7 +335,7 @@ export async function moveDBFile( } async function dbFileAlreadyLoaded(path: string) { - const meta = await listWorkspaces(appContext); + const meta = await listWorkspaces(); const paths = meta.map(m => m[1].secondaryDBPath); return paths.includes(path); } diff --git a/apps/electron/layers/main/src/dialog/index.ts b/apps/electron/src/helper/dialog/index.ts similarity index 69% rename from apps/electron/layers/main/src/dialog/index.ts rename to apps/electron/src/helper/dialog/index.ts index 2bcfb09dd9..28079f2d15 100644 --- a/apps/electron/layers/main/src/dialog/index.ts +++ b/apps/electron/src/helper/dialog/index.ts @@ -1,4 +1,3 @@ -import type { NamespaceHandlers } from '../type'; import { loadDBFile, moveDBFile, @@ -9,25 +8,24 @@ import { } from './dialog'; export const dialogHandlers = { - revealDBFile: async (_, workspaceId: string) => { + revealDBFile: async (workspaceId: string) => { return revealDBFile(workspaceId); }, loadDBFile: async () => { return loadDBFile(); }, - saveDBFileAs: async (_, workspaceId: string) => { + saveDBFileAs: async (workspaceId: string) => { return saveDBFileAs(workspaceId); }, - moveDBFile: (_, workspaceId: string, dbFileLocation?: string) => { + moveDBFile: (workspaceId: string, dbFileLocation?: string) => { return moveDBFile(workspaceId, dbFileLocation); }, selectDBFileLocation: async () => { return selectDBFileLocation(); }, setFakeDialogResult: async ( - _, result: Parameters[0] ) => { return setFakeDialogResult(result); }, -} satisfies NamespaceHandlers; +}; diff --git a/apps/electron/src/helper/exposed.ts b/apps/electron/src/helper/exposed.ts new file mode 100644 index 0000000000..19bd140e76 --- /dev/null +++ b/apps/electron/src/helper/exposed.ts @@ -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, + }; +}; diff --git a/apps/electron/src/helper/index.ts b/apps/electron/src/helper/index.ts new file mode 100644 index 0000000000..d38937b0c0 --- /dev/null +++ b/apps/electron/src/helper/index.ts @@ -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( + 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(); diff --git a/apps/electron/src/helper/logger.ts b/apps/electron/src/helper/logger.ts new file mode 100644 index 0000000000..d677385257 --- /dev/null +++ b/apps/electron/src/helper/logger.ts @@ -0,0 +1,3 @@ +import log from 'electron-log'; + +export const logger = log.scope('helper'); diff --git a/apps/electron/src/helper/main-rpc.ts b/apps/electron/src/helper/main-rpc.ts new file mode 100644 index 0000000000..7874137e0f --- /dev/null +++ b/apps/electron/src/helper/main-rpc.ts @@ -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(helperToMainServer, { + strict: { + unknownMessage: false, + }, + channel: createMessagePortMainChannel(process.parentPort), +}); diff --git a/apps/electron/src/helper/type.ts b/apps/electron/src/helper/type.ts new file mode 100644 index 0000000000..0fa7e8bd07 --- /dev/null +++ b/apps/electron/src/helper/type.ts @@ -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; diff --git a/apps/electron/layers/main/src/db/__tests__/.gitignore b/apps/electron/src/helper/workspace/__tests__/.gitignore similarity index 100% rename from apps/electron/layers/main/src/db/__tests__/.gitignore rename to apps/electron/src/helper/workspace/__tests__/.gitignore diff --git a/apps/electron/layers/main/src/workspace/__tests__/handlers.spec.ts b/apps/electron/src/helper/workspace/__tests__/handlers.spec.ts similarity index 66% rename from apps/electron/layers/main/src/workspace/__tests__/handlers.spec.ts rename to apps/electron/src/helper/workspace/__tests__/handlers.spec.ts index 00be97c510..7faa160eb0 100644 --- a/apps/electron/layers/main/src/workspace/__tests__/handlers.spec.ts +++ b/apps/electron/src/helper/workspace/__tests__/handlers.spec.ts @@ -4,18 +4,8 @@ import fs from 'fs-extra'; import { v4 } from 'uuid'; import { afterEach, describe, expect, test, vi } from 'vitest'; -import type { AppContext } from '../../context'; - const tmpDir = path.join(__dirname, 'tmp'); - -const testAppContext: AppContext = { - appDataPath: path.join(tmpDir, 'test-data'), - appName: 'test', -}; - -vi.doMock('../../context', () => ({ - appContext: testAppContext, -})); +const appDataPath = path.join(tmpDir, 'app-data'); vi.doMock('../../db/ensure-db', () => ({ ensureSQLiteDB: async () => ({ @@ -23,6 +13,12 @@ vi.doMock('../../db/ensure-db', () => ({ }), })); +vi.doMock('../../main-rpc', () => ({ + mainRPC: { + getPath: async () => appDataPath, + }, +})); + afterEach(async () => { await fs.remove(tmpDir); }); @@ -31,30 +27,22 @@ describe('list workspaces', () => { test('listWorkspaces (valid)', async () => { const { listWorkspaces } = await import('../handlers'); const workspaceId = v4(); - const workspacePath = path.join( - testAppContext.appDataPath, - 'workspaces', - workspaceId - ); + const workspacePath = path.join(appDataPath, 'workspaces', workspaceId); const meta = { id: workspaceId, }; await fs.ensureDir(workspacePath); await fs.writeJSON(path.join(workspacePath, 'meta.json'), meta); - const workspaces = await listWorkspaces(testAppContext); + const workspaces = await listWorkspaces(); expect(workspaces).toEqual([[workspaceId, meta]]); }); test('listWorkspaces (without meta json file)', async () => { const { listWorkspaces } = await import('../handlers'); const workspaceId = v4(); - const workspacePath = path.join( - testAppContext.appDataPath, - 'workspaces', - workspaceId - ); + const workspacePath = path.join(appDataPath, 'workspaces', workspaceId); await fs.ensureDir(workspacePath); - const workspaces = await listWorkspaces(testAppContext); + const workspaces = await listWorkspaces(); expect(workspaces).toEqual([ [ workspaceId, @@ -69,18 +57,14 @@ describe('delete workspace', () => { test('deleteWorkspace', async () => { const { deleteWorkspace } = await import('../handlers'); const workspaceId = v4(); - const workspacePath = path.join( - testAppContext.appDataPath, - 'workspaces', - workspaceId - ); + const workspacePath = path.join(appDataPath, 'workspaces', workspaceId); await fs.ensureDir(workspacePath); - await deleteWorkspace(testAppContext, workspaceId); + await deleteWorkspace(workspaceId); expect(await fs.pathExists(workspacePath)).toBe(false); - // removed workspace will be moved to delete-workspaces + // removed workspace will be moved to deleted-workspaces expect( await fs.pathExists( - path.join(testAppContext.appDataPath, 'delete-workspaces', workspaceId) + path.join(appDataPath, 'deleted-workspaces', workspaceId) ) ).toBe(true); }); @@ -90,29 +74,21 @@ describe('getWorkspaceMeta', () => { test('can get meta', async () => { const { getWorkspaceMeta } = await import('../handlers'); const workspaceId = v4(); - const workspacePath = path.join( - testAppContext.appDataPath, - 'workspaces', - workspaceId - ); + const workspacePath = path.join(appDataPath, 'workspaces', workspaceId); const meta = { id: workspaceId, }; await fs.ensureDir(workspacePath); await fs.writeJSON(path.join(workspacePath, 'meta.json'), meta); - expect(await getWorkspaceMeta(testAppContext, workspaceId)).toEqual(meta); + expect(await getWorkspaceMeta(workspaceId)).toEqual(meta); }); test('can create meta if not exists', async () => { const { getWorkspaceMeta } = await import('../handlers'); const workspaceId = v4(); - const workspacePath = path.join( - testAppContext.appDataPath, - 'workspaces', - workspaceId - ); + const workspacePath = path.join(appDataPath, 'workspaces', workspaceId); await fs.ensureDir(workspacePath); - expect(await getWorkspaceMeta(testAppContext, workspaceId)).toEqual({ + expect(await getWorkspaceMeta(workspaceId)).toEqual({ id: workspaceId, mainDBPath: path.join(workspacePath, 'storage.db'), }); @@ -124,18 +100,14 @@ describe('getWorkspaceMeta', () => { test('can migrate meta if db file is a link', async () => { const { getWorkspaceMeta } = await import('../handlers'); const workspaceId = v4(); - const workspacePath = path.join( - testAppContext.appDataPath, - 'workspaces', - workspaceId - ); + const workspacePath = path.join(appDataPath, 'workspaces', workspaceId); await fs.ensureDir(workspacePath); const sourcePath = path.join(tmpDir, 'source.db'); await fs.writeFile(sourcePath, 'test'); await fs.ensureSymlink(sourcePath, path.join(workspacePath, 'storage.db')); - expect(await getWorkspaceMeta(testAppContext, workspaceId)).toEqual({ + expect(await getWorkspaceMeta(workspaceId)).toEqual({ id: workspaceId, mainDBPath: path.join(workspacePath, 'storage.db'), secondaryDBPath: sourcePath, @@ -150,21 +122,17 @@ describe('getWorkspaceMeta', () => { test('storeWorkspaceMeta', async () => { const { storeWorkspaceMeta } = await import('../handlers'); const workspaceId = v4(); - const workspacePath = path.join( - testAppContext.appDataPath, - 'workspaces', - workspaceId - ); + const workspacePath = path.join(appDataPath, 'workspaces', workspaceId); await fs.ensureDir(workspacePath); const meta = { id: workspaceId, mainDBPath: path.join(workspacePath, 'storage.db'), }; - await storeWorkspaceMeta(testAppContext, workspaceId, meta); + await storeWorkspaceMeta(workspaceId, meta); expect(await fs.readJSON(path.join(workspacePath, 'meta.json'))).toEqual( meta ); - await storeWorkspaceMeta(testAppContext, workspaceId, { + await storeWorkspaceMeta(workspaceId, { secondaryDBPath: path.join(tmpDir, 'test.db'), }); expect(await fs.readJSON(path.join(workspacePath, 'meta.json'))).toEqual({ diff --git a/apps/electron/layers/main/src/workspace/handlers.ts b/apps/electron/src/helper/workspace/handlers.ts similarity index 58% rename from apps/electron/layers/main/src/workspace/handlers.ts rename to apps/electron/src/helper/workspace/handlers.ts index 31711df61f..44a6ffaf0f 100644 --- a/apps/electron/layers/main/src/workspace/handlers.ts +++ b/apps/electron/src/helper/workspace/handlers.ts @@ -2,26 +2,38 @@ import path from 'node:path'; import fs from 'fs-extra'; -import { type AppContext } from '../context'; import { ensureSQLiteDB } from '../db/ensure-db'; import { logger } from '../logger'; +import { mainRPC } from '../main-rpc'; import type { WorkspaceMeta } from '../type'; import { workspaceSubjects } from './subjects'; -export async function listWorkspaces( - context: AppContext -): Promise<[workspaceId: string, meta: WorkspaceMeta][]> { - const basePath = getWorkspacesBasePath(context); +let _appDataPath = ''; + +async function getAppDataPath() { + if (_appDataPath) { + return _appDataPath; + } + _appDataPath = await mainRPC.getPath('sessionData'); + return _appDataPath; +} + +export async function listWorkspaces(): Promise< + [workspaceId: string, meta: WorkspaceMeta][] +> { + const basePath = await getWorkspacesBasePath(); try { await fs.ensureDir(basePath); - const dirs = await fs.readdir(basePath, { - withFileTypes: true, - }); + const dirs = ( + await fs.readdir(basePath, { + withFileTypes: true, + }) + ).filter(d => d.isDirectory()); const metaList = ( await Promise.all( dirs.map(async dir => { // ? shall we put all meta in a single file instead of one file per workspace? - return await getWorkspaceMeta(context, dir.name); + return await getWorkspaceMeta(dir.name); }) ) ).filter((w): w is WorkspaceMeta => !!w); @@ -32,13 +44,9 @@ export async function listWorkspaces( } } -export async function deleteWorkspace(context: AppContext, id: string) { - const basePath = getWorkspaceBasePath(context, id); - const movedPath = path.join( - context.appDataPath, - 'delete-workspaces', - `${id}` - ); +export async function deleteWorkspace(id: string) { + const basePath = await getWorkspaceBasePath(id); + const movedPath = path.join(await getDeletedWorkspacesBasePath(), `${id}`); try { const db = await ensureSQLiteDB(id); await db.destroy(); @@ -50,22 +58,24 @@ export async function deleteWorkspace(context: AppContext, id: string) { } } -export function getWorkspacesBasePath(context: AppContext) { - return path.join(context.appDataPath, 'workspaces'); +export async function getWorkspacesBasePath() { + return path.join(await getAppDataPath(), 'workspaces'); } -export function getWorkspaceBasePath(context: AppContext, workspaceId: string) { - return path.join(context.appDataPath, 'workspaces', workspaceId); +export async function getWorkspaceBasePath(workspaceId: string) { + return path.join(await getAppDataPath(), 'workspaces', workspaceId); } -export function getWorkspaceDBPath(context: AppContext, workspaceId: string) { - const basePath = getWorkspaceBasePath(context, workspaceId); - return path.join(basePath, 'storage.db'); +async function getDeletedWorkspacesBasePath() { + return path.join(await getAppDataPath(), 'deleted-workspaces'); } -export function getWorkspaceMetaPath(context: AppContext, workspaceId: string) { - const basePath = getWorkspaceBasePath(context, workspaceId); - return path.join(basePath, 'meta.json'); +export async function getWorkspaceDBPath(workspaceId: string) { + return path.join(await getWorkspaceBasePath(workspaceId), 'storage.db'); +} + +export async function getWorkspaceMetaPath(workspaceId: string) { + return path.join(await getWorkspaceBasePath(workspaceId), 'meta.json'); } /** @@ -73,16 +83,15 @@ export function getWorkspaceMetaPath(context: AppContext, workspaceId: string) { * This function will also migrate the workspace if needed */ export async function getWorkspaceMeta( - context: AppContext, workspaceId: string ): Promise { try { - const basePath = getWorkspaceBasePath(context, workspaceId); - const metaPath = getWorkspaceMetaPath(context, workspaceId); + const basePath = await getWorkspaceBasePath(workspaceId); + const metaPath = await getWorkspaceMetaPath(workspaceId); if (!(await fs.exists(metaPath))) { // since not meta is found, we will migrate symlinked db file if needed await fs.ensureDir(basePath); - const dbPath = getWorkspaceDBPath(context, workspaceId); + const dbPath = await getWorkspaceDBPath(workspaceId); // todo: remove this after migration (in stable version) const realDBPath = (await fs.exists(dbPath)) @@ -111,15 +120,14 @@ export async function getWorkspaceMeta( } export async function storeWorkspaceMeta( - context: AppContext, workspaceId: string, meta: Partial ) { try { - const basePath = getWorkspaceBasePath(context, workspaceId); + const basePath = await getWorkspaceBasePath(workspaceId); await fs.ensureDir(basePath); const metaPath = path.join(basePath, 'meta.json'); - const currentMeta = await getWorkspaceMeta(context, workspaceId); + const currentMeta = await getWorkspaceMeta(workspaceId); const newMeta = { ...currentMeta, ...meta, diff --git a/apps/electron/layers/main/src/workspace/index.ts b/apps/electron/src/helper/workspace/index.ts similarity index 53% rename from apps/electron/layers/main/src/workspace/index.ts rename to apps/electron/src/helper/workspace/index.ts index aefa4ffed6..c36e86a6d6 100644 --- a/apps/electron/layers/main/src/workspace/index.ts +++ b/apps/electron/src/helper/workspace/index.ts @@ -1,9 +1,4 @@ -import { appContext } from '../context'; -import type { - MainEventListener, - NamespaceHandlers, - WorkspaceMeta, -} from '../type'; +import type { MainEventRegister, WorkspaceMeta } from '../type'; import { deleteWorkspace, getWorkspaceMeta, listWorkspaces } from './handlers'; import { workspaceSubjects } from './subjects'; @@ -19,12 +14,12 @@ export const workspaceEvents = { sub.unsubscribe(); }; }, -} satisfies Record; +} satisfies Record; export const workspaceHandlers = { - list: async () => listWorkspaces(appContext), - delete: async (_, id: string) => deleteWorkspace(appContext, id), - getMeta: async (_, id: string) => { - return getWorkspaceMeta(appContext, id); + list: async () => listWorkspaces(), + delete: async (id: string) => deleteWorkspace(id), + getMeta: async (id: string) => { + return getWorkspaceMeta(id); }, -} satisfies NamespaceHandlers; +}; diff --git a/apps/electron/layers/main/src/workspace/subjects.ts b/apps/electron/src/helper/workspace/subjects.ts similarity index 100% rename from apps/electron/layers/main/src/workspace/subjects.ts rename to apps/electron/src/helper/workspace/subjects.ts diff --git a/apps/electron/layers/main/src/workspace/__tests__/.gitignore b/apps/electron/src/main/__tests__/.gitignore similarity index 100% rename from apps/electron/layers/main/src/workspace/__tests__/.gitignore rename to apps/electron/src/main/__tests__/.gitignore diff --git a/apps/electron/src/main/__tests__/integration.spec.ts b/apps/electron/src/main/__tests__/integration.spec.ts new file mode 100644 index 0000000000..4559fb146f --- /dev/null +++ b/apps/electron/src/main/__tests__/integration.spec.ts @@ -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)[] +>(); + +type WithoutFirstParameter = 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> +): // @ts-expect-error +ReturnType { + // @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) => { + 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, + dialog: {} as Partial, +}; + +// 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 = () => {}; + }); +}); diff --git a/apps/electron/layers/main/src/application-menu/create.ts b/apps/electron/src/main/application-menu/create.ts similarity index 100% rename from apps/electron/layers/main/src/application-menu/create.ts rename to apps/electron/src/main/application-menu/create.ts diff --git a/apps/electron/layers/main/src/application-menu/index.ts b/apps/electron/src/main/application-menu/index.ts similarity index 80% rename from apps/electron/layers/main/src/application-menu/index.ts rename to apps/electron/src/main/application-menu/index.ts index 11b4fc73c4..d0c25ebe5b 100644 --- a/apps/electron/layers/main/src/application-menu/index.ts +++ b/apps/electron/src/main/application-menu/index.ts @@ -1,4 +1,4 @@ -import type { MainEventListener } from '../type'; +import type { MainEventRegister } from '../type'; import { applicationMenuSubjects } from './subject'; export * from './create'; @@ -17,4 +17,4 @@ export const applicationMenuEvents = { sub.unsubscribe(); }; }, -} satisfies Record; +} satisfies Record; diff --git a/apps/electron/layers/main/src/application-menu/subject.ts b/apps/electron/src/main/application-menu/subject.ts similarity index 100% rename from apps/electron/layers/main/src/application-menu/subject.ts rename to apps/electron/src/main/application-menu/subject.ts diff --git a/apps/electron/layers/main/src/events.ts b/apps/electron/src/main/events.ts similarity index 89% rename from apps/electron/layers/main/src/events.ts rename to apps/electron/src/main/events.ts index 9ad9393800..d9444603b8 100644 --- a/apps/electron/layers/main/src/events.ts +++ b/apps/electron/src/main/events.ts @@ -1,16 +1,12 @@ import { app, BrowserWindow } from 'electron'; import { applicationMenuEvents } from './application-menu'; -import { dbEvents } from './db'; import { logger } from './logger'; import { updaterEvents } from './updater/event'; -import { workspaceEvents } from './workspace'; export const allEvents = { applicationMenu: applicationMenuEvents, - db: dbEvents, updater: updaterEvents, - workspace: workspaceEvents, }; function getActiveWindows() { diff --git a/apps/electron/layers/main/src/export/index.ts b/apps/electron/src/main/export/index.ts similarity index 100% rename from apps/electron/layers/main/src/export/index.ts rename to apps/electron/src/main/export/index.ts diff --git a/apps/electron/layers/main/src/export/pdf.ts b/apps/electron/src/main/export/pdf.ts similarity index 100% rename from apps/electron/layers/main/src/export/pdf.ts rename to apps/electron/src/main/export/pdf.ts diff --git a/apps/electron/layers/main/src/export/utils.ts b/apps/electron/src/main/export/utils.ts similarity index 100% rename from apps/electron/layers/main/src/export/utils.ts rename to apps/electron/src/main/export/utils.ts diff --git a/apps/electron/layers/main/src/exposed.ts b/apps/electron/src/main/exposed.ts similarity index 75% rename from apps/electron/layers/main/src/exposed.ts rename to apps/electron/src/main/exposed.ts index 3f1c4c447d..59259ca118 100644 --- a/apps/electron/layers/main/src/exposed.ts +++ b/apps/electron/src/main/exposed.ts @@ -9,19 +9,13 @@ export { events, handlers }; export const getExposedMeta = () => { const handlersMeta = Object.entries(handlers).map( ([namespace, namespaceHandlers]) => { - return [ - namespace, - Object.keys(namespaceHandlers).map(handlerName => handlerName), - ]; + return [namespace, Object.keys(namespaceHandlers)]; } ); const eventsMeta = Object.entries(events).map( ([namespace, namespaceHandlers]) => { - return [ - namespace, - Object.keys(namespaceHandlers).map(handlerName => handlerName), - ]; + return [namespace, Object.keys(namespaceHandlers)]; } ); @@ -32,5 +26,4 @@ export const getExposedMeta = () => { }; export type MainIPCHandlerMap = typeof handlers; - export type MainIPCEventMap = typeof events; diff --git a/apps/electron/layers/main/src/handlers.ts b/apps/electron/src/main/handlers.ts similarity index 78% rename from apps/electron/layers/main/src/handlers.ts rename to apps/electron/src/main/handlers.ts index 77abb0dff5..3eedda347b 100644 --- a/apps/electron/layers/main/src/handlers.ts +++ b/apps/electron/src/main/handlers.ts @@ -1,22 +1,16 @@ import type { - DBHandlerManager, DebugHandlerManager, - DialogHandlerManager, ExportHandlerManager, UIHandlerManager, UnwrapManagerHandlerToServerSide, UpdaterHandlerManager, - WorkspaceHandlerManager, } from '@toeverything/infra'; import { ipcMain } from 'electron'; -import { dbHandlers } from './db'; -import { dialogHandlers } from './dialog'; import { exportHandlers } from './export'; import { getLogFilePath, logger, revealLogFile } from './logger'; import { uiHandlers } from './ui'; import { updaterHandlers } from './updater'; -import { workspaceHandlers } from './workspace'; export const debugHandlers = { revealLogFile: async () => { @@ -28,18 +22,10 @@ export const debugHandlers = { }; type AllHandlers = { - db: UnwrapManagerHandlerToServerSide< - Electron.IpcMainInvokeEvent, - DBHandlerManager - >; debug: UnwrapManagerHandlerToServerSide< Electron.IpcMainInvokeEvent, DebugHandlerManager >; - dialog: UnwrapManagerHandlerToServerSide< - Electron.IpcMainInvokeEvent, - DialogHandlerManager - >; export: UnwrapManagerHandlerToServerSide< Electron.IpcMainInvokeEvent, ExportHandlerManager @@ -52,21 +38,14 @@ type AllHandlers = { Electron.IpcMainInvokeEvent, UpdaterHandlerManager >; - workspace: UnwrapManagerHandlerToServerSide< - Electron.IpcMainInvokeEvent, - WorkspaceHandlerManager - >; }; // Note: all of these handlers will be the single-source-of-truth for the apis exposed to the renderer process export const allHandlers = { - db: dbHandlers, debug: debugHandlers, - dialog: dialogHandlers, ui: uiHandlers, export: exportHandlers, updater: updaterHandlers, - workspace: workspaceHandlers, } satisfies AllHandlers; export const registerHandlers = () => { @@ -78,6 +57,7 @@ export const registerHandlers = () => { ipcMain.handle(chan, async (e, ...args) => { const start = performance.now(); try { + // @ts-expect-error - TODO: fix this const result = await handler(e, ...args); logger.info( '[ipc-api]', diff --git a/apps/electron/src/main/helper-process.ts b/apps/electron/src/main/helper-process.ts new file mode 100644 index 0000000000..1c59ec0d0b --- /dev/null +++ b/apps/electron/src/main/helper-process.ts @@ -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( + 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; + #process: UtilityProcess; + + // a rpc server for the main process -> helper process + rpc?: _AsyncVersionOf; + + 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(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; +} diff --git a/apps/electron/layers/main/src/index.ts b/apps/electron/src/main/index.ts similarity index 95% rename from apps/electron/layers/main/src/index.ts rename to apps/electron/src/main/index.ts index d54c84d72a..3026af929a 100644 --- a/apps/electron/layers/main/src/index.ts +++ b/apps/electron/src/main/index.ts @@ -5,6 +5,7 @@ import { app } from 'electron'; import { createApplicationMenu } from './application-menu/create'; import { registerEvents } from './events'; import { registerHandlers } from './handlers'; +import { ensureHelperProcess } from './helper-process'; import { logger } from './logger'; import { restoreOrCreateWindow } from './main-window'; import { registerPlugin } from './plugin'; @@ -62,7 +63,9 @@ app .then(registerHandlers) .then(registerEvents) .then(registerPlugin) + .then(ensureHelperProcess) .then(restoreOrCreateWindow) .then(createApplicationMenu) + .then() .then(registerUpdater) .catch(e => console.error('Failed create window:', e)); diff --git a/apps/electron/layers/main/src/logger.ts b/apps/electron/src/main/logger.ts similarity index 82% rename from apps/electron/layers/main/src/logger.ts rename to apps/electron/src/main/logger.ts index 7bf94d58cd..9f6faef8df 100644 --- a/apps/electron/layers/main/src/logger.ts +++ b/apps/electron/src/main/logger.ts @@ -1,7 +1,8 @@ import { shell } from 'electron'; import log from 'electron-log'; -export const logger = log; +export const logger = log.scope('main'); +log.initialize(); export function getLogFilePath() { return log.transports.file.getFile().path; diff --git a/apps/electron/layers/main/src/main-window.ts b/apps/electron/src/main/main-window.ts similarity index 80% rename from apps/electron/layers/main/src/main-window.ts rename to apps/electron/src/main/main-window.ts index 9f6d885c13..2a3543433c 100644 --- a/apps/electron/layers/main/src/main-window.ts +++ b/apps/electron/src/main/main-window.ts @@ -1,8 +1,11 @@ +import assert from 'node:assert'; + import { BrowserWindow, nativeTheme } from 'electron'; import electronWindowState from 'electron-window-state'; import { join } from 'path'; import { getExposedMeta } from './exposed'; +import { ensureHelperProcess } from './helper-process'; import { logger } from './logger'; import { isMacOS, isWindows } from './utils'; @@ -18,7 +21,12 @@ async function createWindow() { defaultHeight: 800, }); - const exposedMeta = getExposedMeta(); + const helperProcessManager = await ensureHelperProcess(); + const helperExposedMeta = await helperProcessManager.rpc?.getMeta(); + + assert(helperExposedMeta, 'helperExposedMeta should be defined'); + + const mainExposedMeta = getExposedMeta(); const browserWindow = new BrowserWindow({ titleBarStyle: isMacOS() @@ -42,9 +50,12 @@ async function createWindow() { sandbox: false, webviewTag: false, // The webview tag is not recommended. Consider alternatives like iframe or Electron's BrowserView. https://www.electronjs.org/docs/latest/api/webview-tag#warning spellcheck: false, // FIXME: enable? - preload: join(__dirname, '../preload/index.js'), + preload: join(__dirname, './preload.js'), // serialize exposed meta that to be used in preload - additionalArguments: [`--exposed-meta=` + JSON.stringify(exposedMeta)], + additionalArguments: [ + `--main-exposed-meta=` + JSON.stringify(mainExposedMeta), + `--helper-exposed-meta=` + JSON.stringify(helperExposedMeta), + ], }, }); @@ -52,6 +63,8 @@ async function createWindow() { mainWindowState.manage(browserWindow); + let helperConnectionUnsub: (() => void) | undefined; + /** * If you install `show: true` then it can cause issues when trying to close the window. * Use `show: false` and listener events `ready-to-show` to fix these issues. @@ -65,6 +78,9 @@ async function createWindow() { } else { browserWindow.show(); } + helperConnectionUnsub = helperProcessManager.connectRenderer( + browserWindow.webContents + ); logger.info('main window is ready to show'); @@ -78,6 +94,7 @@ async function createWindow() { browserWindow.on('close', e => { e.preventDefault(); browserWindow.destroy(); + helperConnectionUnsub?.(); // TODO: gracefully close the app, for example, ask user to save unsaved changes }); diff --git a/apps/electron/layers/main/src/plugin.ts b/apps/electron/src/main/plugin.ts similarity index 84% rename from apps/electron/layers/main/src/plugin.ts rename to apps/electron/src/main/plugin.ts index e136a6e827..2038b34038 100644 --- a/apps/electron/layers/main/src/plugin.ts +++ b/apps/electron/src/main/plugin.ts @@ -1,10 +1,10 @@ -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { Worker } from 'node:worker_threads'; import { AsyncCall } from 'async-call-rpc'; import { ipcMain } from 'electron'; -import { ThreadWorkerChannel } from './utils'; +import { MessageEventChannel } from './utils'; declare global { // fixme(himself65): @@ -20,17 +20,17 @@ export async function registerPlugin() { >( {}, { - channel: new ThreadWorkerChannel(new Worker(pluginWorkerPath)), + channel: new MessageEventChannel(new Worker(pluginWorkerPath)), } ); globalThis.asyncCall = asyncCall; await import('@toeverything/plugin-infra/manager').then( ({ rootStore, affinePluginsAtom }) => { const bookmarkPluginPath = join( - process.env.PLUGIN_DIR ?? '../../plugins', + process.env.PLUGIN_DIR ?? resolve(__dirname, './plugins'), './bookmark-block/index.mjs' ); - import(bookmarkPluginPath); + import('file://' + bookmarkPluginPath); let dispose: () => void = () => { // noop }; diff --git a/apps/electron/layers/main/src/protocol.ts b/apps/electron/src/main/protocol.ts similarity index 92% rename from apps/electron/layers/main/src/protocol.ts rename to apps/electron/src/main/protocol.ts index 1e190f5b62..4dfd18f9b3 100644 --- a/apps/electron/layers/main/src/protocol.ts +++ b/apps/electron/src/main/protocol.ts @@ -16,7 +16,7 @@ protocol.registerSchemesAsPrivileged([ function toAbsolutePath(url: string) { let realpath = decodeURIComponent(url); - const webStaticDir = join(__dirname, '../../../resources/web-static'); + const webStaticDir = join(__dirname, '../resources/web-static'); if (url.startsWith('./')) { // if is a file type, load the file in resources if (url.split('/').at(-1)?.includes('.')) { @@ -34,6 +34,7 @@ export function registerProtocol() { const url = request.url.replace(/^file:\/\//, ''); const realpath = toAbsolutePath(url); callback(realpath); + console.log('interceptFileProtocol realpath', request.url, realpath); return true; }); diff --git a/apps/electron/layers/main/src/security-restrictions.ts b/apps/electron/src/main/security-restrictions.ts similarity index 100% rename from apps/electron/layers/main/src/security-restrictions.ts rename to apps/electron/src/main/security-restrictions.ts diff --git a/apps/electron/src/main/type.ts b/apps/electron/src/main/type.ts new file mode 100644 index 0000000000..956565c985 --- /dev/null +++ b/apps/electron/src/main/type.ts @@ -0,0 +1,10 @@ +export type MainEventRegister = (...args: any[]) => () => void; + +export type IsomorphicHandler = ( + e: Electron.IpcMainInvokeEvent, + ...args: any[] +) => Promise; + +export type NamespaceHandlers = { + [key: string]: IsomorphicHandler; +}; diff --git a/apps/electron/layers/main/src/ui/google-auth.ts b/apps/electron/src/main/ui/google-auth.ts similarity index 100% rename from apps/electron/layers/main/src/ui/google-auth.ts rename to apps/electron/src/main/ui/google-auth.ts diff --git a/apps/electron/layers/main/src/ui/index.ts b/apps/electron/src/main/ui/index.ts similarity index 100% rename from apps/electron/layers/main/src/ui/index.ts rename to apps/electron/src/main/ui/index.ts diff --git a/apps/electron/layers/main/src/updater/electron-updater.ts b/apps/electron/src/main/updater/electron-updater.ts similarity index 100% rename from apps/electron/layers/main/src/updater/electron-updater.ts rename to apps/electron/src/main/updater/electron-updater.ts diff --git a/apps/electron/layers/main/src/updater/event.ts b/apps/electron/src/main/updater/event.ts similarity index 90% rename from apps/electron/layers/main/src/updater/event.ts rename to apps/electron/src/main/updater/event.ts index 8c95352945..02a368185d 100644 --- a/apps/electron/layers/main/src/updater/event.ts +++ b/apps/electron/src/main/updater/event.ts @@ -1,6 +1,6 @@ import { BehaviorSubject, Subject } from 'rxjs'; -import type { MainEventListener } from '../type'; +import type { MainEventRegister } from '../type'; export interface UpdateMeta { version: string; @@ -33,4 +33,4 @@ export const updaterEvents = { sub.unsubscribe(); }; }, -} satisfies Record; +} satisfies Record; diff --git a/apps/electron/layers/main/src/updater/index.ts b/apps/electron/src/main/updater/index.ts similarity index 100% rename from apps/electron/layers/main/src/updater/index.ts rename to apps/electron/src/main/updater/index.ts diff --git a/apps/electron/src/main/utils.ts b/apps/electron/src/main/utils.ts new file mode 100644 index 0000000000..80d05b78da --- /dev/null +++ b/apps/electron/src/main/utils.ts @@ -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); + } +} diff --git a/apps/electron/layers/main/src/workers/plugin.worker.ts b/apps/electron/src/main/workers/plugin.worker.ts similarity index 79% rename from apps/electron/layers/main/src/workers/plugin.worker.ts rename to apps/electron/src/main/workers/plugin.worker.ts index be8f4fccb8..456eb0334b 100644 --- a/apps/electron/layers/main/src/workers/plugin.worker.ts +++ b/apps/electron/src/main/workers/plugin.worker.ts @@ -1,9 +1,9 @@ -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { parentPort } from 'node:worker_threads'; import { AsyncCall } from 'async-call-rpc'; -import { MessagePortChannel } from '../utils'; +import { MessageEventChannel } from '../utils'; const commandProxy: Record Promise> = {}; @@ -12,17 +12,17 @@ if (!parentPort) { } AsyncCall(commandProxy, { - channel: new MessagePortChannel(parentPort), + channel: new MessageEventChannel(parentPort), }); import('@toeverything/plugin-infra/manager').then( ({ rootStore, affinePluginsAtom }) => { const bookmarkPluginPath = join( - process.env.PLUGIN_DIR ?? '../../../plugins', + process.env.PLUGIN_DIR ?? resolve(__dirname, '../plugins'), './bookmark-block/index.mjs' ); - import(bookmarkPluginPath); + import('file://' + bookmarkPluginPath); rootStore.sub(affinePluginsAtom, () => { const plugins = rootStore.get(affinePluginsAtom); Object.values(plugins).forEach(plugin => { diff --git a/apps/electron/src/preload/affine-apis.ts b/apps/electron/src/preload/affine-apis.ts new file mode 100644 index 0000000000..ddf3a5874a --- /dev/null +++ b/apps/electron/src/preload/affine-apis.ts @@ -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(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(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 }; +} diff --git a/apps/electron/layers/preload/src/bootstrap.ts b/apps/electron/src/preload/bootstrap.ts similarity index 81% rename from apps/electron/layers/preload/src/bootstrap.ts rename to apps/electron/src/preload/bootstrap.ts index 6eaabff6bd..f47dab0e93 100644 --- a/apps/electron/layers/preload/src/bootstrap.ts +++ b/apps/electron/src/preload/bootstrap.ts @@ -1,10 +1,12 @@ import { contextBridge, ipcRenderer } from 'electron'; (async () => { - const affineApis = await import('./affine-apis'); - contextBridge.exposeInMainWorld('apis', affineApis.apis); - contextBridge.exposeInMainWorld('events', affineApis.events); - contextBridge.exposeInMainWorld('appInfo', affineApis.appInfo); + const { appInfo, getAffineAPIs } = await import('./affine-apis'); + const { apis, events } = getAffineAPIs(); + + contextBridge.exposeInMainWorld('appInfo', appInfo); + contextBridge.exposeInMainWorld('apis', apis); + contextBridge.exposeInMainWorld('events', events); // Credit to microsoft/vscode const globals = { diff --git a/apps/electron/layers/preload/src/index.ts b/apps/electron/src/preload/index.ts similarity index 100% rename from apps/electron/layers/preload/src/index.ts rename to apps/electron/src/preload/index.ts diff --git a/apps/electron/src/types.d.ts b/apps/electron/src/types.d.ts new file mode 100644 index 0000000000..cbb08e321e --- /dev/null +++ b/apps/electron/src/types.d.ts @@ -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; + } + + // 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 +} diff --git a/apps/electron/tests/fixture.ts b/apps/electron/tests/fixture.ts index 1f684f97a8..6e4d14b1b6 100644 --- a/apps/electron/tests/fixture.ts +++ b/apps/electron/tests/fixture.ts @@ -80,8 +80,21 @@ export const test = base.extend<{ // a random id to avoid conflicts between tests const id = generateUUID(); const ext = process.platform === 'win32' ? '.cmd' : ''; + const dist = resolve(__dirname, '..', 'dist'); + const clonedDist = resolve(__dirname, '../e2e-dist-' + id); + await fs.copy(dist, clonedDist); + const packageJson = await fs.readJSON( + resolve(__dirname, '..', 'package.json') + ); + // overwrite the app name + packageJson.name = 'affine-test-' + id; + // overwrite the path to the main script + packageJson.main = './main.js'; + // write to the cloned dist + await fs.writeJSON(resolve(clonedDist, 'package.json'), packageJson); + const electronApp = await electron.launch({ - args: [resolve(__dirname, '..'), '--app-name', 'affine-test-' + id], + args: [clonedDist], executablePath: resolve( __dirname, '..', @@ -95,11 +108,11 @@ export const test = base.extend<{ colorScheme: 'light', }); await use(electronApp); - // FIXME: the following does not work well on CI - // const sessionDataPath = await electronApp.evaluate(async ({ app }) => { - // return app.getPath('sessionData'); - // }); - // await fs.rm(sessionDataPath, { recursive: true, force: true }); + try { + await fs.rm(clonedDist, { recursive: true, force: true }); + } catch (error) { + console.log(error); + } }, appInfo: async ({ electronApp }, use) => { const appInfo = await electronApp.evaluate(async ({ app }) => { diff --git a/apps/electron/tsconfig.json b/apps/electron/tsconfig.json index b14dbe19bf..139f7d3649 100644 --- a/apps/electron/tsconfig.json +++ b/apps/electron/tsconfig.json @@ -13,7 +13,7 @@ "resolveJsonModule": true, "noImplicitOverride": true }, - "include": ["./layers"], + "include": ["./src"], "exclude": ["node_modules", "out", "dist"], "references": [ { diff --git a/apps/web/src/types/types.d.ts b/apps/web/src/types/types.d.ts index a459f45e71..d8f47f3769 100644 --- a/apps/web/src/types/types.d.ts +++ b/apps/web/src/types/types.d.ts @@ -2,7 +2,7 @@ // not using import because it will break the declare module line below // eslint-disable-next-line @typescript-eslint/triple-slash-reference -/// +/// declare module '*.md' { const text: string; diff --git a/packages/debug/src/index.ts b/packages/debug/src/index.ts index 8f6cff09aa..179838c501 100644 --- a/packages/debug/src/index.ts +++ b/packages/debug/src/index.ts @@ -17,7 +17,6 @@ if (typeof window !== 'undefined') { debug.enable('*'); console.warn('Debug logs enabled'); } -} else { if (process.env.NODE_ENV === 'development') { debug.enable('*'); console.warn('Debug logs enabled'); diff --git a/tsconfig.json b/tsconfig.json index 2efbe3922d..faf524d98a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -81,7 +81,7 @@ "@affine/native/*": ["./packages/native/*"], // Development only - "@affine/electron/layers/*": ["./apps/electron/layers/*"] + "@affine/electron/*": ["./apps/electron/src/*"] } }, "include": [], diff --git a/vitest.config.ts b/vitest.config.ts index a122261ec8..9cf317e2a8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -29,7 +29,7 @@ export default defineConfig({ ], // split tests that include native addons or not include: process.env.NATIVE_TEST - ? ['apps/electron/layers/**/*.spec.ts'] + ? ['apps/electron/src/**/*.spec.ts'] : [ 'packages/**/*.spec.ts', 'packages/**/*.spec.tsx',