diff --git a/apps/electron/.yarnrc.yml b/apps/electron/.yarnrc.yml deleted file mode 100644 index 79a1d879af..0000000000 --- a/apps/electron/.yarnrc.yml +++ /dev/null @@ -1,8 +0,0 @@ -cacheFolder: '../../.yarn/cache' -deferredVersionFolder: '../../.yarn/versions' -globalFolder: '../../.yarn/global' -installStatePath: '../../.yarn/install-state.gz' -patchFolder: '../../.yarn/patches' -pnpUnpluggedFolder: '../../.yarn/unplugged' -yarnPath: '../../.yarn/releases/yarn-3.5.0.cjs' -virtualFolder: '../../.yarn/__virtual__' diff --git a/apps/electron/forge.config.js b/apps/electron/forge.config.js index d645136259..796f767edd 100644 --- a/apps/electron/forge.config.js +++ b/apps/electron/forge.config.js @@ -3,8 +3,10 @@ const { utils: { fromBuildIdentifier }, } = require('@electron-forge/core'); -const isCanary = process.env.BUILD_TYPE === 'canary'; +const path = require('node:path'); +const isCanary = process.env.BUILD_TYPE === 'canary'; +const buildType = isCanary ? 'canary' : 'stable'; const productName = isCanary ? 'AFFiNE-Canary' : 'AFFiNE'; const icoPath = isCanary ? './resources/icons/icon_canary.ico' @@ -13,6 +15,11 @@ const icnsPath = isCanary ? './resources/icons/icon_canary.icns' : './resources/icons/icon.icns'; +const arch = + process.argv.indexOf('--arch') > 0 + ? process.argv[process.argv.indexOf('--arch') + 1] + : process.arch; + /** * @type {import('@electron-forge/shared-types').ForgeConfig} */ @@ -25,10 +32,10 @@ module.exports = { stable: 'pro.affine.app', }), icon: icnsPath, - osxSign: { - identity: 'Developer ID Application: TOEVERYTHING PTE. LTD.', - 'hardened-runtime': true, - }, + // osxSign: { + // identity: 'Developer ID Application: TOEVERYTHING PTE. LTD.', + // 'hardened-runtime': true, + // }, osxNotarize: process.env.APPLE_ID ? { tool: 'notarytool', @@ -42,9 +49,25 @@ module.exports = { { name: '@electron-forge/maker-dmg', config: { - format: 'ULFO', icon: icnsPath, name: 'AFFiNE', + 'icon-size': 128, + background: './resources/icons/dmg-background.png', + contents: [ + { + x: 176, + y: 192, + type: 'file', + path: path.resolve( + __dirname, + 'out', + buildType, + `${productName}-darwin-${arch}`, + `${productName}.app` + ), + }, + { x: 432, y: 192, type: 'link', path: '/Applications' }, + ], }, }, { diff --git a/apps/electron/layers/logger.ts b/apps/electron/layers/logger.ts new file mode 100644 index 0000000000..d818e6a10c --- /dev/null +++ b/apps/electron/layers/logger.ts @@ -0,0 +1,3 @@ +import log from 'electron-log'; + +export const logger = log; diff --git a/apps/electron/layers/main/src/app-state/index.ts b/apps/electron/layers/main/src/app-state/index.ts deleted file mode 100644 index fdda5dc647..0000000000 --- a/apps/electron/layers/main/src/app-state/index.ts +++ /dev/null @@ -1,69 +0,0 @@ -import * as os from 'node:os'; -import path from 'node:path'; - -import { app, shell } from 'electron'; -import { BrowserWindow, ipcMain, nativeTheme } from 'electron'; -import fs from 'fs-extra'; -import { parse } from 'url'; - -import { isMacOS } from '../../../utils'; -import { getExchangeTokenParams, oauthEndpoint } from './google-auth'; - -const AFFINE_ROOT = path.join(os.homedir(), '.affine'); - -fs.ensureDirSync(AFFINE_ROOT); - -const logger = console; - -export const registerHandlers = () => { - 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: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; - }); -}; diff --git a/apps/electron/layers/main/src/context.ts b/apps/electron/layers/main/src/context.ts new file mode 100644 index 0000000000..fe9da23ffe --- /dev/null +++ b/apps/electron/layers/main/src/context.ts @@ -0,0 +1,9 @@ +import { app } from 'electron'; +import path from 'path'; + +export const appContext = { + appName: app.name, + appDataPath: path.join(app.getPath('appData'), app.name), +}; + +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 new file mode 100644 index 0000000000..162f70798a --- /dev/null +++ b/apps/electron/layers/main/src/data/export.ts @@ -0,0 +1,34 @@ +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/sqlite.ts b/apps/electron/layers/main/src/data/sqlite.ts new file mode 100644 index 0000000000..eda93d7d9c --- /dev/null +++ b/apps/electron/layers/main/src/data/sqlite.ts @@ -0,0 +1,216 @@ +import path from 'node:path'; + +import fs from 'fs-extra'; +import type { Database } from 'sqlite3'; +import sqlite3Default from 'sqlite3'; +import * as Y from 'yjs'; + +import { logger } from '../../../logger'; +import type { AppContext } from '../context'; + +const sqlite3 = sqlite3Default.verbose(); + +const schemas = [ + `CREATE TABLE IF NOT EXISTS "updates" ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + data BLOB NOT NULL, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL +)`, + `CREATE TABLE IF NOT EXISTS "blobs" ( + key TEXT PRIMARY KEY NOT NULL, + data BLOB NOT NULL, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL +)`, +]; + +sqlite3.verbose(); + +interface UpdateRow { + id: number; + data: Buffer; + timestamp: string; +} + +interface BlobRow { + key: string; + data: Buffer; + timestamp: string; +} + +export class WorkspaceDatabase { + sqliteDB$: Promise; + ydoc = new Y.Doc(); + _db: Database | null = null; + + ready: Promise; + + constructor(public path: string) { + this.sqliteDB$ = this.reconnectDB(); + logger.log('open db', path); + + this.ydoc.on('update', update => { + this.addUpdateToSQLite(update); + }); + + this.ready = (async () => { + const updates = await this.getUpdates(); + updates.forEach(update => { + Y.applyUpdate(this.ydoc, update.data); + }); + return this.getEncodedDocUpdates(); + })(); + } + + // release resources + destroy = () => { + this._db?.close(); + this.ydoc.destroy(); + }; + + reconnectDB = async () => { + logger.log('open db', this.path); + if (this._db) { + const _db = this._db; + await new Promise(res => + _db.close(() => { + res(); + }) + ); + } + return (this.sqliteDB$ = new Promise(res => { + // use cached version? + const db = new sqlite3.Database(this.path, error => { + if (error) { + logger.error('open db error', error); + } + }); + this._db = db; + + db.exec(schemas.join(';'), () => { + res(db); + }); + })); + }; + + getEncodedDocUpdates = () => { + return Y.encodeStateAsUpdate(this.ydoc); + }; + + // non-blocking and use yDoc to validate the update + // 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 + }; + + addBlob = async (key: string, data: Uint8Array) => { + const db = await this.sqliteDB$; + return new Promise((resolve, reject) => { + db.run( + 'INSERT INTO blobs (key, data) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET data = ?', + [key, data, data], + err => { + if (err) { + logger.error('addBlob', err); + reject(err); + } else { + resolve(); + } + } + ); + }); + }; + + getBlob = async (key: string) => { + const db = await this.sqliteDB$; + return new Promise((resolve, reject) => { + db.get( + 'SELECT data FROM blobs WHERE key = ?', + [key], + (err, row) => { + if (err) { + logger.error('getBlob', err); + reject(err); + } else if (!row) { + logger.error('getBlob', 'not found'); + resolve(null); + } else { + resolve(row.data); + } + } + ); + }); + }; + + deleteBlob = async (key: string) => { + const db = await this.sqliteDB$; + return new Promise((resolve, reject) => { + db.run('DELETE FROM blobs WHERE key = ?', [key], err => { + if (err) { + logger.error('deleteBlob', err); + reject(err); + } else { + resolve(); + } + }); + }); + }; + + getPersistentBlobKeys = async () => { + const db = await this.sqliteDB$; + return new Promise((resolve, reject) => { + db.all('SELECT key FROM blobs', (err, rows) => { + if (err) { + logger.error('getPersistentBlobKeys', err); + reject(err); + } else { + resolve(rows.map(row => row.key)); + } + }); + }); + }; + + private getUpdates = async () => { + const db = await this.sqliteDB$; + return new Promise<{ id: number; data: any }[]>((resolve, reject) => { + // do we need to order by id? + db.all('SELECT * FROM updates', (err, rows) => { + if (err) { + logger.error('getUpdates', err); + reject(err); + } else { + resolve(rows); + } + }); + }); + }; + + private addUpdateToSQLite = async (data: Uint8Array) => { + const db = await this.sqliteDB$; + return new Promise((resolve, reject) => { + db.run('INSERT INTO updates (data) VALUES (?)', [data], err => { + if (err) { + logger.error('addUpdateToSQLite', err); + reject(err); + } else { + resolve(); + } + }); + }); + }; +} + +export async function openWorkspaceDatabase( + 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); +} diff --git a/apps/electron/layers/main/src/data/workspace.ts b/apps/electron/layers/main/src/data/workspace.ts new file mode 100644 index 0000000000..4c70d85fc5 --- /dev/null +++ b/apps/electron/layers/main/src/data/workspace.ts @@ -0,0 +1,20 @@ +import path from 'node:path'; + +import fs from 'fs-extra'; + +import type { AppContext } from '../context'; + +export async function listWorkspaces(context: AppContext) { + const basePath = path.join(context.appDataPath, 'workspaces'); + return fs.readdir(basePath); +} + +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}` + ); + return fs.move(basePath, movedPath); +} diff --git a/apps/electron/layers/main/src/app-state/google-auth.ts b/apps/electron/layers/main/src/google-auth.ts similarity index 100% rename from apps/electron/layers/main/src/app-state/google-auth.ts rename to apps/electron/layers/main/src/google-auth.ts diff --git a/apps/electron/layers/main/src/handlers.ts b/apps/electron/layers/main/src/handlers.ts new file mode 100644 index 0000000000..1a4c4ed028 --- /dev/null +++ b/apps/electron/layers/main/src/handlers.ts @@ -0,0 +1,184 @@ +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 type { WorkspaceDatabase } from './data/sqlite'; +import { openWorkspaceDatabase } from './data/sqlite'; +import { deleteWorkspace, listWorkspaces } from './data/workspace'; +import { getExchangeTokenParams, oauthEndpoint } from './google-auth'; + +let currentWorkspaceId = ''; + +const dbMapping = new Map(); + +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); + } + await workspaceDB.ready; + return workspaceDB; +} + +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; + }); +} + +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/index.ts b/apps/electron/layers/main/src/index.ts index e9399c6b7c..9bd5e05d47 100644 --- a/apps/electron/layers/main/src/index.ts +++ b/apps/electron/layers/main/src/index.ts @@ -3,7 +3,8 @@ import './security-restrictions'; import { app } from 'electron'; import path from 'path'; -import { registerHandlers } from './app-state'; +import { logger } from '../../logger'; +import { registerHandlers } from './handlers'; import { restoreOrCreateWindow } from './main-window'; import { registerProtocol } from './protocol'; @@ -22,6 +23,7 @@ if (process.defaultApp) { */ const isSingleInstance = app.requestSingleInstanceLock(); if (!isSingleInstance) { + logger.info('Another instance is running, exiting...'); app.quit(); process.exit(0); } diff --git a/apps/electron/layers/main/src/main-window.ts b/apps/electron/layers/main/src/main-window.ts index aed7fa1f3e..cc750d9ba7 100644 --- a/apps/electron/layers/main/src/main-window.ts +++ b/apps/electron/layers/main/src/main-window.ts @@ -2,6 +2,7 @@ import { BrowserWindow, nativeTheme } from 'electron'; import electronWindowState from 'electron-window-state'; import { join } from 'path'; +import { logger } from '../../logger'; import { isMacOS } from '../../utils'; const IS_DEV = process.env.NODE_ENV === 'development'; @@ -87,7 +88,7 @@ export async function restoreOrCreateWindow() { browserWindow.restore(); } - browserWindow.focus(); + logger.info('Create main window'); return browserWindow; } diff --git a/apps/electron/layers/preload/preload.d.ts b/apps/electron/layers/preload/preload.d.ts index 2493e6d032..3fa7cba22d 100644 --- a/apps/electron/layers/preload/preload.d.ts +++ b/apps/electron/layers/preload/preload.d.ts @@ -7,6 +7,6 @@ interface Window { * * @see https://github.com/cawa-93/dts-for-context-bridge */ - readonly apis: { workspaceSync: (id: string) => Promise; onThemeChange: (theme: string) => Promise; onSidebarVisibilityChange: (visible: boolean) => Promise; getGoogleOauthCode: () => Promise<{ requestInit: RequestInit; url: string; }>; updateEnv: (env: string, value: string) => void; }; + readonly apis: { db: { getDoc: (id: string) => Promise; applyDocUpdate: (id: string, update: Uint8Array) => Promise; addBlob: (workspaceId: string, key: string, data: Uint8Array) => Promise; getBlob: (workspaceId: string, key: string) => Promise; deleteBlob: (workspaceId: string, key: string) => Promise; getPersistedBlobs: (workspaceId: string) => Promise; }; workspace: { list: () => Promise; delete: (id: string) => Promise; }; openLoadDBFileDialog: () => Promise; openSaveDBFileDialog: () => Promise; onThemeChange: (theme: string) => Promise; onSidebarVisibilityChange: (visible: boolean) => Promise; onWorkspaceChange: (workspaceId: string) => Promise; openDBFolder: () => Promise; getGoogleOauthCode: () => Promise<{ requestInit: RequestInit; url: string; }>; updateEnv: (env: string, value: string) => void; }; readonly appInfo: { electron: boolean; isMacOS: boolean; }; } diff --git a/apps/electron/layers/preload/src/index.ts b/apps/electron/layers/preload/src/index.ts index bb808c9c94..d0da5d54ac 100644 --- a/apps/electron/layers/preload/src/index.ts +++ b/apps/electron/layers/preload/src/index.ts @@ -22,7 +22,32 @@ import { isMacOS } from '../../utils'; * @see https://github.com/cawa-93/dts-for-context-bridge */ contextBridge.exposeInMainWorld('apis', { - workspaceSync: (id: string) => ipcRenderer.invoke('octo:workspace-sync', id), + db: { + // TODO: do we need to store the workspace list locally? + // 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), + }, + + 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) => @@ -31,6 +56,11 @@ contextBridge.exposeInMainWorld('apis', { 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 diff --git a/apps/electron/package.json b/apps/electron/package.json index 05db23af3b..b36ad2eb88 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -31,6 +31,8 @@ "@electron-forge/shared-types": "^6.1.1", "@electron/rebuild": "^3.2.12", "@electron/remote": "2.0.9", + "@types/fs-extra": "^11.0.1", + "cross-env": "7.0.3", "dts-for-context-bridge": "^0.7.1", "electron": "24.1.2", "electron-squirrel-startup": "1.0.0", @@ -38,11 +40,12 @@ "zx": "^7.2.1" }, "dependencies": { - "cross-env": "7.0.3", + "electron-log": "^5.0.0-beta.22", "electron-window-state": "^5.0.3", - "firebase": "^9.19.1", "fs-extra": "^11.1.1", - "undici": "^5.21.2" + "sqlite3": "^5.1.6", + "undici": "^5.21.2", + "yjs": "^13.5.53" }, "build": { "protocols": [ @@ -54,6 +57,8 @@ } ] }, - "packageManager": "yarn@3.5.0", - "stableVersion": "0.5.3" + "stableVersion": "0.5.3", + "installConfig": { + "hoistingLimits": "workspaces" + } } diff --git a/apps/electron/resources/icons/dmg-background.png b/apps/electron/resources/icons/dmg-background.png new file mode 100644 index 0000000000..166a6e16ec Binary files /dev/null and b/apps/electron/resources/icons/dmg-background.png differ diff --git a/apps/electron/resources/icons/dmg-background@2x.png b/apps/electron/resources/icons/dmg-background@2x.png new file mode 100644 index 0000000000..543f462895 Binary files /dev/null and b/apps/electron/resources/icons/dmg-background@2x.png differ diff --git a/apps/electron/scripts/common.mjs b/apps/electron/scripts/common.mjs index 1c1bed7350..0ae7225e50 100644 --- a/apps/electron/scripts/common.mjs +++ b/apps/electron/scripts/common.mjs @@ -1,4 +1,3 @@ - const NODE_MAJOR_VERSION = 18; const nativeNodeModulesPlugin = { @@ -29,7 +28,7 @@ export default () => { bundle: true, target: `node${NODE_MAJOR_VERSION}`, platform: 'node', - external: ['electron'], + external: ['electron', 'sqlite3'], plugins: [nativeNodeModulesPlugin], define: define, }, diff --git a/apps/electron/scripts/generate-assets.mjs b/apps/electron/scripts/generate-assets.mjs index 98c036a46d..38f6421381 100644 --- a/apps/electron/scripts/generate-assets.mjs +++ b/apps/electron/scripts/generate-assets.mjs @@ -33,39 +33,44 @@ if (process.platform === 'win32') { $.shell = 'powershell.exe'; $.prefix = ''; } -// step 1: build web (nextjs) dist -process.env.ENABLE_LEGACY_PROVIDER = 'false'; + cd(repoRootDir); -await $`yarn add`; -await $`yarn build`; -await $`yarn export`; -// step 1.5: amend sourceMappingURL to allow debugging in devtools -await glob('**/*.{js,css}', { cwd: affineWebOutDir }).then(files => { - return files.map(async file => { - const dir = path.dirname(file); - const fullpath = path.join(affineWebOutDir, file); - let content = await fs.readFile(fullpath, 'utf-8'); - // replace # sourceMappingURL=76-6370cd185962bc89.js.map - // to # sourceMappingURL=assets://./{dir}/76-6370cd185962bc89.js.map - content = content.replace(/# sourceMappingURL=(.*)\.map/g, (_, p1) => { - return `# sourceMappingURL=assets://./${dir}/${p1}.map`; - }); - await fs.writeFile(fullpath, content); - }); -}); - -await fs.move(affineWebOutDir, publicAffineOutDir, { overwrite: true }); - -// step 2: build electron resources +// step 1: build electron resources await buildLayers(); echo('Build layers done'); +// step 2: build web (nextjs) dist +if (!process.env.SKIP_WEB_BUILD) { + process.env.ENABLE_LEGACY_PROVIDER = 'false'; + await $`yarn build`; + await $`yarn export`; + + // step 1.5: amend sourceMappingURL to allow debugging in devtools + await glob('**/*.{js,css}', { cwd: affineWebOutDir }).then(files => { + return files.map(async file => { + const dir = path.dirname(file); + const fullpath = path.join(affineWebOutDir, file); + let content = await fs.readFile(fullpath, 'utf-8'); + // replace # sourceMappingURL=76-6370cd185962bc89.js.map + // to # sourceMappingURL=assets://./{dir}/76-6370cd185962bc89.js.map + content = content.replace(/# sourceMappingURL=(.*)\.map/g, (_, p1) => { + return `# sourceMappingURL=assets://./${dir}/${p1}.map`; + }); + await fs.writeFile(fullpath, content); + }); + }); + + await fs.move(affineWebOutDir, publicAffineOutDir, { overwrite: true }); +} + /// -------- /// -------- /// -------- async function cleanup() { - await fs.emptyDir(publicAffineOutDir); + if (!process.env.SKIP_WEB_BUILD) { + await fs.emptyDir(publicAffineOutDir); + } await fs.emptyDir(path.join(electronRootDir, 'layers', 'main', 'dist')); await fs.emptyDir(path.join(electronRootDir, 'layers', 'preload', 'dist')); await fs.remove(path.join(electronRootDir, 'out')); diff --git a/apps/web/src/atoms/index.ts b/apps/web/src/atoms/index.ts index 89b35f7187..1610ac804e 100644 --- a/apps/web/src/atoms/index.ts +++ b/apps/web/src/atoms/index.ts @@ -7,6 +7,7 @@ import { rootCurrentWorkspaceIdAtom, rootWorkspacesMetadataAtom, } from '@affine/workspace/atom'; +import { WorkspaceFlavour } from '@affine/workspace/type'; import type { Page } from '@blocksuite/store'; import { atom } from 'jotai'; @@ -46,6 +47,21 @@ rootWorkspacesMetadataAtom.onMount = setAtom => { } return metadata; }); + + if (environment.isDesktop) { + window.apis.workspace.list().then(workspaceIDs => { + const newMetadata = workspaceIDs.map(w => ({ + id: w, + flavour: WorkspaceFlavour.LOCAL, + })); + setAtom(metadata => { + return [ + ...metadata, + ...newMetadata.filter(m => !metadata.find(m2 => m2.id === m.id)), + ]; + }); + }); + } }; /** 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 329c2e70bf..0c6ae934b0 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 @@ -6,7 +6,14 @@ export const ExportPanel = () => { return ( <> {t('Export Description')} - diff --git a/apps/web/src/components/affine/workspace-setting-detail/panel/general/index.tsx b/apps/web/src/components/affine/workspace-setting-detail/panel/general/index.tsx index 019412a16f..9ac5dc943e 100644 --- a/apps/web/src/components/affine/workspace-setting-detail/panel/general/index.tsx +++ b/apps/web/src/components/affine/workspace-setting-detail/panel/general/index.tsx @@ -158,7 +158,13 @@ export const GeneralPanel: React.FC = ({ {/* */} {/*)}*/} - + { + if (environment.isDesktop) { + window.apis.openDBFolder(); + } + }} + > {t('Workspace Type')} {isOwner ? ( workspace.flavour === WorkspaceFlavour.LOCAL ? ( diff --git a/apps/web/src/components/blocksuite/workspace-header/header-right-items/theme-mode-switch/index.tsx b/apps/web/src/components/blocksuite/workspace-header/header-right-items/theme-mode-switch/index.tsx index 5a9df22cf5..b97414a259 100644 --- a/apps/web/src/components/blocksuite/workspace-header/header-right-items/theme-mode-switch/index.tsx +++ b/apps/web/src/components/blocksuite/workspace-header/header-right-items/theme-mode-switch/index.tsx @@ -7,7 +7,9 @@ export const ThemeModeSwitch = () => { const { setTheme, resolvedTheme } = useTheme(); useEffect(() => { - window.apis?.onThemeChange(resolvedTheme === 'dark' ? 'dark' : 'light'); + if (environment.isDesktop) { + window.apis?.onThemeChange(resolvedTheme === 'dark' ? 'dark' : 'light'); + } }, [resolvedTheme]); const [isHover, setIsHover] = useState(false); diff --git a/apps/web/src/components/pure/workspace-slider-bar/index.tsx b/apps/web/src/components/pure/workspace-slider-bar/index.tsx index 802695ace9..0fd81ca42b 100644 --- a/apps/web/src/components/pure/workspace-slider-bar/index.tsx +++ b/apps/web/src/components/pure/workspace-slider-bar/index.tsx @@ -95,7 +95,9 @@ export const WorkSpaceSliderBar: React.FC = ({ const show = isPublicWorkspace ? false : sidebarOpen; const actualWidth = floatingSlider ? 'calc(10vw + 400px)' : sliderWidth; useEffect(() => { - window.apis?.onSidebarVisibilityChange(sidebarOpen); + if (environment.isDesktop) { + window.apis?.onSidebarVisibilityChange(sidebarOpen); + } }, [sidebarOpen]); useEffect(() => { diff --git a/apps/web/src/hooks/use-sync-router-with-current-workspace-id.ts b/apps/web/src/hooks/use-sync-router-with-current-workspace-id.ts index 75c17a8295..fe908a1d3c 100644 --- a/apps/web/src/hooks/use-sync-router-with-current-workspace-id.ts +++ b/apps/web/src/hooks/use-sync-router-with-current-workspace-id.ts @@ -28,6 +28,9 @@ export function useSyncRouterWithCurrentWorkspaceId(router: NextRouter) { if (targetWorkspace) { console.log('set workspace id', workspaceId); setCurrentWorkspaceId(targetWorkspace.id); + if (environment.isDesktop) { + window.apis?.onWorkspaceChange(targetWorkspace.id); + } void router.push({ pathname: '/workspace/[workspaceId]/all', query: { diff --git a/packages/i18n/src/resources/en.json b/packages/i18n/src/resources/en.json index a885ff99ef..209aa4b203 100644 --- a/packages/i18n/src/resources/en.json +++ b/packages/i18n/src/resources/en.json @@ -8,7 +8,7 @@ "It takes up more space on your device": { "": "It takes up more space on your device." }, - "Export AFFiNE backup file": "Export AFFiNE backup file (coming soon)", + "Export AFFiNE backup file": "Export AFFiNE backup file", "Saved then enable AFFiNE Cloud": "All changes are saved locally, click to enable AFFiNE Cloud.", "Not now": "Not now", "Export Description": "You can export the entire Workspace data for backup, and the exported data can be re-imported.", diff --git a/packages/i18n/src/resources/zh-Hans.json b/packages/i18n/src/resources/zh-Hans.json index fa8a755ed8..57d3188421 100644 --- a/packages/i18n/src/resources/zh-Hans.json +++ b/packages/i18n/src/resources/zh-Hans.json @@ -178,7 +178,7 @@ "Owner": "所有者", "Published to Web": "公开到互联网", "Data sync mode": "数据同步模式", - "Export AFFiNE backup file": "导出 AFFiNE 备份文件(即将到来)", + "Export AFFiNE backup file": "导出 AFFiNE 备份文件", "Export Description": "您可以导出整个工作区数据进行备份,导出的数据可以重新被导入。", "It takes up little space on your device": { "": "此操作会在你的设备上占用少许空间。" diff --git a/packages/workspace/src/atom.ts b/packages/workspace/src/atom.ts index 9e49a3a465..ca4acf818c 100644 --- a/packages/workspace/src/atom.ts +++ b/packages/workspace/src/atom.ts @@ -1,9 +1,10 @@ import { atomWithSyncStorage } from '@affine/jotai'; -import type { WorkspaceFlavour } from '@affine/workspace/type'; import type { EditorContainer } from '@blocksuite/editor'; import { atom, createStore } from 'jotai'; import { atomWithStorage, createJSONStorage } from 'jotai/utils'; +import type { WorkspaceFlavour } from './type'; + export type RootWorkspaceMetadata = { id: string; flavour: WorkspaceFlavour; @@ -45,5 +46,13 @@ export const rootCurrentEditorAtom = atom | null>( ); //#endregion +const getStorage = () => createJSONStorage(() => localStorage); + +export const getStoredWorkspaceMeta = () => { + const storage = getStorage(); + const data = storage.getItem('jotai-workspaces') as RootWorkspaceMetadata[]; + return data; +}; + // global store export const rootStore = createStore(); diff --git a/packages/workspace/src/blob/sqlite-blob-storage.ts b/packages/workspace/src/blob/sqlite-blob-storage.ts new file mode 100644 index 0000000000..4c01975939 --- /dev/null +++ b/packages/workspace/src/blob/sqlite-blob-storage.ts @@ -0,0 +1,25 @@ +import type { BlobStorage } from '@blocksuite/store'; + +export const createSQLiteStorage = (workspaceId: string): BlobStorage => { + return { + crud: { + get: async (key: string) => { + const buffer = await window.apis.db.getBlob(workspaceId, key); + return buffer ? new Blob([buffer]) : null; + }, + set: async (key: string, value: Blob) => { + return window.apis.db.addBlob( + workspaceId, + key, + new Uint8Array(await value.arrayBuffer()) + ); + }, + delete: async (key: string) => { + return window.apis.db.deleteBlob(workspaceId, key); + }, + list: async () => { + return window.apis.db.getPersistedBlobs(workspaceId); + }, + }, + }; +}; diff --git a/packages/workspace/src/local/crud.ts b/packages/workspace/src/local/crud.ts index c945ef2542..1d1059ffcd 100644 --- a/packages/workspace/src/local/crud.ts +++ b/packages/workspace/src/local/crud.ts @@ -85,18 +85,32 @@ export const CRUD: WorkspaceCRUD = { } data.splice(idx, 1); storage.setItem(kStoreKey, [...data]); + // flywire + if (window.apis && environment.isDesktop) { + await window.apis.workspace.delete(workspace.id); + } }, list: async () => { logger.debug('list'); const storage = getStorage(); - !Array.isArray(storage.getItem(kStoreKey)) && - storage.setItem(kStoreKey, []); - return ( - await Promise.all( - (storage.getItem(kStoreKey) as z.infer).map(id => - CRUD.get(id) - ) - ) + let allWorkspaceIDs: string[] = Array.isArray(storage.getItem(kStoreKey)) + ? (storage.getItem(kStoreKey) as z.infer) + : []; + + // workspaces in desktop + if (window.apis && environment.isDesktop) { + const desktopIds = await window.apis.workspace.list(); + // the ids maybe a subset of the local storage + const moreWorkspaces = desktopIds.filter( + id => !allWorkspaceIDs.includes(id) + ); + allWorkspaceIDs = [...allWorkspaceIDs, ...moreWorkspaces]; + storage.setItem(kStoreKey, allWorkspaceIDs); + } + const workspaces = ( + await Promise.all(allWorkspaceIDs.map(id => CRUD.get(id))) ).filter(item => item !== null) as LocalWorkspace[]; + + return workspaces; }, }; diff --git a/packages/workspace/src/providers/__tests__/sqlite-provider.spec.ts b/packages/workspace/src/providers/__tests__/sqlite-provider.spec.ts new file mode 100644 index 0000000000..c8f2a8d4f0 --- /dev/null +++ b/packages/workspace/src/providers/__tests__/sqlite-provider.spec.ts @@ -0,0 +1,71 @@ +import type { SQLiteProvider } from '@affine/workspace/type'; +import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models'; +import type { Y as YType } from '@blocksuite/store'; +import { uuidv4, Workspace } from '@blocksuite/store'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { createSQLiteProvider } from '../index'; + +const Y = Workspace.Y; + +let id: string; +let workspace: Workspace; +let provider: SQLiteProvider; + +let offlineYdoc: YType.Doc; + +vi.stubGlobal('window', { + apis: { + db: { + getDoc: async (id: string) => { + return Y.encodeStateAsUpdate(offlineYdoc); + }, + applyDocUpdate: async (id: string, update: Uint8Array) => { + Y.applyUpdate(offlineYdoc, update, 'sqlite'); + }, + getPersistedBlobs: async (id: string) => { + return []; + }, + } satisfies Partial, + }, +}); + +vi.stubGlobal('environment', { + isDesktop: true, +}); + +beforeEach(() => { + id = uuidv4(); + workspace = new Workspace({ + id, + isSSR: true, + }); + workspace.register(AffineSchemas).register(__unstableSchemas); + provider = createSQLiteProvider(workspace); + offlineYdoc = new Y.Doc(); + offlineYdoc.getText('text').insert(0, ''); +}); + +describe('SQLite provider', () => { + test('connect', async () => { + // on connect, the updates from sqlite should be sync'ed to the existing ydoc + // and ydoc should be sync'ed back to sqlite + // Workspace.Y.applyUpdate(workspace.doc); + workspace.doc.getText('text').insert(0, 'mem-hello'); + + expect(offlineYdoc.getText('text').toString()).toBe(''); + + await provider.connect(); + + expect(offlineYdoc.getText('text').toString()).toBe('mem-hello'); + expect(workspace.doc.getText('text').toString()).toBe('mem-hello'); + + workspace.doc.getText('text').insert(0, 'world'); + + // check if the data are sync'ed + expect(offlineYdoc.getText('text').toString()).toBe('worldmem-hello'); + }); + + // todo: test disconnect + // todo: test blob sync +}); diff --git a/packages/workspace/src/providers/index.ts b/packages/workspace/src/providers/index.ts index ddc2ea8a9a..be3e3ed9e7 100644 --- a/packages/workspace/src/providers/index.ts +++ b/packages/workspace/src/providers/index.ts @@ -4,15 +4,13 @@ import { getLoginStorage, storageChangeSlot, } from '@affine/workspace/affine/login'; -import type { Provider } from '@affine/workspace/type'; +import type { Provider, SQLiteProvider } from '@affine/workspace/type'; import type { AffineWebSocketProvider, LocalIndexedDBProvider, } from '@affine/workspace/type'; -import type { - Disposable, - Workspace as BlockSuiteWorkspace, -} from '@blocksuite/store'; +import type { BlobManager, Disposable } from '@blocksuite/store'; +import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; import { assertExists } from '@blocksuite/store'; import { createIndexedDBProvider as create, @@ -20,7 +18,9 @@ import { } from '@toeverything/y-indexeddb'; import { createBroadCastChannelProvider } from './broad-cast-channel'; -import { localProviderLogger } from './logger'; +import { localProviderLogger as logger } from './logger'; + +const Y = BlockSuiteWorkspace.Y; const createAffineWebSocketProvider = ( blockSuiteWorkspace: BlockSuiteWorkspace @@ -54,12 +54,12 @@ const createAffineWebSocketProvider = ( connect: false, } ); - localProviderLogger.info('connect', webSocketProvider.url); + logger.info('connect', webSocketProvider.url); webSocketProvider.connect(); }, disconnect: () => { assertExists(webSocketProvider); - localProviderLogger.info('disconnect', webSocketProvider.url); + logger.info('disconnect', webSocketProvider.url); webSocketProvider.destroy(); webSocketProvider = null; dispose?.dispose(); @@ -119,10 +119,7 @@ const createIndexedDBProvider = ( // todo: cleanup data }, connect: () => { - localProviderLogger.info( - 'connect indexeddb provider', - blockSuiteWorkspace.id - ); + logger.info('connect indexeddb provider', blockSuiteWorkspace.id); indexeddbProvider.connect(); indexeddbProvider.whenSynced .then(() => { @@ -139,20 +136,94 @@ const createIndexedDBProvider = ( }, disconnect: () => { assertExists(indexeddbProvider); - localProviderLogger.info( - 'disconnect indexeddb provider', - blockSuiteWorkspace.id - ); + logger.info('disconnect indexeddb provider', blockSuiteWorkspace.id); indexeddbProvider.disconnect(); callbacks.ready = false; }, }; }; +const createSQLiteProvider = ( + blockSuiteWorkspace: BlockSuiteWorkspace +): SQLiteProvider => { + const sqliteOrigin = Symbol('sqlite-provider-origin'); + // make sure it is being used in Electron with APIs + assertExists(environment.isDesktop && window.apis); + + function handleUpdate(update: Uint8Array, origin: unknown) { + if (origin === sqliteOrigin) { + return; + } + window.apis.db.applyDocUpdate(blockSuiteWorkspace.id, update); + } + + async function syncBlobIntoSQLite(bs: BlobManager) { + const persistedKeys = await window.apis.db.getPersistedBlobs( + blockSuiteWorkspace.id + ); + + const allKeys = await bs.list(); + const keysToPersist = allKeys.filter(k => !persistedKeys.includes(k)); + + logger.info('persisting blobs', keysToPersist, 'to sqlite'); + keysToPersist.forEach(async k => { + const blob = await bs.get(k); + if (!blob) { + logger.warn('blob url not found', k); + return; + } + window.apis.db.addBlob( + blockSuiteWorkspace.id, + k, + new Uint8Array(await blob.arrayBuffer()) + ); + }); + } + + const provider = { + flavour: 'sqlite', + background: true, + cleanup: () => { + throw new Error('Method not implemented.'); + }, + connect: async () => { + logger.info('connecting sqlite provider', blockSuiteWorkspace.id); + const updates = await window.apis.db.getDoc(blockSuiteWorkspace.id); + + if (updates) { + Y.applyUpdate(blockSuiteWorkspace.doc, updates, sqliteOrigin); + } + + const mergeUpdates = Y.encodeStateAsUpdate(blockSuiteWorkspace.doc); + + // also apply updates to sqlite + window.apis.db.applyDocUpdate(blockSuiteWorkspace.id, mergeUpdates); + + blockSuiteWorkspace.doc.on('update', handleUpdate); + + const bs = blockSuiteWorkspace.blobs; + + if (bs) { + // this can be non-blocking + syncBlobIntoSQLite(bs); + } + + // blockSuiteWorkspace.doc.on('destroy', ...); + logger.info('connecting sqlite done', blockSuiteWorkspace.id); + }, + disconnect: () => { + // todo: not implemented + }, + } satisfies SQLiteProvider; + + return provider; +}; + export { createAffineWebSocketProvider, createBroadCastChannelProvider, createIndexedDBProvider, + createSQLiteProvider, }; export const createLocalProviders = ( @@ -163,6 +234,7 @@ export const createLocalProviders = ( config.enableBroadCastChannelProvider && createBroadCastChannelProvider(blockSuiteWorkspace), createIndexedDBProvider(blockSuiteWorkspace), + environment.isDesktop && createSQLiteProvider(blockSuiteWorkspace), ] as any[] ).filter(v => Boolean(v)); }; diff --git a/packages/workspace/src/type.ts b/packages/workspace/src/type.ts index 9b8812bb68..42f8a33ef6 100644 --- a/packages/workspace/src/type.ts +++ b/packages/workspace/src/type.ts @@ -35,6 +35,10 @@ export interface LocalIndexedDBProvider extends BackgroundProvider { whenSynced: Promise; } +export interface SQLiteProvider extends BaseProvider { + flavour: 'sqlite'; +} + export interface AffineWebSocketProvider extends BaseProvider { flavour: 'affine-websocket'; } diff --git a/packages/workspace/src/utils.ts b/packages/workspace/src/utils.ts index ab9d962a1a..17527f849f 100644 --- a/packages/workspace/src/utils.ts +++ b/packages/workspace/src/utils.ts @@ -1,9 +1,10 @@ import type { createWorkspaceApis } from '@affine/workspace/affine/api'; import { createAffineBlobStorage } from '@affine/workspace/blob'; import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models'; -import type { Generator } from '@blocksuite/store'; +import type { Generator, StoreOptions } from '@blocksuite/store'; import { createIndexeddbStorage, Workspace } from '@blocksuite/store'; +import { createSQLiteStorage } from './blob/sqlite-blob-storage'; import { WorkspaceFlavour } from './type'; const hashMap = new Map(); @@ -48,15 +49,26 @@ export function createEmptyBlockSuiteWorkspace( return hashMap.get(cacheKey) as Workspace; } const idGenerator = config?.idGenerator; + + const blobStorages: StoreOptions['blobStorages'] = []; + + if (flavour === WorkspaceFlavour.AFFINE) { + blobStorages.push(id => + createAffineBlobStorage(id, config!.workspaceApis!) + ); + } else { + if (typeof window !== 'undefined') { + blobStorages.push(createIndexeddbStorage); + if (environment.isDesktop) { + blobStorages.push(createSQLiteStorage); + } + } + } + const workspace = new Workspace({ id, isSSR: typeof window === 'undefined', - blobStorages: - flavour === WorkspaceFlavour.AFFINE - ? [id => createAffineBlobStorage(id, config!.workspaceApis!)] - : typeof window === 'undefined' - ? [] - : [createIndexeddbStorage], + blobStorages: blobStorages, idGenerator, }) .register(AffineSchemas) diff --git a/yarn.lock b/yarn.lock index ca4c575a5a..6252e5e06a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -122,15 +122,18 @@ __metadata: "@electron-forge/shared-types": ^6.1.1 "@electron/rebuild": ^3.2.12 "@electron/remote": 2.0.9 + "@types/fs-extra": ^11.0.1 cross-env: 7.0.3 dts-for-context-bridge: ^0.7.1 electron: 24.1.2 + electron-log: ^5.0.0-beta.22 electron-squirrel-startup: 1.0.0 electron-window-state: ^5.0.3 esbuild: ^0.17.17 - firebase: ^9.19.1 fs-extra: ^11.1.1 + sqlite3: ^5.1.6 undici: ^5.21.2 + yjs: ^13.5.53 zx: ^7.2.1 languageName: unknown linkType: soft @@ -3867,7 +3870,7 @@ __metadata: languageName: node linkType: hard -"@gar/promisify@npm:^1.1.3": +"@gar/promisify@npm:^1.0.1, @gar/promisify@npm:^1.1.3": version: 1.1.3 resolution: "@gar/promisify@npm:1.1.3" checksum: 4059f790e2d07bf3c3ff3e0fec0daa8144fe35c1f6e0111c9921bd32106adaa97a4ab096ad7dab1e28ee6a9060083c4d1a4ada42a7f5f3f7a96b8812e2b757c1 @@ -4603,6 +4606,25 @@ __metadata: languageName: node linkType: hard +"@mapbox/node-pre-gyp@npm:^1.0.0": + version: 1.0.10 + resolution: "@mapbox/node-pre-gyp@npm:1.0.10" + dependencies: + detect-libc: ^2.0.0 + https-proxy-agent: ^5.0.0 + make-dir: ^3.1.0 + node-fetch: ^2.6.7 + nopt: ^5.0.0 + npmlog: ^5.0.1 + rimraf: ^3.0.2 + semver: ^7.3.5 + tar: ^6.1.11 + bin: + node-pre-gyp: bin/node-pre-gyp + checksum: 1a98db05d955b74dad3814679593df293b9194853698f3f5f1ed00ecd93128cdd4b14fb8767fe44ac6981ef05c23effcfdc88710e7c1de99ccb6f647890597c8 + languageName: node + linkType: hard + "@mdx-js/react@npm:^2.1.5": version: 2.3.0 resolution: "@mdx-js/react@npm:2.3.0" @@ -5189,6 +5211,16 @@ __metadata: languageName: node linkType: hard +"@npmcli/fs@npm:^1.0.0": + version: 1.1.1 + resolution: "@npmcli/fs@npm:1.1.1" + dependencies: + "@gar/promisify": ^1.0.1 + semver: ^7.3.5 + checksum: f5ad92f157ed222e4e31c352333d0901df02c7c04311e42a81d8eb555d4ec4276ea9c635011757de20cc476755af33e91622838de573b17e52e2e7703f0a9965 + languageName: node + linkType: hard + "@npmcli/fs@npm:^2.1.0": version: 2.1.2 resolution: "@npmcli/fs@npm:2.1.2" @@ -5199,6 +5231,16 @@ __metadata: languageName: node linkType: hard +"@npmcli/move-file@npm:^1.0.1": + version: 1.1.2 + resolution: "@npmcli/move-file@npm:1.1.2" + dependencies: + mkdirp: ^1.0.4 + rimraf: ^3.0.2 + checksum: c96381d4a37448ea280951e46233f7e541058cf57a57d4094dd4bdcaae43fa5872b5f2eb6bfb004591a68e29c5877abe3cdc210cb3588cbf20ab2877f31a7de7 + languageName: node + linkType: hard + "@npmcli/move-file@npm:^2.0.0": version: 2.0.1 resolution: "@npmcli/move-file@npm:2.0.1" @@ -7592,6 +7634,13 @@ __metadata: languageName: unknown linkType: soft +"@tootallnate/once@npm:1": + version: 1.1.2 + resolution: "@tootallnate/once@npm:1.1.2" + checksum: e1fb1bbbc12089a0cb9433dc290f97bddd062deadb6178ce9bcb93bb7c1aecde5e60184bc7065aec42fe1663622a213493c48bbd4972d931aae48315f18e1be9 + languageName: node + linkType: hard + "@tootallnate/once@npm:2": version: 2.0.0 resolution: "@tootallnate/once@npm:2.0.0" @@ -9042,7 +9091,7 @@ __metadata: languageName: node linkType: hard -"agentkeepalive@npm:^4.2.1": +"agentkeepalive@npm:^4.1.3, agentkeepalive@npm:^4.2.1": version: 4.3.0 resolution: "agentkeepalive@npm:4.3.0" dependencies: @@ -10108,6 +10157,32 @@ __metadata: languageName: node linkType: hard +"cacache@npm:^15.2.0": + version: 15.3.0 + resolution: "cacache@npm:15.3.0" + dependencies: + "@npmcli/fs": ^1.0.0 + "@npmcli/move-file": ^1.0.1 + chownr: ^2.0.0 + fs-minipass: ^2.0.0 + glob: ^7.1.4 + infer-owner: ^1.0.4 + lru-cache: ^6.0.0 + minipass: ^3.1.1 + minipass-collect: ^1.0.2 + minipass-flush: ^1.0.5 + minipass-pipeline: ^1.2.2 + mkdirp: ^1.0.3 + p-map: ^4.0.0 + promise-inflight: ^1.0.1 + rimraf: ^3.0.2 + ssri: ^8.0.1 + tar: ^6.0.2 + unique-filename: ^1.1.1 + checksum: a07327c27a4152c04eb0a831c63c00390d90f94d51bb80624a66f4e14a6b6360bbf02a84421267bd4d00ca73ac9773287d8d7169e8d2eafe378d2ce140579db8 + languageName: node + linkType: hard + "cacache@npm:^16.1.0": version: 16.1.3 resolution: "cacache@npm:16.1.3" @@ -11550,7 +11625,7 @@ __metadata: languageName: node linkType: hard -"detect-libc@npm:^2.0.1": +"detect-libc@npm:^2.0.0, detect-libc@npm:^2.0.1": version: 2.0.1 resolution: "detect-libc@npm:2.0.1" checksum: ccb05fcabbb555beb544d48080179c18523a343face9ee4e1a86605a8715b4169f94d663c21a03c310ac824592f2ba9a5270218819bb411ad7be578a527593d7 @@ -11949,6 +12024,13 @@ __metadata: languageName: node linkType: hard +"electron-log@npm:^5.0.0-beta.22": + version: 5.0.0-beta.22 + resolution: "electron-log@npm:5.0.0-beta.22" + checksum: 762bf8524079d6960be9fd186fa8b435e9900c2b8522ba38a760368e710a63f8917daef56ed2524c6678d58b692ae8290761ca00828890cb89c474e8c4a29907 + languageName: node + linkType: hard + "electron-packager@npm:^17.1.1": version: 17.1.1 resolution: "electron-packager@npm:17.1.1" @@ -12079,7 +12161,7 @@ __metadata: languageName: node linkType: hard -"encoding@npm:^0.1.13": +"encoding@npm:^0.1.12, encoding@npm:^0.1.13": version: 0.1.13 resolution: "encoding@npm:0.1.13" dependencies: @@ -14705,6 +14787,17 @@ __metadata: languageName: node linkType: hard +"http-proxy-agent@npm:^4.0.1": + version: 4.0.1 + resolution: "http-proxy-agent@npm:4.0.1" + dependencies: + "@tootallnate/once": 1 + agent-base: 6 + debug: 4 + checksum: c6a5da5a1929416b6bbdf77b1aca13888013fe7eb9d59fc292e25d18e041bb154a8dfada58e223fc7b76b9b2d155a87e92e608235201f77d34aa258707963a82 + languageName: node + linkType: hard + "http-proxy-agent@npm:^5.0.0": version: 5.0.0 resolution: "http-proxy-agent@npm:5.0.0" @@ -17292,7 +17385,7 @@ __metadata: languageName: node linkType: hard -"make-dir@npm:^3.0.0, make-dir@npm:^3.0.2": +"make-dir@npm:^3.0.0, make-dir@npm:^3.0.2, make-dir@npm:^3.1.0": version: 3.1.0 resolution: "make-dir@npm:3.1.0" dependencies: @@ -17332,6 +17425,30 @@ __metadata: languageName: node linkType: hard +"make-fetch-happen@npm:^9.1.0": + version: 9.1.0 + resolution: "make-fetch-happen@npm:9.1.0" + dependencies: + agentkeepalive: ^4.1.3 + cacache: ^15.2.0 + http-cache-semantics: ^4.1.0 + http-proxy-agent: ^4.0.1 + https-proxy-agent: ^5.0.0 + is-lambda: ^1.0.1 + lru-cache: ^6.0.0 + minipass: ^3.1.3 + minipass-collect: ^1.0.2 + minipass-fetch: ^1.3.2 + minipass-flush: ^1.0.5 + minipass-pipeline: ^1.2.4 + negotiator: ^0.6.2 + promise-retry: ^2.0.1 + socks-proxy-agent: ^6.0.0 + ssri: ^8.0.0 + checksum: 0eb371c85fdd0b1584fcfdf3dc3c62395761b3c14658be02620c310305a9a7ecf1617a5e6fb30c1d081c5c8aaf177fa133ee225024313afabb7aa6a10f1e3d04 + languageName: node + linkType: hard + "makeerror@npm:1.0.12": version: 1.0.12 resolution: "makeerror@npm:1.0.12" @@ -17667,6 +17784,21 @@ __metadata: languageName: node linkType: hard +"minipass-fetch@npm:^1.3.2": + version: 1.4.1 + resolution: "minipass-fetch@npm:1.4.1" + dependencies: + encoding: ^0.1.12 + minipass: ^3.1.0 + minipass-sized: ^1.0.3 + minizlib: ^2.0.0 + dependenciesMeta: + encoding: + optional: true + checksum: ec93697bdb62129c4e6c0104138e681e30efef8c15d9429dd172f776f83898471bc76521b539ff913248cc2aa6d2b37b652c993504a51cc53282563640f29216 + languageName: node + linkType: hard + "minipass-fetch@npm:^2.0.3": version: 2.1.2 resolution: "minipass-fetch@npm:2.1.2" @@ -17691,7 +17823,7 @@ __metadata: languageName: node linkType: hard -"minipass-pipeline@npm:^1.2.4": +"minipass-pipeline@npm:^1.2.2, minipass-pipeline@npm:^1.2.4": version: 1.2.4 resolution: "minipass-pipeline@npm:1.2.4" dependencies: @@ -17709,7 +17841,7 @@ __metadata: languageName: node linkType: hard -"minipass@npm:^3.0.0, minipass@npm:^3.1.1, minipass@npm:^3.1.6": +"minipass@npm:^3.0.0, minipass@npm:^3.1.0, minipass@npm:^3.1.1, minipass@npm:^3.1.3, minipass@npm:^3.1.6": version: 3.3.6 resolution: "minipass@npm:3.3.6" dependencies: @@ -17725,7 +17857,7 @@ __metadata: languageName: node linkType: hard -"minizlib@npm:^2.1.1, minizlib@npm:^2.1.2": +"minizlib@npm:^2.0.0, minizlib@npm:^2.1.1, minizlib@npm:^2.1.2": version: 2.1.2 resolution: "minizlib@npm:2.1.2" dependencies: @@ -17934,7 +18066,7 @@ __metadata: languageName: node linkType: hard -"negotiator@npm:0.6.3, negotiator@npm:^0.6.3": +"negotiator@npm:0.6.3, negotiator@npm:^0.6.2, negotiator@npm:^0.6.3": version: 0.6.3 resolution: "negotiator@npm:0.6.3" checksum: b8ffeb1e262eff7968fc90a2b6767b04cfd9842582a9d0ece0af7049537266e7b2506dfb1d107a32f06dd849ab2aea834d5830f7f4d0e5cb7d36e1ae55d021d9 @@ -18070,6 +18202,15 @@ __metadata: languageName: node linkType: hard +"node-addon-api@npm:^4.2.0": + version: 4.3.0 + resolution: "node-addon-api@npm:4.3.0" + dependencies: + node-gyp: latest + checksum: 3de396e23cc209f539c704583e8e99c148850226f6e389a641b92e8967953713228109f919765abc1f4355e801e8f41842f96210b8d61c7dcc10a477002dcf00 + languageName: node + linkType: hard + "node-api-version@npm:^0.1.4": version: 0.1.4 resolution: "node-api-version@npm:0.1.4" @@ -18141,6 +18282,26 @@ __metadata: languageName: node linkType: hard +"node-gyp@npm:8.x": + version: 8.4.1 + resolution: "node-gyp@npm:8.4.1" + dependencies: + env-paths: ^2.2.0 + glob: ^7.1.4 + graceful-fs: ^4.2.6 + make-fetch-happen: ^9.1.0 + nopt: ^5.0.0 + npmlog: ^6.0.0 + rimraf: ^3.0.2 + semver: ^7.3.5 + tar: ^6.1.2 + which: ^2.0.2 + bin: + node-gyp: bin/node-gyp.js + checksum: 341710b5da39d3660e6a886b37e210d33f8282047405c2e62c277bcc744c7552c5b8b972ebc3a7d5c2813794e60cc48c3ebd142c46d6e0321db4db6c92dd0355 + languageName: node + linkType: hard + "node-gyp@npm:^9.0.0, node-gyp@npm:latest": version: 9.3.1 resolution: "node-gyp@npm:9.3.1" @@ -18204,6 +18365,17 @@ __metadata: languageName: node linkType: hard +"nopt@npm:^5.0.0": + version: 5.0.0 + resolution: "nopt@npm:5.0.0" + dependencies: + abbrev: 1 + bin: + nopt: bin/nopt.js + checksum: d35fdec187269503843924e0114c0c6533fb54bbf1620d0f28b4b60ba01712d6687f62565c55cc20a504eff0fbe5c63e22340c3fad549ad40469ffb611b04f2f + languageName: node + linkType: hard + "nopt@npm:^6.0.0": version: 6.0.0 resolution: "nopt@npm:6.0.0" @@ -21057,6 +21229,17 @@ __metadata: languageName: node linkType: hard +"socks-proxy-agent@npm:^6.0.0": + version: 6.2.1 + resolution: "socks-proxy-agent@npm:6.2.1" + dependencies: + agent-base: ^6.0.2 + debug: ^4.3.3 + socks: ^2.6.2 + checksum: 9ca089d489e5ee84af06741135c4b0d2022977dad27ac8d649478a114cdce87849e8d82b7c22b51501a4116e231241592946fc7fae0afc93b65030ee57084f58 + languageName: node + linkType: hard + "socks-proxy-agent@npm:^7.0.0": version: 7.0.0 resolution: "socks-proxy-agent@npm:7.0.0" @@ -21232,6 +21415,35 @@ __metadata: languageName: node linkType: hard +"sqlite3@npm:^5.1.6": + version: 5.1.6 + resolution: "sqlite3@npm:5.1.6" + dependencies: + "@mapbox/node-pre-gyp": ^1.0.0 + node-addon-api: ^4.2.0 + node-gyp: 8.x + tar: ^6.1.11 + peerDependencies: + node-gyp: 8.x + dependenciesMeta: + node-gyp: + optional: true + peerDependenciesMeta: + node-gyp: + optional: true + checksum: ea640628843e37a63dfb4bd2c8429dbd7aab845c1a8204574dca3aac61486ab65bc0abfd99b48f1cead1f783171c6111c0cc4115335d5b95bb0b4eb44db162d5 + languageName: node + linkType: hard + +"ssri@npm:^8.0.0, ssri@npm:^8.0.1": + version: 8.0.1 + resolution: "ssri@npm:8.0.1" + dependencies: + minipass: ^3.1.1 + checksum: bc447f5af814fa9713aa201ec2522208ae0f4d8f3bda7a1f445a797c7b929a02720436ff7c478fb5edc4045adb02b1b88d2341b436a80798734e2494f1067b36 + languageName: node + linkType: hard + "ssri@npm:^9.0.0": version: 9.0.1 resolution: "ssri@npm:9.0.1" @@ -21841,7 +22053,7 @@ __metadata: languageName: node linkType: hard -"tar@npm:^6.0.5, tar@npm:^6.1.11, tar@npm:^6.1.13, tar@npm:^6.1.2": +"tar@npm:^6.0.2, tar@npm:^6.0.5, tar@npm:^6.1.11, tar@npm:^6.1.13, tar@npm:^6.1.2": version: 6.1.13 resolution: "tar@npm:6.1.13" dependencies: @@ -22616,6 +22828,15 @@ __metadata: languageName: node linkType: hard +"unique-filename@npm:^1.1.1": + version: 1.1.1 + resolution: "unique-filename@npm:1.1.1" + dependencies: + unique-slug: ^2.0.0 + checksum: cf4998c9228cc7647ba7814e255dec51be43673903897b1786eff2ac2d670f54d4d733357eb08dea969aa5e6875d0e1bd391d668fbdb5a179744e7c7551a6f80 + languageName: node + linkType: hard + "unique-filename@npm:^2.0.0": version: 2.0.1 resolution: "unique-filename@npm:2.0.1" @@ -22625,6 +22846,15 @@ __metadata: languageName: node linkType: hard +"unique-slug@npm:^2.0.0": + version: 2.0.2 + resolution: "unique-slug@npm:2.0.2" + dependencies: + imurmurhash: ^0.1.4 + checksum: 5b6876a645da08d505dedb970d1571f6cebdf87044cb6b740c8dbb24f0d6e1dc8bdbf46825fd09f994d7cf50760e6f6e063cfa197d51c5902c00a861702eb75a + languageName: node + linkType: hard + "unique-slug@npm:^3.0.0": version: 3.0.0 resolution: "unique-slug@npm:3.0.0" @@ -23900,6 +24130,15 @@ __metadata: languageName: node linkType: hard +"yjs@npm:^13.5.53": + version: 13.5.53 + resolution: "yjs@npm:13.5.53" + dependencies: + lib0: ^0.2.72 + checksum: be04e185d694c0c9de93d15d710d2789587226928dc2e66638ad8c075d825cced96727b43d5c50800ac4ec16120d08a273fa538116f751d48653365877e54422 + languageName: node + linkType: hard + "yn@npm:3.1.1": version: 3.1.1 resolution: "yn@npm:3.1.1"