diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 71c491f91b..57e9ac9623 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -47,7 +47,7 @@ jobs: uses: ./.github/actions/setup-node - name: Build Electron working-directory: apps/electron - run: yarn exec ts-node-esm ./scripts/build-ci.mts + run: yarn build-layers - name: Upload Ubuntu desktop artifact uses: actions/upload-artifact@v3 with: @@ -298,6 +298,10 @@ jobs: name: next-js-static path: ./apps/electron/resources/web-static + - name: Rebuild Electron dependences + run: yarn rebuild:for-electron + working-directory: apps/electron + - name: Run desktop tests run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- yarn test working-directory: apps/electron diff --git a/.vscode/settings.json b/.vscode/settings.json index 33a0999a61..a470203b8d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -29,5 +29,14 @@ "rust-analyzer.linkedProjects": ["packages/octobase-node/Cargo.toml"], "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" - } + }, + "vitest.include": [ + "packages/**/*.spec.ts", + "packages/**/*.spec.tsx", + "apps/web/**/*.spec.ts", + "apps/web/**/*.spec.tsx", + "apps/electron/layers/**/*.spec.ts", + "tests/unit/**/*.spec.ts", + "tests/unit/**/*.spec.tsx" + ] } diff --git a/apps/electron/forge.config.js b/apps/electron/forge.config.js index b00cb19515..4352564266 100644 --- a/apps/electron/forge.config.js +++ b/apps/electron/forge.config.js @@ -45,6 +45,7 @@ module.exports = { teamId: process.env.APPLE_TEAM_ID, } : undefined, + // do we need the following line? extraResource: ['./resources/app-update.yml'], }, makers: [ diff --git a/apps/electron/layers/constraints.ts b/apps/electron/layers/constraints.ts new file mode 100644 index 0000000000..59df37c8f9 --- /dev/null +++ b/apps/electron/layers/constraints.ts @@ -0,0 +1,7 @@ +/* eslint-disable @typescript-eslint/consistent-type-imports */ +// This file contains the main process events +// It will guide preload and main process on the correct event types and payloads + +export type MainIPCHandlerMap = typeof import('./main/src/exposed').handlers; + +export type MainIPCEventMap = typeof import('./main/src/exposed').events; diff --git a/apps/electron/layers/logger.ts b/apps/electron/layers/logger.ts deleted file mode 100644 index d818e6a10c..0000000000 --- a/apps/electron/layers/logger.ts +++ /dev/null @@ -1,3 +0,0 @@ -import log from 'electron-log'; - -export const logger = log; diff --git a/apps/electron/layers/main-events.ts b/apps/electron/layers/main-events.ts deleted file mode 100644 index 9a216fcb4f..0000000000 --- a/apps/electron/layers/main-events.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file contains the main process events -// It will guide preload and main process on the correct event types and payloads -export interface MainEventMap { - 'main:on-db-update': (workspaceId: string) => void; - 'main:client-update-available': (version: string) => void; -} diff --git a/apps/electron/layers/main/src/__tests__/handlers.spec.ts b/apps/electron/layers/main/src/__tests__/handlers.spec.ts deleted file mode 100644 index db07034ecc..0000000000 --- a/apps/electron/layers/main/src/__tests__/handlers.spec.ts +++ /dev/null @@ -1,226 +0,0 @@ -import assert from 'node:assert'; -import path from 'node:path'; - -import fs from 'fs-extra'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import * as Y from 'yjs'; - -const registeredHandlers = new Map any>(); - -// common mock dispatcher for ipcMain.handle and app.on -async function dispatch(key: string, ...args: any[]) { - const handler = registeredHandlers.get(key); - assert(handler); - return await handler(null, ...args); -} - -const APP_PATH = path.join(__dirname, './tmp'); - -const browserWindow = { - isDestroyed: () => { - return false; - }, - setWindowButtonVisibility: (_v: boolean) => { - // will be stubbed later - }, - webContents: { - send: (_type: string, ..._args: any[]) => { - // ... - }, - }, -}; - -const ipcMain = { - handle: (key: string, callback: (...args: any[]) => any) => { - registeredHandlers.set(key, callback); - }, -}; - -const nativeTheme = { - themeSource: 'light', -}; - -function compareBuffer(a: Uint8Array, b: Uint8Array) { - if (a.length !== b.length) { - return false; - } - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) { - return false; - } - } - return true; -} - -// dynamically import handlers so that we can inject local variables to mocks -vi.doMock('electron', () => { - return { - app: { - getPath: (name: string) => { - assert(name === 'appData'); - return APP_PATH; - }, - name: 'affine-test', - on: (name: string, callback: (...args: any[]) => any) => { - registeredHandlers.set(name, callback); - }, - }, - BrowserWindow: { - getAllWindows: () => { - return [browserWindow]; - }, - }, - nativeTheme: nativeTheme, - ipcMain, - }; -}); - -beforeEach(async () => { - // clean up tmp folder - const { registerHandlers } = await import('../handlers'); - registerHandlers(); -}); - -afterEach(async () => { - const { cleanupWorkspaceDBs } = await import('../handlers'); - cleanupWorkspaceDBs(); - await fs.remove(APP_PATH); -}); - -describe('ensureWorkspaceDB', () => { - test('should create db file on connection if it does not exist', async () => { - const id = 'test-workspace-id'; - const { ensureWorkspaceDB } = await import('../handlers'); - const workspaceDB = await ensureWorkspaceDB(id); - const file = workspaceDB.path; - const fileExists = await fs.pathExists(file); - expect(fileExists).toBe(true); - }); -}); - -describe('workspace handlers', () => { - test('list all workspace ids', async () => { - const ids = ['test-workspace-id', 'test-workspace-id-2']; - const { ensureWorkspaceDB } = await import('../handlers'); - await Promise.all(ids.map(id => ensureWorkspaceDB(id))); - const list = await dispatch('workspace:list'); - expect(list).toEqual(ids); - }); - - test('delete workspace', async () => { - const ids = ['test-workspace-id', 'test-workspace-id-2']; - const { ensureWorkspaceDB } = await import('../handlers'); - await Promise.all(ids.map(id => ensureWorkspaceDB(id))); - await dispatch('workspace:delete', 'test-workspace-id-2'); - const list = await dispatch('workspace:list'); - expect(list).toEqual(['test-workspace-id']); - }); -}); - -describe('UI handlers', () => { - test('theme-change', async () => { - await dispatch('ui:theme-change', 'dark'); - expect(nativeTheme.themeSource).toBe('dark'); - await dispatch('ui:theme-change', 'light'); - expect(nativeTheme.themeSource).toBe('light'); - }); - - test('sidebar-visibility-change (macOS)', async () => { - vi.stubGlobal('process', { platform: 'darwin' }); - const setWindowButtonVisibility = vi.fn(); - browserWindow.setWindowButtonVisibility = setWindowButtonVisibility; - await dispatch('ui:sidebar-visibility-change', true); - expect(setWindowButtonVisibility).toBeCalledWith(true); - await dispatch('ui:sidebar-visibility-change', false); - expect(setWindowButtonVisibility).toBeCalledWith(false); - vi.unstubAllGlobals(); - }); - - test('sidebar-visibility-change (non-macOS)', async () => { - vi.stubGlobal('process', { platform: 'linux' }); - const setWindowButtonVisibility = vi.fn(); - browserWindow.setWindowButtonVisibility = setWindowButtonVisibility; - await dispatch('ui:sidebar-visibility-change', true); - expect(setWindowButtonVisibility).not.toBeCalled(); - vi.unstubAllGlobals(); - }); -}); - -describe('db handlers', () => { - test('will reconnect on activate', async () => { - const { ensureWorkspaceDB } = await import('../handlers'); - const workspaceDB = await ensureWorkspaceDB('test-workspace-id'); - const instance = vi.spyOn(workspaceDB, 'reconnectDB'); - await dispatch('activate'); - expect(instance).toBeCalled(); - }); - - test('apply doc and get doc updates', async () => { - const workspaceId = 'test-workspace-id'; - const bin = await dispatch('db:get-doc', workspaceId); - // ? is this a good test? - expect(bin.every((byte: number) => byte === 0)).toBe(true); - - const ydoc = new Y.Doc(); - const ytext = ydoc.getText('test'); - ytext.insert(0, 'hello world'); - const bin2 = Y.encodeStateAsUpdate(ydoc); - - await dispatch('db:apply-doc-update', workspaceId, bin2); - - const bin3 = await dispatch('db:get-doc', workspaceId); - const ydoc2 = new Y.Doc(); - Y.applyUpdate(ydoc2, bin3); - const ytext2 = ydoc2.getText('test'); - expect(ytext2.toString()).toBe('hello world'); - }); - - test('get non existent doc', async () => { - const workspaceId = 'test-workspace-id'; - const bin = await dispatch('db:get-blob', workspaceId, 'non-existent-id'); - expect(bin).toBeNull(); - }); - - test('list blobs (empty)', async () => { - const workspaceId = 'test-workspace-id'; - const list = await dispatch('db:get-persisted-blobs', workspaceId); - expect(list).toEqual([]); - }); - - test('CRUD blobs', async () => { - const testBin = new Uint8Array([1, 2, 3, 4, 5]); - const testBin2 = new Uint8Array([6, 7, 8, 9, 10]); - const workspaceId = 'test-workspace-id'; - - // add blob - await dispatch('db:add-blob', workspaceId, 'testBin', testBin); - - // get blob - expect( - compareBuffer( - await dispatch('db:get-blob', workspaceId, 'testBin'), - testBin - ) - ).toBe(true); - - // add another blob - await dispatch('db:add-blob', workspaceId, 'testBin2', testBin2); - expect( - compareBuffer( - await dispatch('db:get-blob', workspaceId, 'testBin2'), - testBin2 - ) - ).toBe(true); - - // list blobs - let lists = await dispatch('db:get-persisted-blobs', workspaceId); - expect(lists).toHaveLength(2); - expect(lists).toContain('testBin'); - expect(lists).toContain('testBin2'); - - // delete blob - await dispatch('db:delete-blob', workspaceId, 'testBin'); - lists = await dispatch('db:get-persisted-blobs', workspaceId); - expect(lists).toEqual(['testBin2']); - }); -}); diff --git a/apps/electron/layers/main/src/context.ts b/apps/electron/layers/main/src/context.ts index fe9da23ffe..53f860ee8a 100644 --- a/apps/electron/layers/main/src/context.ts +++ b/apps/electron/layers/main/src/context.ts @@ -1,9 +1,12 @@ import { app } from 'electron'; -import path from 'path'; export const appContext = { - appName: app.name, - appDataPath: path.join(app.getPath('appData'), app.name), + get appName() { + return app.name; + }, + get appDataPath() { + return app.getPath('sessionData'); + }, }; export type AppContext = typeof appContext; diff --git a/apps/electron/layers/main/src/data/export.ts b/apps/electron/layers/main/src/data/export.ts deleted file mode 100644 index 162f70798a..0000000000 --- a/apps/electron/layers/main/src/data/export.ts +++ /dev/null @@ -1,34 +0,0 @@ -import fs from 'fs-extra'; - -import { logger } from '../../../logger'; -import type { WorkspaceDatabase } from './sqlite'; - -/** - * Start a backup of the database to the given destination. - */ -export async function exportDatabase(db: WorkspaceDatabase, dest: string) { - await fs.copyFile(db.path, dest); - logger.log('export: ', dest); -} - -// export async function startBackup(db: WorkspaceDatabase, dest: string) { -// let timeout: NodeJS.Timeout | null; -// async function backup() { -// await fs.copyFile(db.path, dest); -// logger.log('backup: ', dest); -// } - -// backup(); - -// const _db = await db.sqliteDB$; - -// _db.on('change', () => { -// if (timeout) { -// clearTimeout(timeout); -// } -// timeout = setTimeout(async () => { -// await backup(); -// timeout = null; -// }, 1000); -// }); -// } diff --git a/apps/electron/layers/main/src/data/fs-watch.ts b/apps/electron/layers/main/src/data/fs-watch.ts deleted file mode 100644 index 3545a6b1ce..0000000000 --- a/apps/electron/layers/main/src/data/fs-watch.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { WatchListener } from 'fs-extra'; -import fs from 'fs-extra'; - -export function watchFile(path: string, callback: WatchListener) { - const watcher = fs.watch(path, callback); - return () => watcher.close(); -} diff --git a/apps/electron/layers/main/src/data/workspace.ts b/apps/electron/layers/main/src/data/workspace.ts deleted file mode 100644 index 5fa2b17361..0000000000 --- a/apps/electron/layers/main/src/data/workspace.ts +++ /dev/null @@ -1,34 +0,0 @@ -import path from 'node:path'; - -import fs from 'fs-extra'; - -import { logger } from '../../../logger'; -import type { AppContext } from '../context'; - -export async function listWorkspaces(context: AppContext) { - const basePath = path.join(context.appDataPath, 'workspaces'); - try { - return fs - .readdir(basePath, { - withFileTypes: true, - }) - .then(dirs => dirs.filter(dir => dir.isDirectory()).map(dir => dir.name)); - } catch (error) { - logger.error('listWorkspaces', error); - return []; - } -} - -export async function deleteWorkspace(context: AppContext, id: string) { - const basePath = path.join(context.appDataPath, 'workspaces', id); - const movedPath = path.join( - context.appDataPath, - 'delete-workspaces', - `${id}` - ); - try { - return fs.move(basePath, movedPath); - } catch (error) { - logger.error('deleteWorkspace', error); - } -} diff --git a/apps/electron/layers/main/src/events/db.ts b/apps/electron/layers/main/src/events/db.ts new file mode 100644 index 0000000000..7d2db05b1c --- /dev/null +++ b/apps/electron/layers/main/src/events/db.ts @@ -0,0 +1,26 @@ +import { Subject } from 'rxjs'; + +import type { MainEventListener } from './type'; + +export const dbSubjects = { + // emit workspace ids + dbFileMissing: new Subject(), + // emit workspace ids + dbFileUpdate: new Subject(), +}; + +export const dbEvents = { + onDbFileMissing: (fn: (workspaceId: string) => void) => { + const sub = dbSubjects.dbFileMissing.subscribe(fn); + + return () => { + sub.unsubscribe(); + }; + }, + onDbFileUpdate: (fn: (workspaceId: string) => void) => { + const sub = dbSubjects.dbFileUpdate.subscribe(fn); + return () => { + sub.unsubscribe(); + }; + }, +} satisfies Record; diff --git a/apps/electron/layers/main/src/events/index.ts b/apps/electron/layers/main/src/events/index.ts new file mode 100644 index 0000000000..780a5979d2 --- /dev/null +++ b/apps/electron/layers/main/src/events/index.ts @@ -0,0 +1,7 @@ +export * from './register'; + +import { dbSubjects } from './db'; + +export const subjects = { + db: dbSubjects, +}; diff --git a/apps/electron/layers/main/src/events/register.ts b/apps/electron/layers/main/src/events/register.ts new file mode 100644 index 0000000000..6dd43acc1d --- /dev/null +++ b/apps/electron/layers/main/src/events/register.ts @@ -0,0 +1,30 @@ +import { app, BrowserWindow } from 'electron'; + +import { logger } from '../logger'; +import { dbEvents } from './db'; +import { updaterEvents } from './updater'; + +export const allEvents = { + db: dbEvents, + updater: updaterEvents, +}; + +function getActiveWindows() { + return BrowserWindow.getAllWindows().filter(win => !win.isDestroyed()); +} + +export function registerEvents() { + // register events + for (const [namespace, namespaceEvents] of Object.entries(allEvents)) { + for (const [key, eventRegister] of Object.entries(namespaceEvents)) { + const subscription = eventRegister((...args: any) => { + const chan = `${namespace}:${key}`; + logger.info('[ipc-event]', chan, args); + getActiveWindows().forEach(win => win.webContents.send(chan, ...args)); + }); + app.on('before-quit', () => { + subscription(); + }); + } + } +} diff --git a/apps/electron/layers/main/src/events/type.ts b/apps/electron/layers/main/src/events/type.ts new file mode 100644 index 0000000000..21d2b8c566 --- /dev/null +++ b/apps/electron/layers/main/src/events/type.ts @@ -0,0 +1 @@ +export type MainEventListener = (...args: any[]) => () => void; diff --git a/apps/electron/layers/main/src/events/updater.ts b/apps/electron/layers/main/src/events/updater.ts new file mode 100644 index 0000000000..87233cdc7e --- /dev/null +++ b/apps/electron/layers/main/src/events/updater.ts @@ -0,0 +1,21 @@ +import { Subject } from 'rxjs'; + +import type { MainEventListener } from './type'; + +interface UpdateMeta { + version: string; +} + +export const updaterSubjects = { + // means it is ready for restart and install the new version + clientUpdateReady: new Subject(), +}; + +export const updaterEvents = { + onClientUpdateReady: (fn: (versionMeta: UpdateMeta) => void) => { + const sub = updaterSubjects.clientUpdateReady.subscribe(fn); + return () => { + sub.unsubscribe(); + }; + }, +} satisfies Record; diff --git a/apps/electron/layers/main/src/exposed.ts b/apps/electron/layers/main/src/exposed.ts new file mode 100644 index 0000000000..199f61b0a1 --- /dev/null +++ b/apps/electron/layers/main/src/exposed.ts @@ -0,0 +1,5 @@ +import { allEvents as events } from './events'; +import { allHandlers as handlers } from './handlers'; + +// this will be used by preload script to expose all handlers and events to the renderer process +export { events, handlers }; diff --git a/apps/electron/layers/main/src/google-auth.ts b/apps/electron/layers/main/src/google-auth.ts deleted file mode 100644 index b26bce420f..0000000000 --- a/apps/electron/layers/main/src/google-auth.ts +++ /dev/null @@ -1,23 +0,0 @@ -const redirectUri = 'https://affine.pro/client/auth-callback'; - -export const oauthEndpoint = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${process.env.AFFINE_GOOGLE_CLIENT_ID}&redirect_uri=${redirectUri}&response_type=code&scope=openid https://www.googleapis.com/auth/userinfo.email profile&access_type=offline&customParameters={"prompt":"select_account"}`; - -const tokenEndpoint = 'https://oauth2.googleapis.com/token'; - -export const getExchangeTokenParams = (code: string) => { - const postData = { - code, - client_id: process.env.AFFINE_GOOGLE_CLIENT_ID || '', - client_secret: process.env.AFFINE_GOOGLE_CLIENT_SECRET || '', - redirect_uri: redirectUri, - grant_type: 'authorization_code', - }; - const requestInit: RequestInit = { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams(postData).toString(), - }; - return { requestInit, url: tokenEndpoint }; -}; diff --git a/apps/electron/layers/main/src/handlers.ts b/apps/electron/layers/main/src/handlers.ts deleted file mode 100644 index 942284698c..0000000000 --- a/apps/electron/layers/main/src/handlers.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { - app, - BrowserWindow, - dialog, - ipcMain, - nativeTheme, - shell, -} from 'electron'; -import { parse } from 'url'; - -import { logger } from '../../logger'; -import { isMacOS } from '../../utils'; -import { appContext } from './context'; -import { exportDatabase } from './data/export'; -import { watchFile } from './data/fs-watch'; -import type { WorkspaceDatabase } from './data/sqlite'; -import { openWorkspaceDatabase } from './data/sqlite'; -import { deleteWorkspace, listWorkspaces } from './data/workspace'; -import { getExchangeTokenParams, oauthEndpoint } from './google-auth'; -import { sendMainEvent } from './send-main-event'; -import { updateClient } from './updater'; - -let currentWorkspaceId = ''; - -const dbMapping = new Map(); -const dbWatchers = new Map void>(); -const dBLastUse = new Map(); - -export async function ensureWorkspaceDB(id: string) { - let workspaceDB = dbMapping.get(id); - if (!workspaceDB) { - // hmm... potential race condition? - workspaceDB = await openWorkspaceDatabase(appContext, id); - dbMapping.set(id, workspaceDB); - - logger.info('watch db file', workspaceDB.path); - - dbWatchers.set( - id, - watchFile(workspaceDB.path, (event, filename) => { - const minTime = 1000; - logger.debug( - 'db file changed', - event, - filename, - Date.now() - dBLastUse.get(id)! - ); - - if (Date.now() - dBLastUse.get(id)! < minTime || !filename) { - logger.debug('skip db update'); - return; - } - - sendMainEvent('main:on-db-update', id); - - // handle DB file update by other process - dbWatchers.get(id)?.(); - dbMapping.delete(id); - dbWatchers.delete(id); - ensureWorkspaceDB(id); - }) - ); - } - dBLastUse.set(id, Date.now()); - return workspaceDB; -} - -export async function cleanupWorkspaceDBs() { - for (const [id, db] of dbMapping) { - logger.info('close db connection', id); - db.destroy(); - dbWatchers.get(id)?.(); - } - dbMapping.clear(); - dbWatchers.clear(); - dBLastUse.clear(); -} - -function registerWorkspaceHandlers() { - ipcMain.handle('workspace:list', async _ => { - logger.info('list workspaces'); - return listWorkspaces(appContext); - }); - - ipcMain.handle('workspace:delete', async (_, id) => { - logger.info('delete workspace', id); - return deleteWorkspace(appContext, id); - }); -} - -function registerUIHandlers() { - ipcMain.handle('ui:theme-change', async (_, theme) => { - nativeTheme.themeSource = theme; - logger.info('theme change', theme); - }); - - ipcMain.handle('ui:sidebar-visibility-change', async (_, visible) => { - // todo - // detect if os is macos - if (isMacOS()) { - const windows = BrowserWindow.getAllWindows(); - windows.forEach(w => { - // hide window buttons when sidebar is not visible - w.setWindowButtonVisibility(visible); - }); - logger.info('sidebar visibility change', visible); - } - }); - - ipcMain.handle('ui:workspace-change', async (_, workspaceId) => { - logger.info('workspace change', workspaceId); - currentWorkspaceId = workspaceId; - }); - - // @deprecated - ipcMain.handle('ui:get-google-oauth-code', async () => { - logger.info('starting google sign in ...'); - shell.openExternal(oauthEndpoint); - - return new Promise((resolve, reject) => { - const handleOpenUrl = async (_: any, url: string) => { - const mainWindow = BrowserWindow.getAllWindows().find( - w => !w.isDestroyed() - ); - const urlObj = parse(url.replace('??', '?'), true); - if (!mainWindow || !url.startsWith('affine://auth-callback')) return; - const code = urlObj.query['code'] as string; - if (!code) return; - - logger.info('google sign in code received from callback', code); - - app.removeListener('open-url', handleOpenUrl); - resolve(getExchangeTokenParams(code)); - }; - - app.on('open-url', handleOpenUrl); - - setTimeout(() => { - reject(new Error('Timed out')); - app.removeListener('open-url', handleOpenUrl); - }, 30000); - }); - }); - - ipcMain.handle('main:env-update', async (_, env, value) => { - process.env[env] = value; - }); - - ipcMain.handle('ui:client-update-install', async () => { - await updateClient(); - }); -} - -function registerDBHandlers() { - app.on('activate', () => { - for (const [_, workspaceDB] of dbMapping) { - workspaceDB.reconnectDB(); - } - }); - - ipcMain.handle('db:get-doc', async (_, id) => { - logger.log('main: get doc', id); - const workspaceDB = await ensureWorkspaceDB(id); - return workspaceDB.getEncodedDocUpdates(); - }); - - ipcMain.handle('db:apply-doc-update', async (_, id, update) => { - logger.log('main: apply doc update', id); - const workspaceDB = await ensureWorkspaceDB(id); - return workspaceDB.applyUpdate(update); - }); - - ipcMain.handle('db:add-blob', async (_, workspaceId, key, data) => { - logger.log('main: add blob', workspaceId, key); - const workspaceDB = await ensureWorkspaceDB(workspaceId); - return workspaceDB.addBlob(key, data); - }); - - ipcMain.handle('db:get-blob', async (_, workspaceId, key) => { - logger.log('main: get blob', workspaceId, key); - const workspaceDB = await ensureWorkspaceDB(workspaceId); - return workspaceDB.getBlob(key); - }); - - ipcMain.handle('db:get-persisted-blobs', async (_, workspaceId) => { - logger.log('main: get persisted blob keys', workspaceId); - const workspaceDB = await ensureWorkspaceDB(workspaceId); - return workspaceDB.getPersistentBlobKeys(); - }); - - ipcMain.handle('db:delete-blob', async (_, workspaceId, key) => { - logger.log('main: delete blob', workspaceId, key); - const workspaceDB = await ensureWorkspaceDB(workspaceId); - return workspaceDB.deleteBlob(key); - }); - - ipcMain.handle('ui:open-db-folder', async _ => { - const workspaceDB = await ensureWorkspaceDB(currentWorkspaceId); - logger.log('main: open db folder', workspaceDB.path); - shell.showItemInFolder(workspaceDB.path); - }); - - ipcMain.handle('ui:open-load-db-file-dialog', async () => { - // todo - }); - - ipcMain.handle('ui:open-save-db-file-dialog', async () => { - logger.log('main: open save db file dialog', currentWorkspaceId); - const workspaceDB = await ensureWorkspaceDB(currentWorkspaceId); - const ret = await dialog.showSaveDialog({ - properties: ['showOverwriteConfirmation'], - title: 'Save Workspace', - buttonLabel: 'Save', - defaultPath: currentWorkspaceId + '.db', - message: 'Save Workspace as SQLite Database', - }); - const filePath = ret.filePath; - if (ret.canceled || !filePath) { - return null; - } - - await exportDatabase(workspaceDB, filePath); - shell.showItemInFolder(filePath); - return filePath; - }); -} - -export const registerHandlers = () => { - registerWorkspaceHandlers(); - registerUIHandlers(); - registerDBHandlers(); -}; diff --git a/apps/electron/layers/main/src/__tests__/.gitignore b/apps/electron/layers/main/src/handlers/__tests__/.gitignore similarity index 100% rename from apps/electron/layers/main/src/__tests__/.gitignore rename to apps/electron/layers/main/src/handlers/__tests__/.gitignore diff --git a/apps/electron/layers/main/src/handlers/__tests__/handlers.spec.ts b/apps/electron/layers/main/src/handlers/__tests__/handlers.spec.ts new file mode 100644 index 0000000000..2c27745244 --- /dev/null +++ b/apps/electron/layers/main/src/handlers/__tests__/handlers.spec.ts @@ -0,0 +1,472 @@ +import assert from 'node:assert'; +import path from 'node:path'; + +import fs from 'fs-extra'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import * as Y from 'yjs'; + +import type { MainIPCHandlerMap } from '../../../../constraints'; + +const registeredHandlers = new Map< + string, + ((...args: any[]) => Promise)[] +>(); + +const delay = (ms: number) => new Promise(r => setTimeout(r, ms)); + +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, + // @ts-ignore + ...args: Parameters> +): // @ts-ignore +ReturnType { + // @ts-ignore + 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 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); + }, +}; + +const nativeTheme = { + themeSource: 'light', +}; + +function compareBuffer(a: Uint8Array | null, b: Uint8Array | null) { + 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) => { + assert(name === 'sessionData'); + return SESSION_DATA_PATH; + }, + name: 'affine-test', + on: (name: string, callback: (...args: any[]) => any) => { + const handlers = registeredHandlers.get(name) || []; + handlers.push(callback); + registeredHandlers.set(name, handlers); + }, + }, + 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('../register'); + registerHandlers(); + + // should also register events + const { registerEvents } = await import('../../events'); + registerEvents(); +}); + +afterEach(async () => { + const { cleanupSQLiteDBs } = await import('../db/ensure-db'); + await cleanupSQLiteDBs(); + await fs.remove(SESSION_DATA_PATH); + + // reset registered handlers + registeredHandlers.get('before-quit')?.forEach(fn => fn()); +}); + +describe('ensureSQLiteDB', () => { + test('should create db file on connection if it does not exist', async () => { + const id = 'test-workspace-id'; + 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('when db file is removed', async () => { + // stub webContents.send + const sendStub = vi.fn(); + browserWindow.webContents.send = sendStub; + const id = 'test-workspace-id'; + const { ensureSQLiteDB } = await import('../db/ensure-db'); + let workspaceDB = await ensureSQLiteDB(id); + const file = workspaceDB.path; + const fileExists = await fs.pathExists(file); + expect(fileExists).toBe(true); + + await fs.remove(file); + + // wait for 1000ms for file watcher to detect file removal + await delay(2000); + + expect(sendStub).toBeCalledWith('db:onDbFileMissing', id); + + // ensureSQLiteDB should recreate the db file + workspaceDB = await ensureSQLiteDB(id); + const fileExists2 = await fs.pathExists(file); + expect(fileExists2).toBe(true); + }); + + test('when db file is updated', async () => { + // stub webContents.send + const sendStub = vi.fn(); + browserWindow.webContents.send = sendStub; + const id = 'test-workspace-id'; + 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); + + // wait to make sure + await delay(500); + + // writes some data to the db file + await fs.appendFile(file, 'random-data', { encoding: 'binary' }); + // write again + await fs.appendFile(file, 'random-data', { encoding: 'binary' }); + + // wait for 200ms for file watcher to detect file change + await delay(2000); + + expect(sendStub).toBeCalledWith('db:onDbFileUpdate', id); + + // should only call once for multiple writes + expect(sendStub).toBeCalledTimes(1); + }); +}); + +describe('workspace handlers', () => { + test('list all workspace ids', async () => { + const ids = ['test-workspace-id', 'test-workspace-id-2']; + const { ensureSQLiteDB } = await import('../db/ensure-db'); + await Promise.all(ids.map(id => ensureSQLiteDB(id))); + const list = await dispatch('workspace', 'list'); + expect(list.map(([id]) => id)).toEqual(ids); + }); + + test('delete workspace', async () => { + const ids = ['test-workspace-id', 'test-workspace-id-2']; + const { ensureSQLiteDB } = await import('../db/ensure-db'); + await Promise.all(ids.map(id => ensureSQLiteDB(id))); + await dispatch('workspace', 'delete', 'test-workspace-id-2'); + const list = await dispatch('workspace', 'list'); + expect(list.map(([id]) => id)).toEqual(['test-workspace-id']); + }); +}); + +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 = 'test-workspace-id'; + 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 = 'test-workspace-id'; + const bin = await dispatch('db', 'getBlob', workspaceId, 'non-existent-id'); + expect(bin).toBeNull(); + }); + + test('list blobs (empty)', async () => { + const workspaceId = 'test-workspace-id'; + const list = await dispatch('db', 'getPersistedBlobs', 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', 'getPersistedBlobs', 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', 'getPersistedBlobs', workspaceId); + expect(lists).toEqual(['testBin2']); + }); +}); + +describe('dialog handlers', () => { + test('revealDBFile', async () => { + const mockShowItemInFolder = vi.fn(); + electronModule.shell.showItemInFolder = mockShowItemInFolder; + + const id = 'test-workspace-id'; + 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 = 'test-workspace-id'; + const { ensureSQLiteDB } = await import('../db/ensure-db'); + await ensureSQLiteDB(id); + + await dispatch('dialog', 'saveDBFileAs', id); + expect(mockShowSaveDialog).toBeCalled(); + expect(mockShowItemInFolder).not.toBeCalled(); + }); + + 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 = 'test-workspace-id'; + 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 db file)', async () => { + // create a random db file + const basePath = path.join(SESSION_DATA_PATH, 'random-path'); + const dbPath = path.join(basePath, 'xxx.db'); + 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'); + }); + + test('loadDBFile', async () => { + // we use ensureSQLiteDB to create a valid db file + const id = 'test-workspace-id'; + 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 originDBFilePath = path.join(basePath, 'xxx.db'); + await fs.ensureDir(basePath); + await fs.copyFile(db.path, originDBFilePath); + + // remove db + await fs.remove(db.path); + + // try load originDBFilePath + const mockShowOpenDialog = vi.fn(() => { + return { filePaths: [originDBFilePath] }; + }) as any; + electronModule.dialog.showOpenDialog = mockShowOpenDialog; + + const res = await dispatch('dialog', 'loadDBFile'); + expect(mockShowOpenDialog).toBeCalled(); + expect(res.workspaceId).not.toBeUndefined(); + + const importedDb = await ensureSQLiteDB(res.workspaceId!); + expect(await fs.realpath(importedDb.path)).toBe(originDBFilePath); + expect(importedDb.path).not.toBe(originDBFilePath); + + // 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', async () => { + const newPath = path.join(SESSION_DATA_PATH, 'affine-test', 'xxx'); + const mockShowSaveDialog = vi.fn(() => { + return { filePath: newPath }; + }) as any; + electronModule.dialog.showSaveDialog = mockShowSaveDialog; + + const id = 'test-workspace-id'; + const { ensureSQLiteDB } = await import('../db/ensure-db'); + await ensureSQLiteDB(id); + + const res = await dispatch('dialog', 'moveDBFile', id); + expect(mockShowSaveDialog).toBeCalled(); + expect(res.filePath).toBe(newPath); + }); + + test('moveDBFile (skipped)', async () => { + const mockShowSaveDialog = vi.fn(() => { + return { filePath: null }; + }) as any; + electronModule.dialog.showSaveDialog = mockShowSaveDialog; + + const id = 'test-workspace-id'; + const { ensureSQLiteDB } = await import('../db/ensure-db'); + await ensureSQLiteDB(id); + + const res = await dispatch('dialog', 'moveDBFile', id); + expect(mockShowSaveDialog).toBeCalled(); + expect(res.filePath).toBe(undefined); + }); +}); diff --git a/apps/electron/layers/main/src/handlers/db/ensure-db.ts b/apps/electron/layers/main/src/handlers/db/ensure-db.ts new file mode 100644 index 0000000000..40747dc4d5 --- /dev/null +++ b/apps/electron/layers/main/src/handlers/db/ensure-db.ts @@ -0,0 +1,89 @@ +import { watch } from 'chokidar'; + +import { appContext } from '../../context'; +import { subjects } from '../../events'; +import { logger } from '../../logger'; +import { debounce, ts } from '../../utils'; +import type { WorkspaceSQLiteDB } from './sqlite'; +import { openWorkspaceDatabase } from './sqlite'; + +const dbMapping = new Map>(); +const dbWatchers = new Map void>(); + +// if we removed the file, we will stop watching it +function startWatchingDBFile(db: WorkspaceSQLiteDB) { + if (dbWatchers.has(db.workspaceId)) { + return dbWatchers.get(db.workspaceId); + } + logger.info('watch db file', db.path); + const watcher = watch(db.path); + + const debounceOnChange = debounce(() => { + logger.info( + 'db file changed on disk', + db.workspaceId, + ts() - db.lastUpdateTime, + 'ms' + ); + // reconnect db + db.reconnectDB(); + subjects.db.dbFileUpdate.next(db.workspaceId); + }, 1000); + + watcher.on('change', () => { + const currentTime = ts(); + if (currentTime - db.lastUpdateTime > 100) { + debounceOnChange(); + } + }); + + dbWatchers.set(db.workspaceId, () => { + watcher.close(); + }); + + // todo: there is still a possibility that the file is deleted + // but we didn't get the event soon enough and another event tries to + // access the db + watcher.on('unlink', () => { + logger.info('db file missing', db.workspaceId); + subjects.db.dbFileMissing.next(db.workspaceId); + // cleanup + watcher.close().then(() => { + db.destroy(); + dbWatchers.delete(db.workspaceId); + dbMapping.delete(db.workspaceId); + }); + }); +} + +export async function ensureSQLiteDB(id: string) { + let workspaceDB = dbMapping.get(id); + if (!workspaceDB) { + logger.info('[ensureSQLiteDB] open db connection', id); + workspaceDB = openWorkspaceDatabase(appContext, id); + dbMapping.set(id, workspaceDB); + startWatchingDBFile(await workspaceDB); + } + return await workspaceDB; +} + +export async function disconnectSQLiteDB(id: string) { + const dbp = dbMapping.get(id); + if (dbp) { + const db = await dbp; + logger.info('close db connection', id); + db.destroy(); + dbWatchers.get(id)?.(); + dbWatchers.delete(id); + dbMapping.delete(id); + } +} + +export async function cleanupSQLiteDBs() { + for (const [id] of dbMapping) { + logger.info('close db connection', id); + await disconnectSQLiteDB(id); + } + dbMapping.clear(); + dbWatchers.clear(); +} diff --git a/apps/electron/layers/main/src/handlers/db/index.ts b/apps/electron/layers/main/src/handlers/db/index.ts new file mode 100644 index 0000000000..79f67611d3 --- /dev/null +++ b/apps/electron/layers/main/src/handlers/db/index.ts @@ -0,0 +1,33 @@ +import { appContext } from '../../context'; +import type { NamespaceHandlers } from '../type'; +import { ensureSQLiteDB } from './ensure-db'; + +export const dbHandlers = { + getDocAsUpdates: async (_, id: string) => { + const workspaceDB = await ensureSQLiteDB(id); + return workspaceDB.getDocAsUpdates(); + }, + applyDocUpdate: async (_, id: string, update: Uint8Array) => { + const workspaceDB = await ensureSQLiteDB(id); + return workspaceDB.applyUpdate(update); + }, + addBlob: async (_, workspaceId: string, key: string, data: Uint8Array) => { + const workspaceDB = await ensureSQLiteDB(workspaceId); + return workspaceDB.addBlob(key, data); + }, + getBlob: async (_, workspaceId: string, key: string) => { + const workspaceDB = await ensureSQLiteDB(workspaceId); + return workspaceDB.getBlob(key); + }, + deleteBlob: async (_, workspaceId: string, key: string) => { + const workspaceDB = await ensureSQLiteDB(workspaceId); + return workspaceDB.deleteBlob(key); + }, + getPersistedBlobs: async (_, workspaceId: string) => { + const workspaceDB = await ensureSQLiteDB(workspaceId); + return workspaceDB.getPersistentBlobKeys(); + }, + getDefaultStorageLocation: async () => { + return appContext.appDataPath; + }, +} satisfies NamespaceHandlers; diff --git a/apps/electron/layers/main/src/data/sqlite.ts b/apps/electron/layers/main/src/handlers/db/sqlite.ts similarity index 52% rename from apps/electron/layers/main/src/data/sqlite.ts rename to apps/electron/layers/main/src/handlers/db/sqlite.ts index 674d6246eb..d58480a544 100644 --- a/apps/electron/layers/main/src/data/sqlite.ts +++ b/apps/electron/layers/main/src/handlers/db/sqlite.ts @@ -5,8 +5,9 @@ import sqlite from 'better-sqlite3'; import fs from 'fs-extra'; import * as Y from 'yjs'; -import { logger } from '../../../logger'; -import type { AppContext } from '../context'; +import type { AppContext } from '../../context'; +import { logger } from '../../logger'; +import { ts } from '../../utils'; const schemas = [ `CREATE TABLE IF NOT EXISTS "updates" ( @@ -33,46 +34,68 @@ interface BlobRow { timestamp: string; } -export class WorkspaceDatabase { - sqliteDB: Database; +const SQLITE_ORIGIN = Symbol('sqlite-origin'); + +export class WorkspaceSQLiteDB { + db: Database; ydoc = new Y.Doc(); firstConnect = false; + lastUpdateTime = ts(); - constructor(public path: string) { - this.sqliteDB = this.reconnectDB(); + constructor(public path: string, public workspaceId: string) { + this.db = this.reconnectDB(); } // release resources destroy = () => { - this.sqliteDB?.close(); + this.db?.close(); this.ydoc.destroy(); }; + getWorkspaceName = () => { + return this.ydoc.getMap('space:meta').get('name') as string; + }; + reconnectDB = () => { - logger.log('open db', this.path); - if (this.sqliteDB) { - this.sqliteDB.close(); + logger.log('open db', this.workspaceId); + if (this.db) { + this.db.close(); } // use cached version? - const db = (this.sqliteDB = sqlite(this.path)); + const db = (this.db = sqlite(this.path)); db.exec(schemas.join(';')); if (!this.firstConnect) { - this.ydoc.on('update', this.addUpdateToSQLite); + this.ydoc.on('update', (update: Uint8Array, origin) => { + if (origin !== SQLITE_ORIGIN) { + this.addUpdateToSQLite(update); + } + }); } - const updates = this.getUpdates(); - updates.forEach(update => { - Y.applyUpdate(this.ydoc, update.data); + Y.transact(this.ydoc, () => { + const updates = this.getUpdates(); + updates.forEach(update => { + // give SQLITE_ORIGIN to skip self update + Y.applyUpdate(this.ydoc, update.data, SQLITE_ORIGIN); + }); }); + this.lastUpdateTime = ts(); + + if (this.firstConnect) { + logger.info('db reconnected', this.workspaceId); + } else { + logger.info('db connected', this.workspaceId); + } + this.firstConnect = true; return db; }; - getEncodedDocUpdates = () => { + getDocAsUpdates = () => { return Y.encodeStateAsUpdate(this.ydoc); }; @@ -80,18 +103,23 @@ export class WorkspaceDatabase { // after that, the update is added to the db applyUpdate = (data: Uint8Array) => { Y.applyUpdate(this.ydoc, data); + // todo: trim the updates when the number of records is too large // 1. store the current ydoc state in the db // 2. then delete the old updates // yjs-idb will always trim the db for the first time after DB is loaded + this.lastUpdateTime = ts(); + logger.debug('applyUpdate', this.workspaceId, this.lastUpdateTime); }; addBlob = (key: string, data: Uint8Array) => { + this.lastUpdateTime = ts(); try { - const statement = this.sqliteDB.prepare( + const statement = this.db.prepare( 'INSERT INTO blobs (key, data) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET data = ?' ); statement.run(key, data, data); + return key; } catch (error) { logger.error('addBlob', error); } @@ -99,9 +127,7 @@ export class WorkspaceDatabase { getBlob = (key: string) => { try { - const statement = this.sqliteDB.prepare( - 'SELECT data FROM blobs WHERE key = ?' - ); + const statement = this.db.prepare('SELECT data FROM blobs WHERE key = ?'); const row = statement.get(key) as BlobRow; if (!row) { return null; @@ -114,10 +140,9 @@ export class WorkspaceDatabase { }; deleteBlob = (key: string) => { + this.lastUpdateTime = ts(); try { - const statement = this.sqliteDB.prepare( - 'DELETE FROM blobs WHERE key = ?' - ); + const statement = this.db.prepare('DELETE FROM blobs WHERE key = ?'); statement.run(key); } catch (error) { logger.error('deleteBlob', error); @@ -126,7 +151,7 @@ export class WorkspaceDatabase { getPersistentBlobKeys = () => { try { - const statement = this.sqliteDB.prepare('SELECT key FROM blobs'); + const statement = this.db.prepare('SELECT key FROM blobs'); const rows = statement.all() as BlobRow[]; return rows.map(row => row.key); } catch (error) { @@ -137,7 +162,7 @@ export class WorkspaceDatabase { private getUpdates = () => { try { - const statement = this.sqliteDB.prepare('SELECT * FROM updates'); + const statement = this.db.prepare('SELECT * FROM updates'); const rows = statement.all() as UpdateRow[]; return rows; } catch (error) { @@ -150,25 +175,57 @@ export class WorkspaceDatabase { private addUpdateToSQLite = (data: Uint8Array) => { try { const start = performance.now(); - const statement = this.sqliteDB.prepare( + const statement = this.db.prepare( 'INSERT INTO updates (data) VALUES (?)' ); statement.run(data); - logger.debug('addUpdateToSQLite', performance.now() - start, 'ms'); + logger.debug( + 'addUpdateToSQLite', + this.workspaceId, + 'length:', + data.length, + performance.now() - start, + 'ms' + ); } catch (error) { logger.error('addUpdateToSQLite', error); } }; } -export async function openWorkspaceDatabase( +export async function getWorkspaceDBPath( context: AppContext, workspaceId: string ) { const basePath = path.join(context.appDataPath, 'workspaces', workspaceId); - // hmmm.... blocking api but it should be fine, right? await fs.ensureDir(basePath); - const dbPath = path.join(basePath, 'storage.db'); - - return new WorkspaceDatabase(dbPath); + return path.join(basePath, 'storage.db'); +} + +export async function openWorkspaceDatabase( + context: AppContext, + workspaceId: string +) { + const dbPath = await getWorkspaceDBPath(context, workspaceId); + return new WorkspaceSQLiteDB(dbPath, workspaceId); +} + +export function isValidDBFile(path: string) { + try { + const db = sqlite(path); + // check if db has two tables, one for updates and onefor blobs + const statement = db.prepare( + `SELECT name FROM sqlite_schema WHERE type='table'` + ); + const rows = statement.all() as { name: string }[]; + const tableNames = rows.map(row => row.name); + if (!tableNames.includes('updates') || !tableNames.includes('blobs')) { + return false; + } + db.close(); + return true; + } catch (error) { + logger.error('isValidDBFile', error); + return false; + } } diff --git a/apps/electron/layers/main/src/handlers/dialog/dialog.ts b/apps/electron/layers/main/src/handlers/dialog/dialog.ts new file mode 100644 index 0000000000..be48772d7e --- /dev/null +++ b/apps/electron/layers/main/src/handlers/dialog/dialog.ts @@ -0,0 +1,293 @@ +import path from 'node:path'; + +import { dialog, shell } from 'electron'; +import fs from 'fs-extra'; +import { nanoid } from 'nanoid'; + +import { appContext } from '../../context'; +import { logger } from '../../logger'; +import { ensureSQLiteDB } from '../db/ensure-db'; +import { getWorkspaceDBPath, isValidDBFile } from '../db/sqlite'; +import { listWorkspaces } from '../workspace/workspace'; + +// NOTE: +// we are using native dialogs because HTML dialogs do not give full file paths + +export async function revealDBFile(workspaceId: string) { + const workspaceDB = await ensureSQLiteDB(workspaceId); + shell.showItemInFolder(workspaceDB.path); +} + +// provide a backdoor to set dialog path for testing in playwright +interface FakeDialogResult { + canceled?: boolean; + filePath?: string; + filePaths?: string[]; +} + +// result will be used in the next call to showOpenDialog +// if it is being read once, it will be reset to undefined +let fakeDialogResult: FakeDialogResult | undefined = undefined; + +function getFakedResult() { + const result = fakeDialogResult; + fakeDialogResult = undefined; + return result; +} + +export function setFakeDialogResult(result: FakeDialogResult | undefined) { + fakeDialogResult = result; + // for convenience, we will fill filePaths with filePath if it is not set + if (result?.filePaths === undefined && result?.filePath !== undefined) { + result.filePaths = [result.filePath]; + } +} + +const ErrorMessages = [ + 'DB_FILE_ALREADY_LOADED', + 'DB_FILE_PATH_INVALID', + 'DB_FILE_INVALID', + 'UNKNOWN_ERROR', +] as const; + +type ErrorMessage = (typeof ErrorMessages)[number]; + +interface SaveDBFileResult { + filePath?: string; + canceled?: boolean; + error?: ErrorMessage; +} + +/** + * This function is called when the user clicks the "Save" button in the "Save Workspace" dialog. + * + * It will just copy the file to the given path + */ +export async function saveDBFileAs( + workspaceId: string +): Promise { + try { + const db = await ensureSQLiteDB(workspaceId); + const ret = + getFakedResult() ?? + (await dialog.showSaveDialog({ + properties: ['showOverwriteConfirmation'], + title: 'Save Workspace', + showsTagField: false, + buttonLabel: 'Save', + defaultPath: `${db.getWorkspaceName()}_${workspaceId}.db`, + message: 'Save Workspace as a SQLite Database file', + })); + const filePath = ret.filePath; + if (ret.canceled || !filePath) { + return { + canceled: true, + }; + } + + await fs.copyFile(db.path, filePath); + logger.log('saved', filePath); + shell.showItemInFolder(filePath); + return { filePath }; + } catch (err) { + logger.error('saveDBFileAs', err); + return { + error: 'UNKNOWN_ERROR', + }; + } +} + +interface SelectDBFileLocationResult { + filePath?: string; + error?: ErrorMessage; + canceled?: boolean; +} + +export async function selectDBFileLocation(): Promise { + try { + const ret = + getFakedResult() ?? + (await dialog.showSaveDialog({ + properties: ['showOverwriteConfirmation'], + title: 'Set database location', + showsTagField: false, + buttonLabel: 'Select', + defaultPath: `workspace-storage.db`, + message: "Select a location to store the workspace's database file", + })); + const filePath = ret.filePath; + if (ret.canceled || !filePath) { + return { + canceled: true, + }; + } + // the same db file cannot be loaded twice + if (await dbFileAlreadyLoaded(filePath)) { + return { + error: 'DB_FILE_ALREADY_LOADED', + }; + } + return { filePath }; + } catch (err) { + logger.error('selectDBFileLocation', err); + return { + error: (err as any).message, + }; + } +} + +interface LoadDBFileResult { + workspaceId?: string; + error?: ErrorMessage; + canceled?: boolean; +} + +/** + * This function is called when the user clicks the "Load" button in the "Load Workspace" dialog. + * + * It will + * - symlink the source db file to a new workspace id to app-data + * - return the new workspace id + * + * eg, it will create a new folder in app-data: + * //workspaces//storage.db + * + * On the renderer side, after the UI got a new workspace id, it will + * update the local workspace id list and then connect to it. + * + */ +export async function loadDBFile(): Promise { + try { + const ret = + getFakedResult() ?? + (await dialog.showOpenDialog({ + properties: ['openFile'], + title: 'Load Workspace', + buttonLabel: 'Load', + filters: [ + { + name: 'SQLite Database', + // do we want to support other file format? + extensions: ['db'], + }, + ], + message: 'Load Workspace from a SQLite Database file', + })); + const filePath = ret.filePaths?.[0]; + if (ret.canceled || !filePath) { + logger.info('loadDBFile canceled'); + return { canceled: true }; + } + + // the imported file should not be in app data dir + if (filePath.startsWith(path.join(appContext.appDataPath, 'workspaces'))) { + logger.warn('loadDBFile: db file in app data dir'); + return { error: 'DB_FILE_PATH_INVALID' }; + } + + if (await dbFileAlreadyLoaded(filePath)) { + logger.warn('loadDBFile: db file already loaded'); + return { error: 'DB_FILE_ALREADY_LOADED' }; + } + + if (!isValidDBFile(filePath)) { + // TODO: report invalid db file error? + return { error: 'DB_FILE_INVALID' }; // invalid db file + } + + // symlink the db file to a new workspace id + const workspaceId = nanoid(10); + const linkedFilePath = await getWorkspaceDBPath(appContext, workspaceId); + + await fs.ensureDir(path.join(appContext.appDataPath, 'workspaces')); + + await fs.symlink(filePath, linkedFilePath); + logger.info(`loadDBFile, symlink: ${filePath} -> ${linkedFilePath}`); + + return { workspaceId }; + } catch (err) { + logger.error('loadDBFile', err); + return { + error: 'UNKNOWN_ERROR', + }; + } +} + +interface MoveDBFileResult { + filePath?: string; + error?: ErrorMessage; + canceled?: boolean; +} + +/** + * This function is called when the user clicks the "Move" button in the "Move Workspace Storage" setting. + * + * It will + * - move the source db file to a new location + * - symlink the new location to the old db file + * - return the new file path + */ +export async function moveDBFile( + workspaceId: string, + dbFileLocation?: string +): Promise { + try { + const db = await ensureSQLiteDB(workspaceId); + + // get the real file path of db + const realpath = await fs.realpath(db.path); + const isLink = realpath !== db.path; + + const newFilePath = + dbFileLocation || + ( + getFakedResult() || + (await dialog.showSaveDialog({ + properties: ['showOverwriteConfirmation'], + title: 'Move Workspace Storage', + showsTagField: false, + buttonLabel: 'Save', + defaultPath: realpath, + message: 'Move Workspace storage file', + })) + ).filePath; + + // skips if + // - user canceled the dialog + // - user selected the same file + // - user selected the same file in the link file in app data dir + if (!newFilePath || newFilePath === realpath || db.path === newFilePath) { + return { + canceled: true, + }; + } + + if (isLink) { + // remove the old link to unblock new link + await fs.unlink(db.path); + } + + await fs.move(realpath, newFilePath, { + overwrite: true, + }); + + await fs.ensureSymlink(newFilePath, db.path); + logger.info(`openMoveDBFileDialog symlink: ${realpath} -> ${newFilePath}`); + db.reconnectDB(); + return { + filePath: newFilePath, + }; + } catch (err) { + logger.error('moveDBFile', err); + return { + error: 'UNKNOWN_ERROR', + }; + } +} + +async function dbFileAlreadyLoaded(path: string) { + const meta = await listWorkspaces(appContext); + const realpath = await fs.realpath(path); + const paths = meta.map(m => m[1].realpath); + return paths.includes(realpath); +} diff --git a/apps/electron/layers/main/src/handlers/dialog/index.ts b/apps/electron/layers/main/src/handlers/dialog/index.ts new file mode 100644 index 0000000000..018ce07d6b --- /dev/null +++ b/apps/electron/layers/main/src/handlers/dialog/index.ts @@ -0,0 +1,33 @@ +import type { NamespaceHandlers } from '../type'; +import { + loadDBFile, + moveDBFile, + revealDBFile, + saveDBFileAs, + selectDBFileLocation, + setFakeDialogResult, +} from './dialog'; + +export const dialogHandlers = { + revealDBFile: async (_, workspaceId: string) => { + return revealDBFile(workspaceId); + }, + loadDBFile: async () => { + return loadDBFile(); + }, + saveDBFileAs: async (_, workspaceId: string) => { + return saveDBFileAs(workspaceId); + }, + moveDBFile: async (_, 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/layers/main/src/handlers/index.ts b/apps/electron/layers/main/src/handlers/index.ts new file mode 100644 index 0000000000..412f070006 --- /dev/null +++ b/apps/electron/layers/main/src/handlers/index.ts @@ -0,0 +1 @@ +export * from './register'; diff --git a/apps/electron/layers/main/src/handlers/register.ts b/apps/electron/layers/main/src/handlers/register.ts new file mode 100644 index 0000000000..744fb06465 --- /dev/null +++ b/apps/electron/layers/main/src/handlers/register.ts @@ -0,0 +1,63 @@ +import { ipcMain } from 'electron'; + +import { getLogFilePath, logger, revealLogFile } from '../logger'; +import { dbHandlers } from './db'; +import { dialogHandlers } from './dialog'; +import { uiHandlers } from './ui'; +import { updaterHandlers } from './updater'; +import { workspaceHandlers } from './workspace'; + +type IsomorphicHandler = ( + e: Electron.IpcMainInvokeEvent, + ...args: any[] +) => Promise; + +type NamespaceHandlers = { + [key: string]: IsomorphicHandler; +}; + +export const debugHandlers = { + revealLogFile: async () => { + return revealLogFile(); + }, + logFilePath: async () => { + return getLogFilePath(); + }, +}; + +// Note: all of these handlers will be the single-source-of-truth for the apis exposed to the renderer process +export const allHandlers = { + workspace: workspaceHandlers, + ui: uiHandlers, + db: dbHandlers, + dialog: dialogHandlers, + debug: debugHandlers, + updater: updaterHandlers, +} satisfies Record; + +export const registerHandlers = () => { + for (const [namespace, namespaceHandlers] of Object.entries(allHandlers)) { + for (const [key, handler] of Object.entries(namespaceHandlers)) { + const chan = `${namespace}:${key}`; + ipcMain.handle(chan, async (e, ...args) => { + const start = performance.now(); + try { + const result = await handler(e, ...args); + logger.info( + '[ipc-api]', + chan, + args.filter( + arg => typeof arg !== 'function' && typeof arg !== 'object' + ), + '-', + (performance.now() - start).toFixed(2), + 'ms' + ); + return result; + } catch (error) { + logger.error('[ipc]', chan, error); + } + }); + } + } +}; diff --git a/apps/electron/layers/main/src/handlers/type.ts b/apps/electron/layers/main/src/handlers/type.ts new file mode 100644 index 0000000000..61f439c50c --- /dev/null +++ b/apps/electron/layers/main/src/handlers/type.ts @@ -0,0 +1,8 @@ +export type IsomorphicHandler = ( + e: Electron.IpcMainInvokeEvent, + ...args: any[] +) => Promise; + +export type NamespaceHandlers = { + [key: string]: IsomorphicHandler; +}; diff --git a/apps/electron/layers/main/src/handlers/ui/google-auth.ts b/apps/electron/layers/main/src/handlers/ui/google-auth.ts new file mode 100644 index 0000000000..93da1fb85a --- /dev/null +++ b/apps/electron/layers/main/src/handlers/ui/google-auth.ts @@ -0,0 +1,58 @@ +import { app, BrowserWindow, shell } from 'electron'; +import { parse } from 'url'; + +import { logger } from '../../logger'; + +const redirectUri = 'https://affine.pro/client/auth-callback'; + +export const oauthEndpoint = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${process.env.AFFINE_GOOGLE_CLIENT_ID}&redirect_uri=${redirectUri}&response_type=code&scope=openid https://www.googleapis.com/auth/userinfo.email profile&access_type=offline&customParameters={"prompt":"select_account"}`; + +const tokenEndpoint = 'https://oauth2.googleapis.com/token'; + +export const getExchangeTokenParams = (code: string) => { + const postData = { + code, + client_id: process.env.AFFINE_GOOGLE_CLIENT_ID || '', + client_secret: process.env.AFFINE_GOOGLE_CLIENT_SECRET || '', + redirect_uri: redirectUri, + grant_type: 'authorization_code', + }; + const requestInit: RequestInit = { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(postData).toString(), + }; + return { requestInit, url: tokenEndpoint }; +}; + +export function getGoogleOauthCode() { + shell.openExternal(oauthEndpoint); + + return new Promise>( + (resolve, reject) => { + const handleOpenUrl = async (_: any, url: string) => { + const mainWindow = BrowserWindow.getAllWindows().find( + w => !w.isDestroyed() + ); + const urlObj = parse(url.replace('??', '?'), true); + if (!mainWindow || !url.startsWith('affine://auth-callback')) return; + const code = urlObj.query['code'] as string; + if (!code) return; + + logger.info('google sign in code received from callback', code); + + app.removeListener('open-url', handleOpenUrl); + resolve(getExchangeTokenParams(code)); + }; + + app.on('open-url', handleOpenUrl); + + setTimeout(() => { + reject(new Error('Timed out')); + app.removeListener('open-url', handleOpenUrl); + }, 30000); + } + ); +} diff --git a/apps/electron/layers/main/src/handlers/ui/index.ts b/apps/electron/layers/main/src/handlers/ui/index.ts new file mode 100644 index 0000000000..7ff5c3bc5a --- /dev/null +++ b/apps/electron/layers/main/src/handlers/ui/index.ts @@ -0,0 +1,23 @@ +import { BrowserWindow, nativeTheme } from 'electron'; + +import { isMacOS } from '../../../../utils'; +import type { NamespaceHandlers } from '../type'; +import { getGoogleOauthCode } from './google-auth'; + +export const uiHandlers = { + handleThemeChange: async (_, theme: (typeof nativeTheme)['themeSource']) => { + nativeTheme.themeSource = theme; + }, + handleSidebarVisibilityChange: async (_, visible: boolean) => { + if (isMacOS()) { + const windows = BrowserWindow.getAllWindows(); + windows.forEach(w => { + // hide window buttons when sidebar is not visible + w.setWindowButtonVisibility(visible); + }); + } + }, + getGoogleOauthCode: async () => { + return getGoogleOauthCode(); + }, +} satisfies NamespaceHandlers; diff --git a/apps/electron/layers/main/src/handlers/updater/index.ts b/apps/electron/layers/main/src/handlers/updater/index.ts new file mode 100644 index 0000000000..8852085b69 --- /dev/null +++ b/apps/electron/layers/main/src/handlers/updater/index.ts @@ -0,0 +1,10 @@ +import type { NamespaceHandlers } from '../type'; +import { updateClient } from './updater'; + +export const updaterHandlers = { + updateClient: async () => { + return updateClient(); + }, +} satisfies NamespaceHandlers; + +export * from './updater'; diff --git a/apps/electron/layers/main/src/handlers/updater/updater.ts b/apps/electron/layers/main/src/handlers/updater/updater.ts new file mode 100644 index 0000000000..fec28b5b7b --- /dev/null +++ b/apps/electron/layers/main/src/handlers/updater/updater.ts @@ -0,0 +1,69 @@ +import type { AppUpdater } from 'electron-updater'; + +import { isMacOS } from '../../../../utils'; +import { updaterSubjects } from '../../events/updater'; +import { logger } from '../../logger'; + +const buildType = (process.env.BUILD_TYPE || 'canary').trim().toLowerCase(); +const mode = process.env.NODE_ENV; +const isDev = mode === 'development'; + +let _autoUpdater: AppUpdater | null = null; + +export const updateClient = async () => { + _autoUpdater?.quitAndInstall(); +}; + +export const registerUpdater = async () => { + // require it will cause some side effects and will break generate-main-exposed-meta, + // so we wrap it in a function + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { autoUpdater } = await import('electron-updater'); + + _autoUpdater = autoUpdater; + + autoUpdater.autoDownload = false; + autoUpdater.allowPrerelease = buildType !== 'stable'; + autoUpdater.autoInstallOnAppQuit = false; + autoUpdater.autoRunAppAfterInstall = true; + autoUpdater.setFeedURL({ + channel: buildType, + provider: 'github', + repo: 'AFFiNE', + owner: 'toeverything', + releaseType: buildType === 'stable' ? 'release' : 'prerelease', + }); + + autoUpdater.autoDownload = false; + autoUpdater.allowPrerelease = buildType !== 'stable'; + autoUpdater.autoInstallOnAppQuit = false; + autoUpdater.autoRunAppAfterInstall = true; + autoUpdater.setFeedURL({ + channel: buildType, + provider: 'github', + repo: 'AFFiNE', + owner: 'toeverything', + releaseType: buildType === 'stable' ? 'release' : 'prerelease', + }); + + if (isMacOS()) { + autoUpdater.on('update-available', () => { + autoUpdater.downloadUpdate(); + logger.info('Update available, downloading...'); + }); + autoUpdater.on('download-progress', e => { + logger.info(`Download progress: ${e.percent}`); + }); + autoUpdater.on('update-downloaded', e => { + updaterSubjects.clientUpdateReady.next({ + version: e.version, + }); + logger.info('Update downloaded, ready to install'); + }); + autoUpdater.on('error', e => { + logger.error('Error while updating client', e); + }); + autoUpdater.forceDevUpdateConfig = isDev; + await autoUpdater.checkForUpdatesAndNotify(); + } +}; diff --git a/apps/electron/layers/main/src/handlers/workspace/index.ts b/apps/electron/layers/main/src/handlers/workspace/index.ts new file mode 100644 index 0000000000..355c7a4cec --- /dev/null +++ b/apps/electron/layers/main/src/handlers/workspace/index.ts @@ -0,0 +1,8 @@ +import { appContext } from '../../context'; +import type { NamespaceHandlers } from '../type'; +import { deleteWorkspace, listWorkspaces } from './workspace'; + +export const workspaceHandlers = { + list: async () => listWorkspaces(appContext), + delete: async (_, id: string) => deleteWorkspace(appContext, id), +} satisfies NamespaceHandlers; diff --git a/apps/electron/layers/main/src/handlers/workspace/workspace.ts b/apps/electron/layers/main/src/handlers/workspace/workspace.ts new file mode 100644 index 0000000000..4a5c7d9ee1 --- /dev/null +++ b/apps/electron/layers/main/src/handlers/workspace/workspace.ts @@ -0,0 +1,60 @@ +import path from 'node:path'; + +import fs from 'fs-extra'; + +import type { AppContext } from '../../context'; +import { logger } from '../../logger'; + +interface WorkspaceMeta { + path: string; + realpath: string; +} + +export async function listWorkspaces( + context: AppContext +): Promise<[workspaceId: string, meta: WorkspaceMeta][]> { + const basePath = path.join(context.appDataPath, 'workspaces'); + try { + await fs.ensureDir(basePath); + const dirs = await fs.readdir(basePath, { + withFileTypes: true, + }); + + const meta = await Promise.all( + dirs.map(async dir => { + const dbFilePath = path.join(basePath, dir.name, 'storage.db'); + if (dir.isDirectory() && (await fs.exists(dbFilePath))) { + // try read storage.db under it + const realpath = await fs.realpath(dbFilePath); + return [dir.name, { path: dbFilePath, realpath }] as [ + string, + WorkspaceMeta + ]; + } else { + return null; + } + }) + ); + + return meta.filter((w): w is [string, WorkspaceMeta] => !!w); + } catch (error) { + logger.error('listWorkspaces', error); + return []; + } +} + +export async function deleteWorkspace(context: AppContext, id: string) { + const basePath = path.join(context.appDataPath, 'workspaces', id); + const movedPath = path.join( + context.appDataPath, + 'delete-workspaces', + `${id}` + ); + try { + return await fs.move(basePath, movedPath, { + overwrite: true, + }); + } catch (error) { + logger.error('deleteWorkspace', error); + } +} diff --git a/apps/electron/layers/main/src/index.ts b/apps/electron/layers/main/src/index.ts index cbd97d8527..5ebe120e12 100644 --- a/apps/electron/layers/main/src/index.ts +++ b/apps/electron/layers/main/src/index.ts @@ -1,24 +1,21 @@ import './security-restrictions'; import { app } from 'electron'; -import path from 'path'; -import { logger } from '../../logger'; +import { registerEvents } from './events'; import { registerHandlers } from './handlers'; +import { registerUpdater } from './handlers/updater'; +import { logger } from './logger'; import { restoreOrCreateWindow } from './main-window'; import { registerProtocol } from './protocol'; -import { registerUpdater } from './updater'; -if (require('electron-squirrel-startup')) app.exit(); -if (process.defaultApp) { - if (process.argv.length >= 2) { - app.setAsDefaultProtocolClient('affine', process.execPath, [ - path.resolve(process.argv[1]), - ]); - } -} else { - app.setAsDefaultProtocolClient('affine'); +// allow tests to overwrite app name through passing args +if (process.argv.includes('--app-name')) { + const appNameIndex = process.argv.indexOf('--app-name'); + const appName = process.argv[appNameIndex + 1]; + app.setName(appName); } + /** * Prevent multiple instances */ @@ -58,6 +55,7 @@ app .whenReady() .then(registerProtocol) .then(registerHandlers) + .then(registerEvents) .then(restoreOrCreateWindow) .then(registerUpdater) .catch(e => console.error('Failed create window:', e)); diff --git a/apps/electron/layers/main/src/logger.ts b/apps/electron/layers/main/src/logger.ts new file mode 100644 index 0000000000..b633e7eab3 --- /dev/null +++ b/apps/electron/layers/main/src/logger.ts @@ -0,0 +1,13 @@ +import { shell } from 'electron'; +import log from 'electron-log'; + +export const logger = log; + +export function getLogFilePath() { + return log.transports.file.getFile().path; +} + +export function revealLogFile() { + const filePath = getLogFilePath(); + shell.showItemInFolder(filePath); +} diff --git a/apps/electron/layers/main/src/main-window.ts b/apps/electron/layers/main/src/main-window.ts index 7178160f41..7654362d16 100644 --- a/apps/electron/layers/main/src/main-window.ts +++ b/apps/electron/layers/main/src/main-window.ts @@ -2,8 +2,8 @@ import { BrowserWindow, nativeTheme } from 'electron'; import electronWindowState from 'electron-window-state'; import { join } from 'path'; -import { logger } from '../../logger'; import { isMacOS } from '../../utils'; +import { logger } from './logger'; const IS_DEV: boolean = process.env.NODE_ENV === 'development' && !process.env.CI; @@ -60,7 +60,9 @@ async function createWindow() { logger.info('main window is ready to show'); if (DEV_TOOL) { - browserWindow.webContents.openDevTools(); + browserWindow.webContents.openDevTools({ + mode: 'detach', + }); } }); @@ -75,9 +77,11 @@ async function createWindow() { */ const pageUrl = process.env.DEV_SERVER_URL || 'file://./index.html'; // see protocol.ts + logger.info('loading page at', pageUrl); + await browserWindow.loadURL(pageUrl); - logger.info('main window is loaded at' + pageUrl); + logger.info('main window is loaded at', pageUrl); return browserWindow; } diff --git a/apps/electron/layers/main/src/protocol.ts b/apps/electron/layers/main/src/protocol.ts index 23810d5053..1e190f5b62 100644 --- a/apps/electron/layers/main/src/protocol.ts +++ b/apps/electron/layers/main/src/protocol.ts @@ -33,7 +33,6 @@ export function registerProtocol() { protocol.interceptFileProtocol('file', (request, callback) => { const url = request.url.replace(/^file:\/\//, ''); const realpath = toAbsolutePath(url); - // console.log('realpath', realpath, 'for', url); callback(realpath); return true; }); @@ -41,7 +40,6 @@ export function registerProtocol() { protocol.registerFileProtocol('assets', (request, callback) => { const url = request.url.replace(/^assets:\/\//, ''); const realpath = toAbsolutePath(url); - // console.log('realpath', realpath, 'for', url); callback(realpath); return true; }); diff --git a/apps/electron/layers/main/src/send-main-event.ts b/apps/electron/layers/main/src/send-main-event.ts deleted file mode 100644 index b1849168cc..0000000000 --- a/apps/electron/layers/main/src/send-main-event.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { BrowserWindow } from 'electron'; - -import type { MainEventMap } from '../../main-events'; - -function getActiveWindows() { - return BrowserWindow.getAllWindows().filter(win => !win.isDestroyed()); -} - -export function sendMainEvent( - type: T, - ...args: Parameters -) { - getActiveWindows().forEach(win => win.webContents.send(type, ...args)); -} diff --git a/apps/electron/layers/main/src/updater.ts b/apps/electron/layers/main/src/updater.ts deleted file mode 100644 index 14137c8c0b..0000000000 --- a/apps/electron/layers/main/src/updater.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { autoUpdater } from 'electron-updater'; - -import { isMacOS } from '../../utils'; -import { sendMainEvent } from './send-main-event'; -const buildType = (process.env.BUILD_TYPE || 'canary').trim().toLowerCase(); -const mode = process.env.NODE_ENV; -const isDev = mode === 'development'; - -autoUpdater.autoDownload = false; -autoUpdater.allowPrerelease = buildType !== 'stable'; -autoUpdater.autoInstallOnAppQuit = false; -autoUpdater.autoRunAppAfterInstall = false; -autoUpdater.setFeedURL({ - channel: buildType, - provider: 'github', - repo: 'AFFiNE', - owner: 'toeverything', - releaseType: buildType === 'stable' ? 'release' : 'prerelease', -}); - -export const updateClient = async () => { - autoUpdater.quitAndInstall(); -}; - -export const registerUpdater = async () => { - if (isMacOS()) { - autoUpdater.on('update-available', () => { - autoUpdater.downloadUpdate(); - }); - autoUpdater.on('download-progress', e => { - console.log(e.percent); - }); - - autoUpdater.on('update-downloaded', e => { - sendMainEvent('main:client-update-available', e.version); - }); - autoUpdater.on('error', e => { - console.log(e.message); - }); - autoUpdater.forceDevUpdateConfig = isDev; - await autoUpdater.checkForUpdatesAndNotify(); - } -}; diff --git a/apps/electron/layers/main/src/utils.ts b/apps/electron/layers/main/src/utils.ts new file mode 100644 index 0000000000..9de0a27689 --- /dev/null +++ b/apps/electron/layers/main/src/utils.ts @@ -0,0 +1,19 @@ +export function debounce void>( + fn: T, + delay: number +) { + let timeoutId: NodeJS.Timer | undefined; + return (...args: Parameters) => { + if (timeoutId) { + clearTimeout(timeoutId); + } + timeoutId = setTimeout(() => { + fn(...args); + timeoutId = undefined; + }, delay); + }; +} + +export function ts() { + return new Date().getTime(); +} diff --git a/apps/electron/layers/preload/preload.d.ts b/apps/electron/layers/preload/preload.d.ts index 1c259896b3..064be96d9f 100644 --- a/apps/electron/layers/preload/preload.d.ts +++ b/apps/electron/layers/preload/preload.d.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/consistent-type-imports */ interface Window { - apis: typeof import('./src/affine-apis').apis; - appInfo: typeof import('./src/affine-apis').appInfo; + apis?: typeof import('./src/affine-apis').apis; + events?: typeof import('./src/affine-apis').events; + appInfo?: typeof import('./src/affine-apis').appInfo; } diff --git a/apps/electron/layers/preload/src/affine-apis.ts b/apps/electron/layers/preload/src/affine-apis.ts index 3b8a64413e..64250bd719 100644 --- a/apps/electron/layers/preload/src/affine-apis.ts +++ b/apps/electron/layers/preload/src/affine-apis.ts @@ -1,88 +1,88 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ // NOTE: we will generate preload types from this file import { ipcRenderer } from 'electron'; -import type { MainEventMap } from '../../main-events'; +import type { MainIPCEventMap, MainIPCHandlerMap } from '../../constraints'; -// main -> renderer -function onMainEvent( - eventName: T, - callback: MainEventMap[T] -): () => void { - // @ts-expect-error fix me later - const fn = (_, ...args) => callback(...args); - ipcRenderer.on(eventName, fn); - return () => ipcRenderer.off(eventName, fn); -} +type WithoutFirstParameter = T extends (_: any, ...args: infer P) => infer R + ? (...args: P) => R + : T; -const apis = { - db: { - // workspace providers - getDoc: (id: string): Promise => - ipcRenderer.invoke('db:get-doc', id), - applyDocUpdate: (id: string, update: Uint8Array) => - ipcRenderer.invoke('db:apply-doc-update', id, update), - addBlob: (workspaceId: string, key: string, data: Uint8Array) => - ipcRenderer.invoke('db:add-blob', workspaceId, key, data), - getBlob: (workspaceId: string, key: string): Promise => - ipcRenderer.invoke('db:get-blob', workspaceId, key), - deleteBlob: (workspaceId: string, key: string) => - ipcRenderer.invoke('db:delete-blob', workspaceId, key), - getPersistedBlobs: (workspaceId: string): Promise => - ipcRenderer.invoke('db:get-persisted-blobs', workspaceId), - - // listeners - onDBUpdate: (callback: (workspaceId: string) => void) => { - return onMainEvent('main:on-db-update', callback); - }, - }, - - workspace: { - list: (): Promise => ipcRenderer.invoke('workspace:list'), - delete: (id: string): Promise => - ipcRenderer.invoke('workspace:delete', id), - // create will be implicitly called by db functions - }, - - openLoadDBFileDialog: () => ipcRenderer.invoke('ui:open-load-db-file-dialog'), - openSaveDBFileDialog: () => ipcRenderer.invoke('ui:open-save-db-file-dialog'), - - // ui - onThemeChange: (theme: string) => - ipcRenderer.invoke('ui:theme-change', theme), - - onSidebarVisibilityChange: (visible: boolean) => - ipcRenderer.invoke('ui:sidebar-visibility-change', visible), - - onWorkspaceChange: (workspaceId: string) => - ipcRenderer.invoke('ui:workspace-change', workspaceId), - - openDBFolder: () => ipcRenderer.invoke('ui:open-db-folder'), - - /** - * Try sign in using Google and return a request object to exchange the code for a token - * Not exchange in Node side because it is easier to do it in the renderer with VPN - */ - getGoogleOauthCode: (): Promise<{ requestInit: RequestInit; url: string }> => - ipcRenderer.invoke('ui:get-google-oauth-code'), - - /** - * Secret backdoor to update environment variables in main process - */ - updateEnv: (env: string, value: string) => { - ipcRenderer.invoke('main:env-update', env, value); - }, - onClientUpdateInstall: () => { - ipcRenderer.invoke('ui:client-update-install'); - }, - - onClientUpdateAvailable: (callback: (version: string) => void) => { - return onMainEvent('main:client-update-available', callback); - }, +type HandlersMap = { + [K in keyof MainIPCHandlerMap[N]]: WithoutFirstParameter< + MainIPCHandlerMap[N][K] + >; }; +type PreloadHandlers = { + [N in keyof MainIPCHandlerMap]: HandlersMap; +}; + +type MainExposedMeta = { + handlers: [namespace: string, handlerNames: string[]][]; + events: [namespace: string, eventNames: string[]][]; +}; + +// main handlers that can be invoked from the renderer process +const apis: PreloadHandlers = (() => { + // the following were generated by the build script + // 1. bundle extra main/src/expose.ts entry + // 2. use generate-main-exposed-meta.mjs to generate exposed-meta.js in dist + // + // we cannot directly import main/src/handlers.ts because it will be bundled into the preload bundle + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { + handlers: handlersMeta, + }: MainExposedMeta = require('../main/exposed-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: MainIPCEventMap = (() => { + const { + events: eventsMeta, + }: MainExposedMeta = require('../main/exposed-meta'); + 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 }; +export { apis, appInfo, events }; diff --git a/apps/electron/layers/preload/src/index.ts b/apps/electron/layers/preload/src/index.ts index c187f096d6..2361d817a1 100644 --- a/apps/electron/layers/preload/src/index.ts +++ b/apps/electron/layers/preload/src/index.ts @@ -14,4 +14,5 @@ import * as affineApis from './affine-apis'; */ contextBridge.exposeInMainWorld('apis', affineApis.apis); +contextBridge.exposeInMainWorld('events', affineApis.events); contextBridge.exposeInMainWorld('appInfo', affineApis.appInfo); diff --git a/apps/electron/package.json b/apps/electron/package.json index ee7fffde5f..8ae1f6be75 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -11,15 +11,18 @@ "homepage": "https://github.com/toeverything/AFFiNE", "scripts": { "dev": "yarn electron-rebuild && yarn cross-env DEV_SERVER_URL=http://localhost:8080 node scripts/dev.mjs", + "watch": "yarn electron-rebuild && yarn cross-env DEV_SERVER_URL=http://localhost:8080 node scripts/dev.mjs --watch", "prod": "yarn electron-rebuild && yarn node scripts/dev.mjs", + "build-layers": "zx scripts/build-layers.mjs", "generate-assets": "zx scripts/generate-assets.mjs", + "generate-main-exposed-meta": "zx scripts/generate-main-exposed-meta.mjs", "package": "electron-forge package", "make": "electron-forge make", "make-macos-arm64": "electron-forge make --platform=darwin --arch=arm64", "make-macos-x64": "electron-forge make --platform=darwin --arch=x64", "make-windows-x64": "electron-forge make --platform=win32 --arch=x64", "make-linux-x64": "electron-forge make --platform=linux --arch=x64", - "rebuild:for-test": "yarn rebuild better-sqlite3", + "rebuild:for-unit-test": "yarn rebuild better-sqlite3", "rebuild:for-electron": "yarn electron-rebuild", "test": "playwright test" }, @@ -55,7 +58,10 @@ }, "dependencies": { "better-sqlite3": "^8.3.0", + "chokidar": "^3.5.3", "electron-updater": "^5.3.0", + "nanoid": "^4.0.2", + "rxjs": "^7.8.1", "yjs": "^13.6.1" }, "build": { diff --git a/apps/electron/scripts/build-ci.mts b/apps/electron/scripts/build-ci.mts deleted file mode 100755 index ef6c70339f..0000000000 --- a/apps/electron/scripts/build-ci.mts +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env ts-node-esm -import * as esbuild from 'esbuild'; - -import { config } from './common.mjs'; - -const common = config(); -await esbuild.build(common.preload); - -await esbuild.build({ - ...common.main, - define: { - ...common.main.define, - 'process.env.NODE_ENV': `"production"`, - }, -}); - -console.log('Compiled successfully.'); diff --git a/apps/electron/scripts/build-layers.mjs b/apps/electron/scripts/build-layers.mjs new file mode 100644 index 0000000000..777abec892 --- /dev/null +++ b/apps/electron/scripts/build-layers.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env zx +import 'zx/globals'; + +import * as esbuild from 'esbuild'; + +import { config } from './common.mjs'; + +const NODE_ENV = + process.env.NODE_ENV === 'development' ? 'development' : 'production'; + +async function buildLayers() { + const common = config(); + await esbuild.build(common.preload); + + await esbuild.build({ + ...common.main, + define: { + ...common.main.define, + 'process.env.NODE_ENV': `"${NODE_ENV}"`, + 'process.env.BUILD_TYPE': `"${process.env.BUILD_TYPE || 'stable'}"`, + }, + }); + + await $`yarn workspace @affine/electron generate-main-exposed-meta`; +} + +await buildLayers(); +echo('Build layers done'); diff --git a/apps/electron/scripts/common.mjs b/apps/electron/scripts/common.mjs index 10240d3a5f..122f0223ba 100644 --- a/apps/electron/scripts/common.mjs +++ b/apps/electron/scripts/common.mjs @@ -5,6 +5,13 @@ import { fileURLToPath } from 'url'; export const root = fileURLToPath(new URL('..', import.meta.url)); export const NODE_MAJOR_VERSION = 18; +// hard-coded for now: +// fixme(xp): report error if app is not running on DEV_SERVER_URL +const DEV_SERVER_URL = process.env.DEV_SERVER_URL; + +/** @type 'production' | 'development'' */ +const mode = (process.env.NODE_ENV = process.env.NODE_ENV || 'development'); + const nativeNodeModulesPlugin = { name: 'native-node-modules', setup(build) { @@ -20,22 +27,32 @@ const ENV_MACROS = ['AFFINE_GOOGLE_CLIENT_ID', 'AFFINE_GOOGLE_CLIENT_SECRET']; /** @return {{main: import('esbuild').BuildOptions, preload: import('esbuild').BuildOptions}} */ export const config = () => { - const define = Object.fromEntries( - ENV_MACROS.map(key => [ + const define = Object.fromEntries([ + ...ENV_MACROS.map(key => [ 'process.env.' + key, JSON.stringify(process.env[key] ?? ''), - ]) - ); + ]), + ['process.env.NODE_ENV', `"${mode}"`], + ]); + + if (DEV_SERVER_URL) { + define['process.env.DEV_SERVER_URL'] = `"${DEV_SERVER_URL}"`; + } + return { main: { - entryPoints: [resolve(root, './layers/main/src/index.ts')], + entryPoints: [ + resolve(root, './layers/main/src/index.ts'), + resolve(root, './layers/main/src/exposed.ts'), + ], outdir: resolve(root, './dist/layers/main'), bundle: true, target: `node${NODE_MAJOR_VERSION}`, platform: 'node', - external: ['electron', 'yjs', 'better-sqlite3'], + external: ['electron', 'yjs', 'better-sqlite3', 'electron-updater'], plugins: [nativeNodeModulesPlugin], define: define, + format: 'cjs', }, preload: { entryPoints: [resolve(root, './layers/preload/src/index.ts')], @@ -43,7 +60,8 @@ export const config = () => { bundle: true, target: `node${NODE_MAJOR_VERSION}`, platform: 'node', - external: ['electron'], + external: ['electron', '../main/exposed-meta'], + plugins: [nativeNodeModulesPlugin], define: define, }, }; diff --git a/apps/electron/scripts/dev.mjs b/apps/electron/scripts/dev.mjs index cc4399b239..56bd7d2bb0 100644 --- a/apps/electron/scripts/dev.mjs +++ b/apps/electron/scripts/dev.mjs @@ -1,4 +1,5 @@ -import { spawn } from 'node:child_process'; +/* eslint-disable no-async-promise-executor */ +import { execSync, spawn } from 'node:child_process'; import { readFileSync } from 'node:fs'; import path from 'node:path'; @@ -7,8 +8,8 @@ import * as esbuild from 'esbuild'; import { config, root } from './common.mjs'; -/** @type 'production' | 'development'' */ -const mode = (process.env.NODE_ENV = process.env.NODE_ENV || 'development'); +// this means we don't spawn electron windows, mainly for testing +const watchMode = process.argv.includes('--watch'); /** Messages on stderr that match any of the contained patterns will be stripped from output */ const stderrFilterPatterns = [ @@ -29,14 +30,13 @@ try { ); } -// hard-coded for now: -// fixme(xp): report error if app is not running on DEV_SERVER_URL -const DEV_SERVER_URL = process.env.DEV_SERVER_URL; - /** @type {ChildProcessWithoutNullStreams | null} */ let spawnProcess = null; function spawnOrReloadElectron() { + if (watchMode) { + return; + } if (spawnProcess !== null) { spawnProcess.off('exit', process.exit); spawnProcess.kill('SIGINT'); @@ -59,77 +59,80 @@ function spawnOrReloadElectron() { console.error(data); }); - // Stops the watch script when the application has been quit + // Stops the watch script when the application has quit spawnProcess.on('exit', process.exit); } const common = config(); -async function main() { - async function watchPreload(onInitialBuild) { +function watchPreload() { + return new Promise(async resolve => { + let initialBuild = false; const preloadBuild = await esbuild.context({ ...common.preload, plugins: [ ...(common.preload.plugins ?? []), { - name: 'affine-dev:reload-app-on-preload-change', + name: 'electron-dev:reload-app-on-preload-change', setup(build) { - let initialBuild = false; build.onEnd(() => { if (initialBuild) { - console.log(`[preload] has changed`); + console.log(`[preload] has changed, [re]launching electron...`); spawnOrReloadElectron(); } else { + resolve(); initialBuild = true; - onInitialBuild(); } }); }, }, ], }); + // watch will trigger build.onEnd() on first run & on subsequent changes await preloadBuild.watch(); - } + }); +} - async function watchMain() { - const define = { - ...common.main.define, - 'process.env.NODE_ENV': `"${mode}"`, - }; - - if (DEV_SERVER_URL) { - define['process.env.DEV_SERVER_URL'] = `"${DEV_SERVER_URL}"`; - } +async function watchMain() { + return new Promise(async resolve => { + let initialBuild = false; const mainBuild = await esbuild.context({ ...common.main, - define: define, plugins: [ ...(common.main.plugins ?? []), { - name: 'affine-dev:reload-app-on-main-change', + name: 'electron-dev:reload-app-on-main-change', setup(build) { - let initialBuild = false; build.onEnd(() => { + execSync('yarn generate-main-exposed-meta'); + if (initialBuild) { console.log(`[main] has changed, [re]launching electron...`); + spawnOrReloadElectron(); } else { + resolve(); initialBuild = true; } - spawnOrReloadElectron(); }); }, }, ], }); await mainBuild.watch(); - } - - await watchPreload(async () => { - await watchMain(); - spawnOrReloadElectron(); - console.log(`Electron is started, watching for changes...`); }); } +async function main() { + await watchMain(); + await watchPreload(); + + if (watchMode) { + console.log(`Watching for changes...`); + } else { + spawnOrReloadElectron(); + console.log(`Electron is started, watching for changes...`); + } +} + main(); diff --git a/apps/electron/scripts/generate-assets.mjs b/apps/electron/scripts/generate-assets.mjs index 966c6cc405..afee19c86b 100644 --- a/apps/electron/scripts/generate-assets.mjs +++ b/apps/electron/scripts/generate-assets.mjs @@ -3,10 +3,6 @@ import 'zx/globals'; import path from 'node:path'; -import * as esbuild from 'esbuild'; - -import { config } from './common.mjs'; - const repoRootDir = path.join(__dirname, '..', '..', '..'); const electronRootDir = path.join(__dirname, '..'); const publicDistDir = path.join(electronRootDir, 'resources'); @@ -37,8 +33,7 @@ if (process.platform === 'win32') { cd(repoRootDir); // step 1: build electron resources -await buildLayers(); -echo('Build layers done'); +await $`yarn workspace @affine/electron build-layers`; // step 2: build web (nextjs) dist if (!process.env.SKIP_WEB_BUILD) { @@ -75,17 +70,3 @@ async function cleanup() { await fs.emptyDir(path.join(electronRootDir, 'layers', 'preload', 'dist')); await fs.remove(path.join(electronRootDir, 'out')); } - -async function buildLayers() { - const common = config(); - await esbuild.build(common.preload); - - await esbuild.build({ - ...common.main, - define: { - ...common.main.define, - 'process.env.NODE_ENV': `"production"`, - 'process.env.BUILD_TYPE': `"${process.env.BUILD_TYPE || 'statble'}"`, - }, - }); -} diff --git a/apps/electron/scripts/generate-main-exposed-meta.mjs b/apps/electron/scripts/generate-main-exposed-meta.mjs new file mode 100644 index 0000000000..eb88a87f8b --- /dev/null +++ b/apps/electron/scripts/generate-main-exposed-meta.mjs @@ -0,0 +1,40 @@ +#!/usr/bin/env zx +/* eslint-disable @typescript-eslint/no-restricted-imports */ +import 'zx/globals'; + +const mainDistDir = path.resolve(__dirname, '../dist/layers/main'); + +// be careful and avoid any side effects in +const { handlers, events } = await import( + path.resolve(mainDistDir, 'exposed.js') +); + +const handlersMeta = Object.entries(handlers).map( + ([namespace, namespaceHandlers]) => { + return [ + namespace, + Object.keys(namespaceHandlers).map(handlerName => handlerName), + ]; + } +); + +const eventsMeta = Object.entries(events).map( + ([namespace, namespaceHandlers]) => { + return [ + namespace, + Object.keys(namespaceHandlers).map(handlerName => handlerName), + ]; + } +); + +const meta = { + handlers: handlersMeta, + events: eventsMeta, +}; + +await fs.writeFile( + path.resolve(mainDistDir, 'exposed-meta.js'), + `module.exports = ${JSON.stringify(meta)};` +); + +console.log('generate main exposed-meta.js done'); diff --git a/apps/electron/tests/basic.spec.ts b/apps/electron/tests/basic.spec.ts index c7bde31b83..6e073111db 100644 --- a/apps/electron/tests/basic.spec.ts +++ b/apps/electron/tests/basic.spec.ts @@ -1,48 +1,17 @@ -import { resolve } from 'node:path'; - -import { test, testResultDir } from '@affine-test/kit/playwright'; -import type { Page } from '@playwright/test'; import { expect } from '@playwright/test'; -import type { ElectronApplication } from 'playwright'; -import { _electron as electron } from 'playwright'; -let electronApp: ElectronApplication; -let page: Page; +import { test } from './fixture'; -test.beforeEach(async () => { - electronApp = await electron.launch({ - args: [resolve(__dirname, '..')], - executablePath: resolve(__dirname, '../node_modules/.bin/electron'), - colorScheme: 'light', - }); - page = await electronApp.firstWindow(); - await page.getByTestId('onboarding-modal-close-button').click({ - delay: 100, - }); - // cleanup page data - await page.evaluate(() => localStorage.clear()); -}); - -test.afterEach(async () => { - // cleanup page data - await page.evaluate(() => localStorage.clear()); - await page.close(); - await electronApp.close(); -}); - -test('new page', async () => { +test('new page', async ({ page, workspace }) => { await page.getByTestId('new-page-button').click({ delay: 100, }); await page.waitForSelector('v-line'); - const flavour = await page.evaluate( - // @ts-expect-error - () => globalThis.currentWorkspace.flavour - ); + const flavour = (await workspace.current()).flavour; expect(flavour).toBe('local'); }); -test('app theme', async () => { +test('app theme', async ({ page, electronApp }) => { await page.waitForSelector('v-line'); const root = page.locator('html'); { @@ -50,25 +19,35 @@ test('app theme', async () => { element.getAttribute('data-theme') ); expect(themeMode).toBe('light'); + + // check if electron theme source is set to light + const themeSource = await electronApp.evaluate(({ nativeTheme }) => { + return nativeTheme.themeSource; + }); + + expect(themeSource).toBe('light'); } - await page.screenshot({ - path: resolve(testResultDir, 'affine-light-theme-electron.png'), - }); - await page.getByTestId('editor-option-menu').click(); - await page.getByTestId('change-theme-dark').click(); - await page.waitForTimeout(50); + { - const themeMode = await root.evaluate(element => - element.getAttribute('data-theme') - ); - expect(themeMode).toBe('dark'); + await page.getByTestId('editor-option-menu').click(); + await page.getByTestId('change-theme-dark').click(); + await page.waitForTimeout(50); + { + const themeMode = await root.evaluate(element => + element.getAttribute('data-theme') + ); + expect(themeMode).toBe('dark'); + } + + const themeSource = await electronApp.evaluate(({ nativeTheme }) => { + return nativeTheme.themeSource; + }); + + expect(themeSource).toBe('dark'); } - await page.screenshot({ - path: resolve(testResultDir, 'affine-dark-theme-electron.png'), - }); }); -test('affine cloud disabled', async () => { +test('affine cloud disabled', async ({ page }) => { await page.getByTestId('new-page-button').click({ delay: 100, }); @@ -79,7 +58,8 @@ test('affine cloud disabled', async () => { state: 'visible', }); }); -test('affine onboarding button', async () => { + +test('affine onboarding button', async ({ page }) => { await page.getByTestId('help-island').click(); await page.getByTestId('easy-guide').click(); const onboardingModal = page.locator('[data-testid=onboarding-modal]'); diff --git a/apps/electron/tests/fixture.ts b/apps/electron/tests/fixture.ts new file mode 100644 index 0000000000..9529397a36 --- /dev/null +++ b/apps/electron/tests/fixture.ts @@ -0,0 +1,86 @@ +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// + +/* eslint-disable no-empty-pattern */ +import crypto from 'node:crypto'; +import { resolve } from 'node:path'; + +import { test as base } from '@affine-test/kit/playwright'; +import fs from 'fs-extra'; +import type { ElectronApplication, Page } from 'playwright'; +import { _electron as electron } from 'playwright'; + +function generateUUID() { + return crypto.randomUUID(); +} + +export const test = base.extend<{ + page: Page; + electronApp: ElectronApplication; + appInfo: { + appPath: string; + appData: string; + sessionData: string; + }; + workspace: { + // get current workspace + current: () => Promise; // todo: type + }; +}>({ + page: async ({ electronApp }, use) => { + const page = await electronApp.firstWindow(); + await page.getByTestId('onboarding-modal-close-button').click({ + delay: 100, + }); + if (!process.env.CI) { + await electronApp.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0].webContents.openDevTools({ + mode: 'detach', + }); + }); + } + const logFilePath = await page.evaluate(async () => { + return window.apis?.debug.logFilePath(); + }); + await use(page); + await page.close(); + if (logFilePath) { + const logs = await fs.readFile(logFilePath, 'utf-8'); + console.log(logs); + } + }, + electronApp: async ({}, use) => { + // a random id to avoid conflicts between tests + const id = generateUUID(); + const electronApp = await electron.launch({ + args: [resolve(__dirname, '..'), '--app-name', 'affine-test-' + id], + executablePath: resolve(__dirname, '../node_modules/.bin/electron'), + colorScheme: 'light', + }); + const sessionDataPath = await electronApp.evaluate(async ({ app }) => { + return app.getPath('sessionData'); + }); + await use(electronApp); + await fs.rm(sessionDataPath, { recursive: true, force: true }); + }, + appInfo: async ({ electronApp }, use) => { + const appInfo = await electronApp.evaluate(async ({ app }) => { + return { + appPath: app.getAppPath(), + appData: app.getPath('appData'), + sessionData: app.getPath('sessionData'), + }; + }); + await use(appInfo); + }, + workspace: async ({ page }, use) => { + await use({ + current: async () => { + return await page.evaluate(async () => { + // @ts-expect-error + return globalThis.currentWorkspace; + }); + }, + }); + }, +}); diff --git a/apps/electron/tests/setup.ts b/apps/electron/tests/setup.ts new file mode 100644 index 0000000000..c069a995c8 --- /dev/null +++ b/apps/electron/tests/setup.ts @@ -0,0 +1,7 @@ +import { execSync } from 'node:child_process'; + +export default async function () { + execSync('yarn ts-node-esm scripts/', { + cwd: path.join(__dirname, '..'), + }); +} diff --git a/apps/electron/tests/workspace.spec.ts b/apps/electron/tests/workspace.spec.ts new file mode 100644 index 0000000000..5e4a25ff76 --- /dev/null +++ b/apps/electron/tests/workspace.spec.ts @@ -0,0 +1,97 @@ +import path from 'node:path'; + +import { expect } from '@playwright/test'; +import fs from 'fs-extra'; + +import { test } from './fixture'; + +test('check workspace has a DB file', async ({ appInfo, workspace }) => { + const w = await workspace.current(); + const dbPath = path.join( + appInfo.sessionData, + 'workspaces', + w.id, + 'storage.db' + ); + // check if db file exists + expect(await fs.exists(dbPath)).toBe(true); +}); + +test('move workspace db file', async ({ page, appInfo, workspace }) => { + const w = await workspace.current(); + const settingButton = page.getByTestId('slider-bar-workspace-setting-button'); + // goto settings + await settingButton.click(); + + const tmpPath = path.join(appInfo.sessionData, w.id + '-tmp.db'); + + // move db file to tmp folder + await page.evaluate(tmpPath => { + window.apis?.dialog.setFakeDialogResult({ + filePath: tmpPath, + }); + }, tmpPath); + + await page.getByTestId('move-folder').click(); + // check if db file exists + await page.waitForSelector('text="Move folder success"'); + expect(await fs.exists(tmpPath)).toBe(true); +}); + +test('export then add', async ({ page, appInfo, workspace }) => { + const w = await workspace.current(); + const settingButton = page.getByTestId('slider-bar-workspace-setting-button'); + // goto settings + await settingButton.click(); + + const originalId = w.id; + + const newWorkspaceName = 'new-test-name'; + + // change workspace name + await page.getByTestId('workspace-name-input').fill(newWorkspaceName); + await page.getByTestId('save-workspace-name').click(); + await page.waitForSelector('text="Update workspace name success"'); + await page.click('[data-tab-key="export"]'); + + const tmpPath = path.join(appInfo.sessionData, w.id + '-tmp.db'); + + // move db file to tmp folder + await page.evaluate(tmpPath => { + window.apis?.dialog.setFakeDialogResult({ + filePath: tmpPath, + }); + }, tmpPath); + + await page.getByTestId('export-affine-backup').click(); + await page.waitForSelector('text="Export success"'); + + expect(await fs.exists(tmpPath)).toBe(true); + + // add workspace + // we are reusing the same db file so that we don't need to maintain one + // in the codebase + + await page.getByTestId('current-workspace').click(); + await page.getByTestId('add-or-new-workspace').click(); + + await page.evaluate(tmpPath => { + window.apis?.dialog.setFakeDialogResult({ + filePath: tmpPath, + }); + }, tmpPath); + + // load the db file + await page.getByTestId('add-workspace').click(); + + // should show "Added Successfully" dialog + await page.waitForSelector('text="Added Successfully"'); + await page.getByTestId('create-workspace-continue-button').click(); + + // sleep for a while to wait for the workspace to be added :D + await page.waitForTimeout(2000); + const newWorkspace = await workspace.current(); + expect(newWorkspace.id).not.toBe(originalId); + // check its name is correct + await expect(page.getByTestId('workspace-name')).toHaveText(newWorkspaceName); +}); diff --git a/apps/web/src/atoms/index.ts b/apps/web/src/atoms/index.ts index 31acb66c10..5ee5662b47 100644 --- a/apps/web/src/atoms/index.ts +++ b/apps/web/src/atoms/index.ts @@ -11,6 +11,7 @@ import type { Page } from '@blocksuite/store'; import { atom } from 'jotai'; import { atomWithStorage } from 'jotai/utils'; +import type { CreateWorkspaceMode } from '../components/affine/create-workspace-modal'; import { WorkspacePlugins } from '../plugins'; const logger = new DebugLogger('web:atoms'); @@ -49,9 +50,9 @@ rootWorkspacesMetadataAtom.onMount = setAtom => { }); if (environment.isDesktop) { - window.apis.workspace.list().then(workspaceIDs => { + window.apis?.workspace.list().then(workspaceIDs => { const newMetadata = workspaceIDs.map(w => ({ - id: w, + id: w[0], flavour: WorkspaceFlavour.LOCAL, })); setAtom(metadata => { @@ -75,7 +76,7 @@ export const currentEditorAtom = rootCurrentEditorAtom; // modal atoms export const openWorkspacesModalAtom = atom(false); -export const openCreateWorkspaceModalAtom = atom(false); +export const openCreateWorkspaceModalAtom = atom(false); export const openQuickSearchModalAtom = atom(false); export const openOnboardingModalAtom = atom(false); diff --git a/apps/web/src/components/affine/create-workspace-modal/index.css.ts b/apps/web/src/components/affine/create-workspace-modal/index.css.ts new file mode 100644 index 0000000000..580c927675 --- /dev/null +++ b/apps/web/src/components/affine/create-workspace-modal/index.css.ts @@ -0,0 +1,43 @@ +import { globalStyle, style } from '@vanilla-extract/css'; + +export const header = style({ + position: 'relative', + height: '44px', +}); + +export const content = style({ + padding: '0 40px', + fontSize: '18px', + lineHeight: '26px', +}); + +globalStyle(`${content} p`, { + marginTop: '12px', + marginBottom: '16px', +}); + +export const contentTitle = style({ + fontSize: '20px', + lineHeight: '28px', + fontWeight: 600, + paddingBottom: '16px', +}); + +export const buttonGroup = style({ + display: 'flex', + justifyContent: 'flex-end', + gap: '20px', + margin: '24px 0', +}); + +export const radioGroup = style({ + display: 'flex', + flexDirection: 'column', + gap: '8px', +}); + +export const radio = style({ + cursor: 'pointer', + appearance: 'auto', + marginRight: '12px', +}); diff --git a/apps/web/src/components/affine/create-workspace-modal/index.tsx b/apps/web/src/components/affine/create-workspace-modal/index.tsx new file mode 100644 index 0000000000..134e5b2a82 --- /dev/null +++ b/apps/web/src/components/affine/create-workspace-modal/index.tsx @@ -0,0 +1,346 @@ +import { + Button, + Input, + Modal, + ModalCloseButton, + ModalWrapper, + toast, + Tooltip, +} from '@affine/component'; +import { DebugLogger } from '@affine/debug'; +import { config } from '@affine/env'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { HelpIcon } from '@blocksuite/icons'; +import { useSetAtom } from 'jotai'; +import type { KeyboardEvent } from 'react'; +import { useEffect } from 'react'; +import { useLayoutEffect } from 'react'; +import { useCallback, useRef, useState } from 'react'; + +import { openDisableCloudAlertModalAtom } from '../../../atoms'; +import { useAppHelper } from '../../../hooks/use-workspaces'; +import * as style from './index.css'; + +type CreateWorkspaceStep = + | 'set-db-location' + | 'name-workspace' + | 'set-syncing-mode'; + +export type CreateWorkspaceMode = 'add' | 'new' | false; + +const logger = new DebugLogger('CreateWorkspaceModal'); + +interface ModalProps { + mode: CreateWorkspaceMode; // false means not open + onClose: () => void; + onCreate: (id: string) => void; +} + +interface NameWorkspaceContentProps { + onClose: () => void; + onConfirmName: (name: string) => void; +} + +const NameWorkspaceContent = ({ + onConfirmName, + onClose, +}: NameWorkspaceContentProps) => { + const [workspaceName, setWorkspaceName] = useState(''); + const isComposition = useRef(false); + + const handleCreateWorkspace = useCallback(() => { + onConfirmName(workspaceName); + }, [onConfirmName, workspaceName]); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'Enter' && workspaceName && !isComposition.current) { + handleCreateWorkspace(); + } + }, + [handleCreateWorkspace, workspaceName] + ); + const t = useAFFiNEI18N(); + return ( +
+
{t['Name Your Workspace']()}
+

{t['Workspace description']()}

+ { + if (ref) { + setTimeout(() => ref.focus(), 0); + } + }} + data-testid="create-workspace-input" + onKeyDown={handleKeyDown} + placeholder={t['Set a Workspace name']()} + maxLength={15} // TODO: the max workspace name length? + minLength={0} + onChange={value => { + setWorkspaceName(value); + }} + onCompositionStart={() => { + isComposition.current = true; + }} + onCompositionEnd={() => { + isComposition.current = false; + }} + /> +
+ + +
+
+ ); +}; + +interface SetDBLocationContentProps { + onConfirmLocation: (dir?: string) => void; +} + +const SetDBLocationContent = ({ + onConfirmLocation, +}: SetDBLocationContentProps) => { + const t = useAFFiNEI18N(); + const [defaultDBLocation, setDefaultDBLocation] = useState(''); + + useEffect(() => { + window.apis?.db.getDefaultStorageLocation().then(dir => { + setDefaultDBLocation(dir); + }); + }, []); + + return ( +
+
{t['Set database location']()}
+

{t['Workspace database storage description']()}

+
+ + + + +
+
+ ); +}; + +interface SetSyncingModeContentProps { + mode: CreateWorkspaceMode; + onConfirmMode: (enableCloudSyncing: boolean) => void; +} + +const SetSyncingModeContent = ({ + mode, + onConfirmMode, +}: SetSyncingModeContentProps) => { + const t = useAFFiNEI18N(); + const [enableCloudSyncing, setEnableCloudSyncing] = useState(false); + return ( +
+
+ {t[mode === 'new' ? 'Created Successfully' : 'Added Successfully']()} +
+ +
+ + +
+ +
+ +
+
+ ); +}; + +export const CreateWorkspaceModal = ({ + mode, + onClose, + onCreate, +}: ModalProps) => { + const { createLocalWorkspace, addLocalWorkspace } = useAppHelper(); + const [step, setStep] = useState(); + const [addedId, setAddedId] = useState(); + const [workspaceName, setWorkspaceName] = useState(); + const [dbFileLocation, setDBFileLocation] = useState(); + const setOpenDisableCloudAlertModal = useSetAtom( + openDisableCloudAlertModalAtom + ); + const t = useAFFiNEI18N(); + + // todo: maybe refactor using xstate? + useLayoutEffect(() => { + let canceled = false; + // if mode changed, reset step + if (mode === 'add') { + // a hack for now + // when adding a workspace, we will immediately let user select a db file + // after it is done, it will effectively add a new workspace to app-data folder + // so after that, we will be able to load it via importLocalWorkspace + (async () => { + if (!window.apis) { + return; + } + logger.info('load db file'); + setStep(undefined); + const result = await window.apis.dialog.loadDBFile(); + if (result.workspaceId && !canceled) { + setAddedId(result.workspaceId); + setStep('set-syncing-mode'); + } else if (result.error || result.canceled) { + if (result.error) { + toast(t[result.error]()); + } + onClose(); + } + })(); + } else if (mode === 'new') { + setStep(environment.isDesktop ? 'set-db-location' : 'name-workspace'); + } else { + setStep(undefined); + } + return () => { + canceled = true; + }; + }, [mode, onClose, t]); + + return ( + + +
+ { + onClose(); + }} + /> +
+ {step === 'name-workspace' && ( + { + setWorkspaceName(name); + if (environment.isDesktop) { + setStep('set-syncing-mode'); + } else { + // this will be the last step for web for now + // fix me later + const id = await createLocalWorkspace(name); + onCreate(id); + } + }} + /> + )} + {step === 'set-db-location' && ( + { + setDBFileLocation(dir); + setStep('name-workspace'); + }} + /> + )} + {step === 'set-syncing-mode' && ( + { + if (!config.enableLegacyCloud && enableCloudSyncing) { + setOpenDisableCloudAlertModal(true); + } else { + let id = addedId; + // syncing mode is also the last step + if (addedId && mode === 'add') { + await addLocalWorkspace(addedId); + } else if (mode === 'new' && workspaceName) { + id = await createLocalWorkspace(workspaceName); + // if dbFileLocation is set, move db file to that location + if (dbFileLocation) { + await window.apis?.dialog.moveDBFile(id, dbFileLocation); + } + } else { + logger.error('invalid state'); + return; + } + if (id) { + onCreate(id); + } + } + }} + /> + )} +
+
+ ); +}; diff --git a/apps/web/src/components/affine/tmp-disable-affine-cloud-modal/style.ts b/apps/web/src/components/affine/tmp-disable-affine-cloud-modal/style.ts index c81814f99e..ad05a68234 100644 --- a/apps/web/src/components/affine/tmp-disable-affine-cloud-modal/style.ts +++ b/apps/web/src/components/affine/tmp-disable-affine-cloud-modal/style.ts @@ -26,7 +26,7 @@ export const StyleTips = styled('div')(() => { userSelect: 'none', margin: '20px 0', a: { - color: 'var(--affine-background-primary-color)', + color: 'var(--affine-primary-color)', }, }; }); diff --git a/apps/web/src/components/affine/workspace-setting-detail/index.css.ts b/apps/web/src/components/affine/workspace-setting-detail/index.css.ts new file mode 100644 index 0000000000..ca4b15f66e --- /dev/null +++ b/apps/web/src/components/affine/workspace-setting-detail/index.css.ts @@ -0,0 +1,192 @@ +// import { styled } from '@affine/component'; +// import { FlexWrapper } from '@affine/component'; + +import { globalStyle, style, styleVariants } from '@vanilla-extract/css'; + +export const container = style({ + display: 'flex', + flexDirection: 'column', + padding: '52px 52px 0 52px', + height: 'calc(100vh - 52px)', +}); + +export const sidebar = style({ + marginTop: '52px', +}); + +export const content = style({ + overflow: 'auto', + flex: 1, + marginTop: '40px', +}); + +const baseAvatar = style({ + position: 'relative', + marginRight: '20px', + cursor: 'pointer', +}); + +globalStyle(`${baseAvatar} .camera-icon`, { + position: 'absolute', + top: 0, + left: 0, + display: 'none', + width: '100%', + height: '100%', + borderRadius: '50%', + backgroundColor: 'rgba(60, 61, 63, 0.5)', + justifyContent: 'center', + alignItems: 'center', + zIndex: 10, +}); + +globalStyle(`${baseAvatar}:hover .camera-icon`, { + display: 'flex', +}); + +export const avatar = styleVariants({ + disabled: [ + baseAvatar, + { + cursor: 'default', + }, + ], + enabled: [ + baseAvatar, + { + cursor: 'pointer', + }, + ], +}); + +const baseTagItem = style({ + display: 'flex', + margin: '0 48px 0 0', + height: '34px', + fontWeight: '500', + fontSize: 'var(--affine-font-h6)', + lineHeight: 'var(--affine-line-height)', + cursor: 'pointer', + transition: 'all 0.15s ease', +}); + +export const tagItem = styleVariants({ + active: [ + baseTagItem, + { + color: 'var(--affine-primary-color)', + }, + ], + inactive: [ + baseTagItem, + { + color: 'var(--affine-text-secondary-color)', + }, + ], +}); + +export const settingKey = style({ + width: '140px', + fontSize: 'var(--affine-font-base)', + fontWeight: 500, + marginRight: '56px', + flexShrink: 0, +}); + +export const settingItemLabel = style({ + fontSize: 'var(--affine-font-base)', + fontWeight: 600, + flexShrink: 0, +}); + +export const settingItemLabelHint = style({ + fontSize: 'var(--affine-font-sm)', + color: 'var(--affine-text-secondary-color)', + fontWeight: 400, + flexShrink: 0, + marginTop: '4px', +}); + +export const row = style({ + padding: '40px 0', + display: 'flex', + gap: '60px', + selectors: { + '&': { + borderBottom: '1px solid var(--affine-border-color)', + }, + '&:first-child': { + paddingTop: 0, + }, + }, +}); + +export const col = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + flexShrink: 0, + selectors: { + [`${row} &:nth-child(1)`]: { + flex: 3, + }, + [`${row} &:nth-child(2)`]: { + flex: 5, + }, + [`${row} &:nth-child(3)`]: { + flex: 2, + alignItems: 'flex-end', + }, + }, +}); + +export const workspaceName = style({ + fontWeight: '400', + fontSize: 'var(--affine-font-h6)', +}); + +export const indicator = style({ + height: '2px', + background: 'var(--affine-primary-color)', + position: 'absolute', + left: '0', + bottom: '0', + transition: 'left .3s, width .3s', +}); + +export const tabButtonWrapper = style({ + display: 'flex', + position: 'relative', +}); + +export const storageTypeWrapper = style({ + width: '100%', + display: 'flex', + alignItems: 'flex-start', + padding: '12px', + borderRadius: '10px', + gap: '12px', + boxShadow: 'var(--affine-shadow-1)', + cursor: 'pointer', + selectors: { + '&:hover': { + boxShadow: 'var(--affine-shadow-2)', + }, + '&:not(:last-child)': { + marginBottom: '12px', + }, + }, +}); + +export const storageTypeLabelWrapper = style({ + flex: 1, +}); + +export const storageTypeLabel = style({ + fontSize: 'var(--affine-font-base)', +}); + +export const storageTypeLabelHint = style({ + fontSize: 'var(--affine-font-sm)', + color: 'var(--affine-text-secondary-color)', +}); diff --git a/apps/web/src/components/affine/workspace-setting-detail/index.tsx b/apps/web/src/components/affine/workspace-setting-detail/index.tsx index 518d284b00..bcb797cb3c 100644 --- a/apps/web/src/components/affine/workspace-setting-detail/index.tsx +++ b/apps/web/src/components/affine/workspace-setting-detail/index.tsx @@ -9,18 +9,12 @@ import { preload } from 'swr'; import { useIsWorkspaceOwner } from '../../../hooks/affine/use-is-workspace-owner'; import { fetcher, QueryKey } from '../../../plugins/affine/fetcher'; import type { AffineOfficialWorkspace } from '../../../shared'; +import * as style from './index.css'; import { CollaborationPanel } from './panel/collaboration'; import { ExportPanel } from './panel/export'; import { GeneralPanel } from './panel/general'; import { PublishPanel } from './panel/publish'; import { SyncPanel } from './panel/sync'; -import { - StyledIndicator, - StyledSettingContainer, - StyledSettingContent, - StyledTabButtonWrapper, - WorkspaceSettingTagItem, -} from './style'; export type WorkspaceSettingDetailProps = { workspace: AffineOfficialWorkspace; @@ -133,39 +127,43 @@ export const WorkspaceSettingDetail: React.FC< ); const Component = useMemo(() => panelMap[currentTab].ui, [currentTab]); return ( - - +
{Object.entries(panelMap).map(([key, value]) => { if ('enable' in value && !value.enable(workspace.flavour)) { return null; } return ( - {t[value.name]()} - +
); })} - { indicatorRef.current = ref; startTransaction(); }} /> -
- + +
{/* todo: add skeleton */} - - +
+ ); }; diff --git a/apps/web/src/components/affine/workspace-setting-detail/panel/export/index.tsx b/apps/web/src/components/affine/workspace-setting-detail/panel/export/index.tsx index d1a66067d4..60b681f8d4 100644 --- a/apps/web/src/components/affine/workspace-setting-detail/panel/export/index.tsx +++ b/apps/web/src/components/affine/workspace-setting-detail/panel/export/index.tsx @@ -1,7 +1,10 @@ -import { Button, Wrapper } from '@affine/component'; +import { Button, toast, Wrapper } from '@affine/component'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { rootCurrentWorkspaceIdAtom } from '@affine/workspace/atom'; +import { useAtomValue } from 'jotai'; export const ExportPanel = () => { + const id = useAtomValue(rootCurrentWorkspaceIdAtom); const t = useAFFiNEI18N(); return ( <> @@ -9,9 +12,12 @@ export const ExportPanel = () => { + + + + {environment.isDesktop && ( +
+
+
+ {t['Storage Folder']()} +
+
+ {t['Storage Folder Hint']()} +
+
+ +
+
{ + if (environment.isDesktop) { + window.apis?.dialog.revealDBFile(workspace.id); + } + }} > - <> -
- + +
+
+ {t['Open folder']()}
- - - +
+ {t['Open folder hint']()} +
+
+ +
+ +
{ + if (await window.apis?.dialog.moveDBFile(workspace.id)) { + toast(t['Move folder success']()); + } + }} + > + +
+
+ {t['Move folder']()} +
+
+ {t['Move folder hint']()} +
+
+ +
+
+
+
+ )} + +
+
+
+ {t['Delete Workspace']()} +
+
+ {t['Delete Workspace Label Hint']()} +
+
+ +
+
+ {isOwner ? ( + <> + + { + setShowDelete(false); + }} + workspace={workspace} + /> + ) : ( - - )} - - - - - {t['Workspace Name']()} - -
- - - {name} - {isOwner && ( - { - setShowEditInput(true); - }} - > - {t['Edit']()} - - )} - - - - {isOwner && ( - - - { - setInput(newName); - }} - > - - - - + <> + + { + setShowLeave(false); + }} + /> + )}
-
- - {/* fixme(himself65): how to know a workspace owner by api? */} - {/*{!isOwner && (*/} - {/* */} - {/* {t('Workspace Owner')}*/} - {/* */} - {/* */} - {/* */} - {/* */} - {/* /!*{currentWorkspace?.owner?.name}*!/*/} - {/* */} - {/* */} - {/*)}*/} - {/*{!isOwner && (*/} - {/* */} - {/* {t('Members')}*/} - {/* */} - {/* /!*{currentWorkspace?.memberCount}*!/*/} - {/* */} - {/* */} - {/*)}*/} - - { - if (environment.isDesktop) { - window.apis.openDBFolder(); - } - }} - > - {t['Workspace Type']()} - {isOwner ? ( - workspace.flavour === WorkspaceFlavour.LOCAL ? ( - - - {t['Local Workspace']()} - - ) : ( - - - {t['Cloud Workspace']()} - - ) - ) : ( - - - {t['Joined Workspace']()} - - )} - - - - {t['Delete Workspace']()} - {isOwner ? ( - <> - - { - setShowDelete(false); - }} - workspace={workspace} - /> - - ) : ( - <> - - { - setShowLeave(false); - }} - /> - - )} - +
); }; diff --git a/apps/web/src/components/affine/workspace-setting-detail/panel/general/style.ts b/apps/web/src/components/affine/workspace-setting-detail/panel/general/style.ts index a990272c70..39c1009ba7 100644 --- a/apps/web/src/components/affine/workspace-setting-detail/panel/general/style.ts +++ b/apps/web/src/components/affine/workspace-setting-detail/panel/general/style.ts @@ -4,7 +4,7 @@ import { Input } from '@affine/component'; export const StyledInput = styled(Input)(() => { return { border: '1px solid var(--affine-border-color)', - borderRadius: '10px', + borderRadius: '8px', fontSize: 'var(--affine-font-sm)', }; }); diff --git a/apps/web/src/components/affine/workspace-setting-detail/style.ts b/apps/web/src/components/affine/workspace-setting-detail/style.ts deleted file mode 100644 index b29bb90698..0000000000 --- a/apps/web/src/components/affine/workspace-setting-detail/style.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { styled } from '@affine/component'; -import { FlexWrapper } from '@affine/component'; -export const StyledSettingContainer = styled('div')(() => { - return { - display: 'flex', - flexDirection: 'column', - padding: '52px 0 0 52px', - height: 'calc(100vh - 52px)', - }; -}); - -export const StyledSettingSidebar = styled('div')(() => { - { - return { - marginTop: '52px', - }; - } -}); - -export const StyledSettingContent = styled('div')(() => { - return { - overflow: 'auto', - flex: 1, - paddingTop: '48px', - }; -}); - -export const WorkspaceSettingTagItem = styled('li')<{ isActive?: boolean }>( - ({ isActive }) => { - { - return { - display: 'flex', - margin: '0 48px 0 0', - height: '34px', - color: isActive - ? 'var(--affine-primary-color)' - : 'var(--affine-text-primary-color)', - fontWeight: '500', - fontSize: 'var(--affine-font-h6)', - lineHeight: 'var(--affine-line-height)', - cursor: 'pointer', - transition: 'all 0.15s ease', - }; - } - } -); - -export const StyledSettingKey = styled('div')(() => { - return { - width: '140px', - fontSize: 'var(--affine-font-base)', - fontWeight: 500, - marginRight: '56px', - flexShrink: 0, - }; -}); -export const StyledRow = styled(FlexWrapper)(() => { - return { - marginBottom: '42px', - }; -}); - -export const StyledWorkspaceName = styled('span')(() => { - return { - fontWeight: '400', - fontSize: 'var(--affine-font-h6)', - }; -}); - -export const StyledIndicator = styled('div')(() => { - return { - height: '2px', - background: 'var(--affine-primary-color)', - position: 'absolute', - left: '0', - bottom: '0', - transition: 'left .3s, width .3s', - }; -}); - -export const StyledTabButtonWrapper = styled('div')(() => { - return { - display: 'flex', - position: 'relative', - }; -}); - -// export const StyledDownloadCard = styled('div')<{ active?: boolean }>( -// ({ theme, active }) => { -// return { -// width: '240px', -// height: '86px', -// border: '1px solid', -// borderColor: active -// ? 'var(--affine-primary-color)' -// : 'var(--affine-border-color)', -// borderRadius: '10px', -// padding: '8px 12px', -// position: 'relative', -// ':not(:last-of-type)': { -// marginRight: '24px', -// }, -// svg: { -// display: active ? 'block' : 'none', -// ...positionAbsolute({ top: '-12px', right: '-12px' }), -// }, -// }; -// } -// ); -// export const StyledDownloadCardDes = styled('div')(({ theme }) => { -// return { -// fontSize: 'var(--affine-font-sm)', -// color: 'var(--affine-icon-color)', -// }; -// }); diff --git a/apps/web/src/components/pure/create-workspace-modal/index.tsx b/apps/web/src/components/pure/create-workspace-modal/index.tsx deleted file mode 100644 index 1f6a87869d..0000000000 --- a/apps/web/src/components/pure/create-workspace-modal/index.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { - Button, - Input, - Modal, - ModalCloseButton, - ModalWrapper, - styled, -} from '@affine/component'; -import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import type { KeyboardEvent } from 'react'; -import { useCallback, useRef, useState } from 'react'; - -interface ModalProps { - open: boolean; - onClose: () => void; - onCreate: (name: string) => void; -} - -export const CreateWorkspaceModal = ({ - open, - onClose, - onCreate, -}: ModalProps) => { - const [workspaceName, setWorkspaceName] = useState(''); - const isComposition = useRef(false); - - const handleCreateWorkspace = useCallback(() => { - onCreate(workspaceName); - }, [onCreate, workspaceName]); - const handleKeyDown = useCallback( - (event: KeyboardEvent) => { - if (event.key === 'Enter' && workspaceName && !isComposition.current) { - handleCreateWorkspace(); - } - }, - [handleCreateWorkspace, workspaceName] - ); - const t = useAFFiNEI18N(); - return ( - - -
- { - onClose(); - }} - /> -
- - {t['New Workspace']()} -

{t['Workspace description']()}

- { - if (ref) { - setTimeout(() => ref.focus(), 0); - } - }} - data-testid="create-workspace-input" - onKeyDown={handleKeyDown} - placeholder={t['Set a Workspace name']()} - maxLength={15} - minLength={0} - onChange={value => { - setWorkspaceName(value); - }} - onCompositionStart={() => { - isComposition.current = true; - }} - onCompositionEnd={() => { - isComposition.current = false; - }} - /> - -
-
-
- ); -}; - -const Header = styled('div')({ - position: 'relative', - height: '44px', -}); - -const Content = styled('div')(() => { - return { - padding: '0 84px', - textAlign: 'center', - fontSize: '18px', - lineHeight: '26px', - p: { - marginTop: '12px', - marginBottom: '16px', - }, - }; -}); - -const ContentTitle = styled('div')(() => { - return { - fontSize: '20px', - lineHeight: '28px', - fontWeight: 600, - textAlign: 'center', - paddingBottom: '16px', - }; -}); diff --git a/apps/web/src/components/pure/workspace-list-modal/index.tsx b/apps/web/src/components/pure/workspace-list-modal/index.tsx index 19b0089498..0ee799c3ee 100644 --- a/apps/web/src/components/pure/workspace-list-modal/index.tsx +++ b/apps/web/src/components/pure/workspace-list-modal/index.tsx @@ -1,4 +1,6 @@ import { + Menu, + MenuItem, Modal, ModalCloseButton, ModalWrapper, @@ -9,14 +11,19 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks'; import type { AccessTokenMessage } from '@affine/workspace/affine/login'; import type { AffineWorkspace, LocalWorkspace } from '@affine/workspace/type'; import { WorkspaceFlavour } from '@affine/workspace/type'; -import { HelpIcon, PlusIcon } from '@blocksuite/icons'; +import { HelpIcon, ImportIcon, PlusIcon } from '@blocksuite/icons'; import type { DragEndEvent } from '@dnd-kit/core'; -import { useCallback } from 'react'; +import { useCallback, useRef } from 'react'; import type { AllWorkspace } from '../../../shared'; import { Footer } from '../footer'; import { StyledCreateWorkspaceCard, + StyledCreateWorkspaceCardPill, + StyledCreateWorkspaceCardPillContainer, + StyledCreateWorkspaceCardPillContent, + StyledCreateWorkspaceCardPillIcon, + StyledCreateWorkspaceCardPillTextSecondary, StyledHelperContainer, StyledModalContent, StyledModalHeader, @@ -39,7 +46,8 @@ interface WorkspaceModalProps { onClickWorkspaceSetting: (workspace: AllWorkspace) => void; onClickLogin: () => void; onClickLogout: () => void; - onCreateWorkspace: () => void; + onNewWorkspace: () => void; + onAddWorkspace: () => void; onMoveWorkspace: (activeId: string, overId: string) => void; } @@ -53,12 +61,13 @@ export const WorkspaceListModal = ({ onClickLogout, onClickWorkspace, onClickWorkspaceSetting, - onCreateWorkspace, + onNewWorkspace, + onAddWorkspace, currentWorkspaceId, onMoveWorkspace, }: WorkspaceModalProps) => { const t = useAFFiNEI18N(); - + const anchorEL = useRef(null); return ( - - - - + {!environment.isDesktop && ( + + + + - - {t['New Workspace']()} -

{t['Create Or Import']()}

-
-
+ + + {t['New Workspace']()} + +

{t['Create Or Import']()}

+
+
+ )} + + {environment.isDesktop && ( + + + + +
+

{t['New Workspace']()}

+ +

{t['Create your own workspace']()}

+
+
+ + + +
+
+
+ + + +
+

{t['Add Workspace']()}

+ +

{t['Add Workspace Hint']()}

+
+
+ + + +
+
+
+ + } + > + + + + + + + + {t['New Workspace']()} + +

{t['Create Or Import']()}

+
+
+
+ )}