feat(core): new worker workspace engine (#9257)

This commit is contained in:
EYHN
2025-01-17 00:22:18 +08:00
committed by GitHub
parent 7dc470e7ea
commit a2ffdb4047
219 changed files with 4267 additions and 7194 deletions
+1 -7
View File
@@ -6,12 +6,8 @@ import { configureCommonModules } from '@affine/core/modules';
import { I18nProvider } from '@affine/core/modules/i18n';
import { LifecycleService } from '@affine/core/modules/lifecycle';
import { configureLocalStorageStateStorageImpls } from '@affine/core/modules/storage';
import { configureIndexedDBUserspaceStorageProvider } from '@affine/core/modules/userspace';
import { configureBrowserWorkbenchModule } from '@affine/core/modules/workbench';
import {
configureBrowserWorkspaceFlavours,
configureIndexedDBWorkspaceEngineStorageProvider,
} from '@affine/core/modules/workspace-engine';
import { configureBrowserWorkspaceFlavours } from '@affine/core/modules/workspace-engine';
import { Framework, FrameworkRoot, getCurrentStore } from '@toeverything/infra';
import { Suspense } from 'react';
import { RouterProvider } from 'react-router-dom';
@@ -25,8 +21,6 @@ configureCommonModules(framework);
configureBrowserWorkbenchModule(framework);
configureLocalStorageStateStorageImpls(framework);
configureBrowserWorkspaceFlavours(framework);
configureIndexedDBWorkspaceEngineStorageProvider(framework);
configureIndexedDBUserspaceStorageProvider(framework);
configureMobileModules(framework);
const frameworkProvider = framework.provider();
@@ -12,11 +12,13 @@
"@affine/core": "workspace:*",
"@affine/electron-api": "workspace:*",
"@affine/i18n": "workspace:*",
"@affine/nbstore": "workspace:*",
"@emotion/react": "^11.14.0",
"@sentry/react": "^8.44.0",
"@toeverything/infra": "workspace:*",
"@toeverything/theme": "^1.1.3",
"@vanilla-extract/css": "^1.16.1",
"async-call-rpc": "^6.4.2",
"next-themes": "^0.4.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -19,25 +19,26 @@ import { configureFindInPageModule } from '@affine/core/modules/find-in-page';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { I18nProvider } from '@affine/core/modules/i18n';
import { LifecycleService } from '@affine/core/modules/lifecycle';
import { configureElectronStateStorageImpls } from '@affine/core/modules/storage';
import {
configureElectronStateStorageImpls,
NbstoreProvider,
} from '@affine/core/modules/storage';
import {
ClientSchemeProvider,
PopupWindowProvider,
} from '@affine/core/modules/url';
import { configureSqliteUserspaceStorageProvider } from '@affine/core/modules/userspace';
import {
configureDesktopWorkbenchModule,
WorkbenchService,
} from '@affine/core/modules/workbench';
import { WorkspacesService } from '@affine/core/modules/workspace';
import {
configureBrowserWorkspaceFlavours,
configureSqliteWorkspaceEngineStorageProvider,
} from '@affine/core/modules/workspace-engine';
import { configureBrowserWorkspaceFlavours } from '@affine/core/modules/workspace-engine';
import createEmotionCache from '@affine/core/utils/create-emotion-cache';
import { apis, events } from '@affine/electron-api';
import { WorkerClient } from '@affine/nbstore/worker/client';
import { CacheProvider } from '@emotion/react';
import { Framework, FrameworkRoot, getCurrentStore } from '@toeverything/infra';
import { OpClient } from '@toeverything/infra/op';
import { Suspense } from 'react';
import { RouterProvider } from 'react-router-dom';
@@ -71,14 +72,61 @@ const framework = new Framework();
configureCommonModules(framework);
configureElectronStateStorageImpls(framework);
configureBrowserWorkspaceFlavours(framework);
configureSqliteWorkspaceEngineStorageProvider(framework);
configureSqliteUserspaceStorageProvider(framework);
configureDesktopWorkbenchModule(framework);
configureAppTabsHeaderModule(framework);
configureFindInPageModule(framework);
configureDesktopApiModule(framework);
configureSpellCheckSettingModule(framework);
framework.impl(NbstoreProvider, {
openStore(key, options) {
const { port1: portForOpClient, port2: portForWorker } =
new MessageChannel();
let portFromWorker: MessagePort | null = null;
let portId = crypto.randomUUID();
const handleMessage = (ev: MessageEvent) => {
if (
ev.data.type === 'electron:worker-connect' &&
ev.data.portId === portId
) {
portFromWorker = ev.ports[0];
// connect portForWorker and portFromWorker
portFromWorker.addEventListener('message', ev => {
portForWorker.postMessage(ev.data);
});
portForWorker.addEventListener('message', ev => {
// oxlint-disable-next-line no-non-null-assertion
portFromWorker!.postMessage(ev.data);
});
portForWorker.start();
portFromWorker.start();
}
};
window.addEventListener('message', handleMessage);
// oxlint-disable-next-line no-non-null-assertion
apis!.worker.connectWorker(key, portId).catch(err => {
console.error('failed to connect worker', err);
});
const store = new WorkerClient(new OpClient(portForOpClient), options);
portForOpClient.start();
return {
store,
dispose: () => {
window.removeEventListener('message', handleMessage);
portForOpClient.close();
portForWorker.close();
portFromWorker?.close();
// oxlint-disable-next-line no-non-null-assertion
apis!.worker.disconnectWorker(key, portId).catch(err => {
console.error('failed to disconnect worker', err);
});
},
};
},
});
framework.impl(PopupWindowProvider, p => {
const apis = p.get(DesktopApiService).api;
return {
@@ -0,0 +1,36 @@
import '@affine/core/bootstrap/electron';
import { apis } from '@affine/electron-api';
import { broadcastChannelStorages } from '@affine/nbstore/broadcast-channel';
import { cloudStorages } from '@affine/nbstore/cloud';
import { bindNativeDBApis, sqliteStorages } from '@affine/nbstore/sqlite';
import {
bindNativeDBV1Apis,
sqliteV1Storages,
} from '@affine/nbstore/sqlite/v1';
import {
WorkerConsumer,
type WorkerOps,
} from '@affine/nbstore/worker/consumer';
import { OpConsumer } from '@toeverything/infra/op';
// oxlint-disable-next-line no-non-null-assertion
bindNativeDBApis(apis!.nbstore);
// oxlint-disable-next-line no-non-null-assertion
bindNativeDBV1Apis(apis!.db);
const worker = new WorkerConsumer([
...sqliteStorages,
...sqliteV1Storages,
...broadcastChannelStorages,
...cloudStorages,
]);
window.addEventListener('message', ev => {
if (ev.data.type === 'electron:worker-connect') {
const port = ev.ports[0];
const consumer = new OpConsumer<WorkerOps>(port);
worker.bindConsumer(consumer);
}
});
@@ -0,0 +1,96 @@
import '@affine/core/bootstrap/electron';
import type { ClientHandler } from '@affine/electron-api';
import { broadcastChannelStorages } from '@affine/nbstore/broadcast-channel';
import { cloudStorages } from '@affine/nbstore/cloud';
import { bindNativeDBApis, sqliteStorages } from '@affine/nbstore/sqlite';
import {
bindNativeDBV1Apis,
sqliteV1Storages,
} from '@affine/nbstore/sqlite/v1';
import {
WorkerConsumer,
type WorkerOps,
} from '@affine/nbstore/worker/consumer';
import { OpConsumer } from '@toeverything/infra/op';
import { AsyncCall } from 'async-call-rpc';
const worker = new WorkerConsumer([
...sqliteStorages,
...sqliteV1Storages,
...broadcastChannelStorages,
...cloudStorages,
]);
let activeConnectionCount = 0;
let electronAPIsInitialized = false;
function connectElectronAPIs(port: MessagePort) {
if (electronAPIsInitialized) {
return;
}
electronAPIsInitialized = true;
port.postMessage({ type: '__electron-apis-init__' });
const { promise, resolve } = Promise.withResolvers<MessagePort>();
port.addEventListener('message', event => {
if (event.data.type === '__electron-apis__') {
const [port] = event.ports;
resolve(port);
}
});
const rpc = AsyncCall<Record<string, any>>(null, {
channel: promise.then(p => ({
on(listener) {
p.onmessage = e => {
listener(e.data);
};
p.start();
return () => {
p.onmessage = null;
try {
p.close();
} catch (err) {
console.error('close port error', err);
}
};
},
send(data) {
p.postMessage(data);
},
})),
log: false,
});
const electronAPIs = new Proxy<ClientHandler>(rpc as any, {
get(_, namespace: string) {
return new Proxy(rpc as any, {
get(_, method: string) {
return rpc[`${namespace}:${method}`];
},
});
},
});
bindNativeDBApis(electronAPIs.nbstore);
bindNativeDBV1Apis(electronAPIs.db);
}
(globalThis as any).onconnect = (event: MessageEvent) => {
activeConnectionCount++;
const port = event.ports[0];
port.addEventListener('message', (event: MessageEvent) => {
if (event.data.type === '__close__') {
activeConnectionCount--;
if (activeConnectionCount === 0) {
globalThis.close();
}
}
});
connectElectronAPIs(port);
const consumer = new OpConsumer<WorkerOps>(port);
worker.bindConsumer(consumer);
};
@@ -1,3 +1,12 @@
import '@affine/core/bootstrap/electron';
import '@affine/component/theme';
import './global.css';
import { apis } from '@affine/electron-api';
import { bindNativeDBApis } from '@affine/nbstore/sqlite';
import { bindNativeDBV1Apis } from '@affine/nbstore/sqlite/v1';
// oxlint-disable-next-line no-non-null-assertion
bindNativeDBApis(apis!.nbstore);
// oxlint-disable-next-line no-non-null-assertion
bindNativeDBV1Apis(apis!.db);
@@ -11,7 +11,7 @@ import { configureDesktopApiModule } from '@affine/core/modules/desktop-api';
import { configureI18nModule, I18nProvider } from '@affine/core/modules/i18n';
import {
configureElectronStateStorageImpls,
configureGlobalStorageModule,
configureStorageModule,
} from '@affine/core/modules/storage';
import { configureAppThemeModule } from '@affine/core/modules/theme';
import { Framework, FrameworkRoot } from '@toeverything/infra';
@@ -19,7 +19,7 @@ import { Framework, FrameworkRoot } from '@toeverything/infra';
import * as styles from './app.css';
const framework = new Framework();
configureGlobalStorageModule(framework);
configureStorageModule(framework);
configureElectronStateStorageImpls(framework);
configureAppTabsHeaderModule(framework);
configureAppSidebarModule(framework);
@@ -12,6 +12,7 @@
{ "path": "../../core" },
{ "path": "../../electron-api" },
{ "path": "../../i18n" },
{ "path": "../../../common/nbstore" },
{ "path": "../../../common/infra" },
{ "path": "../../../../tools/utils" }
]
@@ -2,5 +2,6 @@ export const config = {
entry: {
app: './src/index.tsx',
shell: './src/shell/index.tsx',
backgroundWorker: './src/background-worker/index.ts',
},
};
@@ -15,6 +15,7 @@ import {
switchToPreviousTab,
undoCloseTab,
} from '../windows-manager';
import { WorkerManager } from '../worker/pool';
import { applicationMenuSubjects } from './subject';
// Unique id for menuitems
@@ -113,6 +114,21 @@ export function createApplicationMenu() {
showDevTools();
},
},
{
label: 'Open worker devtools',
click: () => {
Menu.buildFromTemplate(
Array.from(WorkerManager.instance.workers.values()).map(item => ({
label: `${item.key}`,
click: () => {
item.browserWindow.webContents.openDevTools({
mode: 'undocked',
});
},
}))
).popup();
},
},
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
@@ -199,7 +215,7 @@ export function createApplicationMenu() {
{
label: 'Learn More',
click: async () => {
// oxlint-disable-next-line
// oxlint-disable-next-line no-var-requires
const { shell } = require('electron');
await shell.openExternal('https://affine.pro/');
},
@@ -220,7 +236,7 @@ export function createApplicationMenu() {
{
label: 'Documentation',
click: async () => {
// oxlint-disable-next-line
// oxlint-disable-next-line no-var-requires
const { shell } = require('electron');
await shell.openExternal(
'https://docs.affine.pro/docs/hello-bonjour-aloha-你好'
@@ -1,4 +1,5 @@
export const mainWindowOrigin = process.env.DEV_SERVER_URL || 'file://.';
export const onboardingViewUrl = `${mainWindowOrigin}${mainWindowOrigin.endsWith('/') ? '' : '/'}onboarding`;
export const shellViewUrl = `${mainWindowOrigin}${mainWindowOrigin.endsWith('/') ? '' : '/'}shell.html`;
export const backgroundWorkerViewUrl = `${mainWindowOrigin}${mainWindowOrigin.endsWith('/') ? '' : '/'}background-worker.html`;
export const customThemeViewUrl = `${mainWindowOrigin}${mainWindowOrigin.endsWith('/') ? '' : '/'}theme-editor`;
@@ -8,6 +8,7 @@ import { getLogFilePath, logger, revealLogFile } from './logger';
import { sharedStorageHandlers } from './shared-storage';
import { uiHandlers } from './ui/handlers';
import { updaterHandlers } from './updater';
import { workerHandlers } from './worker/handlers';
export const debugHandlers = {
revealLogFile: async () => {
@@ -27,6 +28,7 @@ export const allHandlers = {
configStorage: configStorageHandlers,
findInPage: findInPageHandlers,
sharedStorage: sharedStorageHandlers,
worker: workerHandlers,
};
export const registerHandlers = () => {
@@ -25,7 +25,6 @@ import {
import { isMacOS } from '../../shared/utils';
import { beforeAppQuit } from '../cleanup';
import { isDev } from '../config';
import { mainWindowOrigin, shellViewUrl } from '../constants';
import { ensureHelperProcess } from '../helper-process';
import { logger } from '../logger';
@@ -871,9 +870,6 @@ export class WebContentViewsManager {
});
view.webContents.loadURL(shellViewUrl).catch(err => logger.error(err));
if (isDev) {
view.webContents.openDevTools();
}
}
view.webContents.on('destroyed', () => {
@@ -0,0 +1,19 @@
import type { NamespaceHandlers } from '../type';
import { WorkerManager } from './pool';
export const workerHandlers = {
connectWorker: async (e, key: string, portId: string) => {
const { portForRenderer } = await WorkerManager.instance.connectWorker(
key,
portId,
e.sender
);
e.sender.postMessage('worker-connect', { portId }, [portForRenderer]);
return {
portId: portId,
};
},
disconnectWorker: async (_, key: string, portId: string) => {
WorkerManager.instance.disconnectWorker(key, portId);
},
} satisfies NamespaceHandlers;
@@ -0,0 +1,96 @@
import { join } from 'node:path';
import { BrowserWindow, MessageChannelMain, type WebContents } from 'electron';
import { backgroundWorkerViewUrl } from '../constants';
import { ensureHelperProcess } from '../helper-process';
import { logger } from '../logger';
async function getAdditionalArguments() {
const { getExposedMeta } = await import('../exposed');
const mainExposedMeta = getExposedMeta();
const helperProcessManager = await ensureHelperProcess();
const helperExposedMeta = await helperProcessManager.rpc?.getMeta();
return [
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
`--helper-exposed-meta=` + JSON.stringify(helperExposedMeta),
`--window-name=worker`,
];
}
export class WorkerManager {
static readonly instance = new WorkerManager();
workers = new Map<
string,
{ browserWindow: BrowserWindow; ports: Set<string>; key: string }
>();
private async getOrCreateWorker(key: string) {
const additionalArguments = await getAdditionalArguments();
const helperProcessManager = await ensureHelperProcess();
const exists = this.workers.get(key);
if (exists) {
return exists;
} else {
const worker = new BrowserWindow({
width: 1200,
height: 600,
webPreferences: {
preload: join(__dirname, './preload.js'),
additionalArguments: additionalArguments,
},
show: false,
});
let disconnectHelperProcess: (() => void) | null = null;
worker.on('close', e => {
e.preventDefault();
if (worker && !worker.isDestroyed()) {
worker.destroy();
this.workers.delete(key);
disconnectHelperProcess?.();
}
});
worker.loadURL(backgroundWorkerViewUrl).catch(e => {
logger.error('failed to load url', e);
});
worker.webContents.addListener('did-finish-load', () => {
disconnectHelperProcess = helperProcessManager.connectRenderer(
worker.webContents
);
});
const record = { browserWindow: worker, ports: new Set<string>(), key };
this.workers.set(key, record);
return record;
}
}
async connectWorker(
key: string,
portId: string,
bindWebContent: WebContents
) {
bindWebContent.addListener('destroyed', () => {
this.disconnectWorker(key, portId);
});
const worker = await this.getOrCreateWorker(key);
const { port1: portForWorker, port2: portForRenderer } =
new MessageChannelMain();
worker.browserWindow.webContents.postMessage('worker-connect', { portId }, [
portForWorker,
]);
return { portForRenderer, portId };
}
disconnectWorker(key: string, portId: string) {
const worker = this.workers.get(key);
if (worker) {
worker.ports.delete(portId);
if (worker.ports.size === 0) {
worker.browserWindow.destroy();
this.workers.delete(key);
}
}
}
}
@@ -2,11 +2,13 @@ import '@sentry/electron/preload';
import { contextBridge } from 'electron';
import { apis, appInfo, events, requestWebWorkerPort } from './electron-api';
import { apis, appInfo, events } from './electron-api';
import { sharedStorage } from './shared-storage';
import { listenWorkerApis } from './worker';
contextBridge.exposeInMainWorld('__appInfo', appInfo);
contextBridge.exposeInMainWorld('__apis', apis);
contextBridge.exposeInMainWorld('__events', events);
contextBridge.exposeInMainWorld('__sharedStorage', sharedStorage);
contextBridge.exposeInMainWorld('__requestWebWorkerPort', requestWebWorkerPort);
listenWorkerApis();
@@ -248,53 +248,3 @@ export const events = {
...mainAPIs.events,
...helperAPIs.events,
};
/**
* Create MessagePort that can be used by web workers
*
* !!!
* SHOULD ONLY BE USED IN RENDERER PROCESS
* !!!
*/
export function requestWebWorkerPort() {
const ch = new MessageChannel();
const localPort = ch.port1;
const remotePort = ch.port2;
// todo: should be able to let the web worker use the electron APIs directly for better performance
const flattenedAPIs = Object.entries(apis).flatMap(([namespace, api]) => {
return Object.entries(api as any).map(([method, fn]) => [
`${namespace}:${method}`,
fn,
]);
});
AsyncCall(Object.fromEntries(flattenedAPIs), {
channel: createMessagePortChannel(localPort),
log: false,
});
const cleanup = () => {
remotePort.close();
localPort.close();
};
const portId = crypto.randomUUID();
setTimeout(() => {
// @ts-expect-error this function should only be evaluated in the renderer process
window.postMessage(
{
type: 'electron:request-api-port',
portId,
ports: [remotePort],
},
'*',
[remotePort]
);
});
localPort.start();
return { portId, cleanup };
}
@@ -0,0 +1,33 @@
import { ipcRenderer } from 'electron';
export function listenWorkerApis() {
ipcRenderer.on('worker-connect', (ev, data) => {
const portForRenderer = ev.ports[0];
// @ts-expect-error this function should only be evaluated in the renderer process
if (document.readyState === 'complete') {
// @ts-expect-error this function should only be evaluated in the renderer process
window.postMessage(
{
type: 'electron:worker-connect',
portId: data.portId,
},
'*',
[portForRenderer]
);
} else {
// @ts-expect-error this function should only be evaluated in the renderer process
window.addEventListener('load', () => {
// @ts-expect-error this function should only be evaluated in the renderer process
window.postMessage(
{
type: 'electron:worker-connect',
portId: data.portId,
},
'*',
[portForRenderer]
);
});
}
});
}
@@ -24,6 +24,9 @@
9D90BE2B2CCB9876006677DB /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 9D90BE1F2CCB9876006677DB /* config.xml */; };
9D90BE2D2CCB9876006677DB /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 9D90BE222CCB9876006677DB /* Main.storyboard */; };
9D90BE2E2CCB9876006677DB /* public in Resources */ = {isa = PBXBuildFile; fileRef = 9D90BE232CCB9876006677DB /* public */; };
9DEC593B2D3002E70027CEBD /* AffineHttpHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DEC593A2D3002C70027CEBD /* AffineHttpHandler.swift */; };
9DEC593F2D30EFA40027CEBD /* AffineWsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DEC593E2D30EFA40027CEBD /* AffineWsHandler.swift */; };
9DEC59432D323EE40027CEBD /* Mutex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DEC59422D323EE00027CEBD /* Mutex.swift */; };
9DFCD1462D27D1D70028C92B /* libaffine_mobile_native.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 9DFCD1452D27D1D70028C92B /* libaffine_mobile_native.a */; };
C4C413792CBE705D00337889 /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; };
C4C97C7C2D030BE000BC2AD1 /* affine_mobile_native.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C97C6F2D0307B700BC2AD1 /* affine_mobile_native.swift */; };
@@ -52,6 +55,9 @@
9D90BE202CCB9876006677DB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
9D90BE212CCB9876006677DB /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
9D90BE232CCB9876006677DB /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
9DEC593A2D3002C70027CEBD /* AffineHttpHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AffineHttpHandler.swift; sourceTree = "<group>"; };
9DEC593E2D30EFA40027CEBD /* AffineWsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AffineWsHandler.swift; sourceTree = "<group>"; };
9DEC59422D323EE00027CEBD /* Mutex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mutex.swift; sourceTree = "<group>"; };
9DFCD1452D27D1D70028C92B /* libaffine_mobile_native.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libaffine_mobile_native.a; sourceTree = "<group>"; };
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
@@ -156,6 +162,9 @@
9D90BE242CCB9876006677DB /* App */ = {
isa = PBXGroup;
children = (
9DEC59422D323EE00027CEBD /* Mutex.swift */,
9DEC593A2D3002C70027CEBD /* AffineHttpHandler.swift */,
9DEC593E2D30EFA40027CEBD /* AffineWsHandler.swift */,
9D52FC422D26CDB600105D0A /* JSValueContainerExt.swift */,
9D90BE1A2CCB9876006677DB /* Plugins */,
9D90BE1C2CCB9876006677DB /* AppDelegate.swift */,
@@ -331,13 +340,16 @@
9D52FC432D26CDBF00105D0A /* JSValueContainerExt.swift in Sources */,
5075136E2D1925BC00AD60C0 /* IntelligentsPlugin.swift in Sources */,
5075136A2D1924C600AD60C0 /* RootViewController.swift in Sources */,
9DEC593B2D3002E70027CEBD /* AffineHttpHandler.swift in Sources */,
C4C97C7C2D030BE000BC2AD1 /* affine_mobile_native.swift in Sources */,
C4C97C7D2D030BE000BC2AD1 /* affine_mobile_nativeFFI.h in Sources */,
C4C97C7E2D030BE000BC2AD1 /* affine_mobile_nativeFFI.modulemap in Sources */,
E93B276C2CED92B1001409B8 /* NavigationGesturePlugin.swift in Sources */,
9DEC59432D323EE40027CEBD /* Mutex.swift in Sources */,
9D90BE252CCB9876006677DB /* CookieManager.swift in Sources */,
9D90BE262CCB9876006677DB /* CookiePlugin.swift in Sources */,
9D6A85332CCF6DA700DAB35F /* HashcashPlugin.swift in Sources */,
9DEC593F2D30EFA40027CEBD /* AffineWsHandler.swift in Sources */,
9D90BE272CCB9876006677DB /* AffineViewController.swift in Sources */,
9D90BE282CCB9876006677DB /* AppDelegate.swift in Sources */,
);
@@ -0,0 +1,114 @@
//
// RequestUrlSchemeHandler.swift
// App
//
// Created by EYHN on 2025/1/9.
//
import WebKit
enum AffineHttpError: Error {
case invalidOperation(reason: String), invalidState(reason: String)
}
class AffineHttpHandler: NSObject, WKURLSchemeHandler {
func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
urlSchemeTask.stopped = Mutex.init(false)
guard let rawUrl = urlSchemeTask.request.url else {
urlSchemeTask.didFailWithError(AffineHttpError.invalidOperation(reason: "bad request"))
return
}
guard let scheme = rawUrl.scheme else {
urlSchemeTask.didFailWithError(AffineHttpError.invalidOperation(reason: "bad request"))
return
}
let httpProtocol = scheme == "affine-http" ? "http" : "https"
guard let urlComponents = URLComponents(url: rawUrl, resolvingAgainstBaseURL: true) else {
urlSchemeTask.didFailWithError(AffineHttpError.invalidOperation(reason: "bad request"))
return
}
guard let host = urlComponents.host else {
urlSchemeTask.didFailWithError(AffineHttpError.invalidOperation(reason: "bad url"))
return
}
let path = urlComponents.path
let query = urlComponents.query != nil ? "?\(urlComponents.query!)" : ""
guard let targetUrl = URL(string: "\(httpProtocol)://\(host)\(path)\(query)") else {
urlSchemeTask.didFailWithError(AffineHttpError.invalidOperation(reason: "bad url"))
return
}
var request = URLRequest(url: targetUrl);
request.httpMethod = urlSchemeTask.request.httpMethod;
request.httpShouldHandleCookies = true
request.httpBody = urlSchemeTask.request.httpBody
urlSchemeTask.request.allHTTPHeaderFields?.filter({
key, value in
let normalizedKey = key.lowercased()
return normalizedKey == "content-type" ||
normalizedKey == "content-length" ||
normalizedKey == "accept"
}).forEach {
key, value in
request.setValue(value, forHTTPHeaderField: key)
}
URLSession.shared.dataTask(with: request) {
rawData, rawResponse, error in
urlSchemeTask.stopped?.withLock({
if $0 {
return
}
if error != nil {
urlSchemeTask.didFailWithError(error!)
} else {
guard let httpResponse = rawResponse as? HTTPURLResponse else {
urlSchemeTask.didFailWithError(AffineHttpError.invalidState(reason: "bad response"))
return
}
let inheritedHeaders = httpResponse.allHeaderFields.filter({
key, value in
let normalizedKey = (key as? String)?.lowercased()
return normalizedKey == "content-type" ||
normalizedKey == "content-length"
}) as? [String: String] ?? [:]
let newHeaders: [String: String] = [
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "*"
]
guard let response = HTTPURLResponse.init(url: rawUrl, statusCode: httpResponse.statusCode, httpVersion: nil, headerFields: inheritedHeaders.merging(newHeaders, uniquingKeysWith: { (_, newHeaders) in newHeaders })) else {
urlSchemeTask.didFailWithError(AffineHttpError.invalidState(reason: "failed to create response"))
return
}
urlSchemeTask.didReceive(response)
if rawData != nil {
urlSchemeTask.didReceive(rawData!)
}
urlSchemeTask.didFinish()
}
})
}
}
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
urlSchemeTask.stopped?.withLock({
$0 = true
})
}
}
private extension WKURLSchemeTask {
var stopped: Mutex<Bool>? {
get {
return objc_getAssociatedObject(self, &stoppedKey) as? Mutex<Bool> ?? nil
}
set {
objc_setAssociatedObject(self, &stoppedKey, newValue, .OBJC_ASSOCIATION_ASSIGN)
}
}
}
private var stoppedKey = malloc(1)
@@ -13,6 +13,19 @@ class AFFiNEViewController: CAPBridgeViewController {
intelligentsButton.delegate = self
dismissIntelligentsButton()
}
override func webViewConfiguration(for instanceConfiguration: InstanceConfiguration) -> WKWebViewConfiguration {
let configuration = super.webViewConfiguration(for: instanceConfiguration)
return configuration
}
override func webView(with frame: CGRect, configuration: WKWebViewConfiguration) -> WKWebView {
configuration.setURLSchemeHandler(AffineHttpHandler(), forURLScheme: "affine-http")
configuration.setURLSchemeHandler(AffineHttpHandler(), forURLScheme: "affine-https")
configuration.setURLSchemeHandler(AffineWsHandler(), forURLScheme: "affine-ws")
configuration.setURLSchemeHandler(AffineWsHandler(), forURLScheme: "affine-wss")
return super.webView(with: frame, configuration: configuration)
}
override func capacitorDidLoad() {
let plugins: [CAPPlugin] = [
@@ -0,0 +1,197 @@
//
// RequestUrlSchemeHandler.swift
// App
//
// Created by EYHN on 2025/1/9.
//
import WebKit
enum AffineWsError: Error {
case invalidOperation(reason: String), invalidState(reason: String)
}
/**
this custom url scheme handler simulates websocket connection through an http request.
frontend open websocket connections and send messages by sending requests to affine-ws:// or affine-wss://
the handler has two endpoints:
`affine-ws:///open?uuid={uuid}&url={wsUrl}`: open a websocket connection and return received data through the SSE protocol. If the front-end closes the http connection, the websocket connection will also be closed.
`affine-ws:///send?uuid={uuid}`: send the request body data to the websocket connection with the specified uuid.
*/
class AffineWsHandler: NSObject, WKURLSchemeHandler {
var wsTasks: [UUID: URLSessionWebSocketTask] = [:]
func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
urlSchemeTask.stopped = Mutex.init(false)
guard let rawUrl = urlSchemeTask.request.url else {
urlSchemeTask.didFailWithError(AffineWsError.invalidOperation(reason: "bad request"))
return
}
guard let urlComponents = URLComponents(url: rawUrl, resolvingAgainstBaseURL: true) else {
urlSchemeTask.didFailWithError(AffineWsError.invalidOperation(reason: "bad request"))
return
}
let path = urlComponents.path
if path == "/open" {
guard let targetUrlStr = urlComponents.queryItems?.first(where: { $0.name == "url" })?.value else {
urlSchemeTask.didFailWithError(AffineWsError.invalidOperation(reason: "url is request"))
return
}
guard let targetUrl = URL(string: targetUrlStr) else {
urlSchemeTask.didFailWithError(AffineWsError.invalidOperation(reason: "failed to parse url"))
return
}
guard let uuidStr = urlComponents.queryItems?.first(where: { $0.name == "uuid" })?.value else {
urlSchemeTask.didFailWithError(AffineWsError.invalidOperation(reason: "url is request"))
return
}
guard let uuid = UUID(uuidString: uuidStr) else {
urlSchemeTask.didFailWithError(AffineWsError.invalidOperation(reason: "invalid uuid"))
return
}
guard let response = HTTPURLResponse.init(url: rawUrl, statusCode: 200, httpVersion: nil, headerFields: [
"X-Accel-Buffering": "no",
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "*"
]) else {
urlSchemeTask.didFailWithError(AffineHttpError.invalidState(reason: "failed to create response"))
return
}
urlSchemeTask.didReceive(response)
let jsonEncoder = JSONEncoder()
let json = String(data: try! jsonEncoder.encode(["type": "start"]), encoding: .utf8)!
urlSchemeTask.didReceive("data: \(json)\n\n".data(using: .utf8)!)
var request = URLRequest(url: targetUrl);
request.httpShouldHandleCookies = true
let webSocketTask = URLSession.shared.webSocketTask(with: targetUrl)
self.wsTasks[uuid] = webSocketTask
webSocketTask.resume()
urlSchemeTask.wsTask = webSocketTask
var completionHandler: ((Result<URLSessionWebSocketTask.Message, any Error>) -> Void)!
completionHandler = {
let result = $0
urlSchemeTask.stopped?.withLock({
let stopped = $0
if stopped {
return
}
let jsonEncoder = JSONEncoder()
switch result {
case .success(let message):
if case .string(let string) = message {
let json = String(data: try! jsonEncoder.encode(["type": "message", "data": string]), encoding: .utf8)!
urlSchemeTask.didReceive("data: \(json)\n\n".data(using: .utf8)!)
}
case .failure(let error):
let json = String(data: try! jsonEncoder.encode(["type": "error", "error": error.localizedDescription]), encoding: .utf8)!
urlSchemeTask.didReceive("data: \(json)\n\n".data(using: .utf8)!)
urlSchemeTask.didFinish()
}
})
// recursive calls
webSocketTask.receive(completionHandler: completionHandler)
}
webSocketTask.receive(completionHandler: completionHandler)
} else if path == "/send" {
if urlSchemeTask.request.httpMethod != "POST" {
urlSchemeTask.didFailWithError(AffineWsError.invalidOperation(reason: "Method should be POST"))
return
}
guard let uuidStr = urlComponents.queryItems?.first(where: { $0.name == "uuid" })?.value else {
urlSchemeTask.didFailWithError(AffineWsError.invalidOperation(reason: "url is request"))
return
}
guard let uuid = UUID(uuidString: uuidStr) else {
urlSchemeTask.didFailWithError(AffineWsError.invalidOperation(reason: "invalid uuid"))
return
}
guard let ContentType = urlSchemeTask.request.allHTTPHeaderFields?.first(where: {$0.key.lowercased() == "content-type"})?.value else {
urlSchemeTask.didFailWithError(AffineWsError.invalidOperation(reason: "content-type is request"))
return
}
if ContentType != "text/plain" {
urlSchemeTask.didFailWithError(AffineWsError.invalidOperation(reason: "content-type not support"))
return
}
guard let body = urlSchemeTask.request.httpBody else {
urlSchemeTask.didFailWithError(AffineWsError.invalidOperation(reason: "no body"))
return
}
let stringBody = String(decoding: body, as: UTF8.self)
guard let webSocketTask = self.wsTasks[uuid] else {
urlSchemeTask.didFailWithError(AffineWsError.invalidOperation(reason: "connection not found"))
return
}
guard let response = HTTPURLResponse.init(url: rawUrl, statusCode: 200, httpVersion: nil, headerFields: [
"Content-Type": "application/json",
"Cache-Control": "no-cache",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "*"
]) else {
urlSchemeTask.didFailWithError(AffineHttpError.invalidState(reason: "failed to create response"))
return
}
let jsonEncoder = JSONEncoder()
webSocketTask.send(.string(stringBody), completionHandler: {
error in
urlSchemeTask.stopped?.withLock({
if $0 {
return
}
if error != nil {
let json = try! jsonEncoder.encode(["error": error!.localizedDescription])
urlSchemeTask.didReceive(response)
urlSchemeTask.didReceive(json)
} else {
urlSchemeTask.didReceive(response)
urlSchemeTask.didReceive(try! jsonEncoder.encode(["uuid": uuid.uuidString.data(using: .utf8)!]))
urlSchemeTask.didFinish()
}
})
})
}
}
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
urlSchemeTask.stopped?.withLock({
$0 = false
})
urlSchemeTask.wsTask?.cancel(with: .abnormalClosure, reason: "Closed".data(using: .utf8))
}
}
private extension WKURLSchemeTask {
var stopped: Mutex<Bool>? {
get {
return objc_getAssociatedObject(self, &stoppedKey) as? Mutex<Bool> ?? nil
}
set {
objc_setAssociatedObject(self, &stoppedKey, newValue, .OBJC_ASSOCIATION_ASSIGN)
}
}
var wsTask: URLSessionWebSocketTask? {
get {
return objc_getAssociatedObject(self, &wsTaskKey) as? URLSessionWebSocketTask
}
set {
objc_setAssociatedObject(self, &stoppedKey, newValue, .OBJC_ASSOCIATION_ASSIGN)
}
}
}
private var stoppedKey = malloc(1)
private var wsTaskKey = malloc(1)
@@ -0,0 +1,23 @@
//
// Mutex.swift
// App
//
// Created by EYHN on 2025/1/11.
//
import Foundation
final class Mutex<Wrapped>: @unchecked Sendable {
private let lock = NSLock.init()
private var wrapped: Wrapped
init(_ wrapped: Wrapped) {
self.wrapped = wrapped
}
func withLock<R>(_ body: @Sendable (inout Wrapped) throws -> R) rethrows -> R {
self.lock.lock()
defer { self.lock.unlock() }
return try body(&wrapped)
}
}
@@ -30,6 +30,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
CAPPluginMethod(name: "getPeerPulledRemoteClocks", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getPeerPulledRemoteClock", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setPeerPulledRemoteClock", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getPeerPushedClock", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getPeerPushedClocks", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setPeerPushedClock", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "clearClocks", returnType: CAPPluginReturnPromise),
@@ -334,11 +335,14 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
let peer = try call.getStringEnsure("peer")
let docId = try call.getStringEnsure("docId")
let clock = try await docStoragePool.getPeerRemoteClock(universalId: id, peer: peer, docId: docId)
call.resolve([
"docId": clock.docId,
"timestamp": clock.timestamp,
])
if let clock = try await docStoragePool.getPeerRemoteClock(universalId: id, peer: peer, docId: docId) {
call.resolve([
"docId": clock.docId,
"timestamp": clock.timestamp,
])
} else {
call.resolve()
}
} catch {
call.reject("Failed to get peer remote clock, \(error)", nil, error)
@@ -391,11 +395,14 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
let peer = try call.getStringEnsure("peer")
let docId = try call.getStringEnsure("docId")
let clock = try await docStoragePool.getPeerPulledRemoteClock(universalId: id, peer: peer, docId: docId)
call.resolve([
"docId": clock.docId,
"timestamp": clock.timestamp,
])
if let clock = try await docStoragePool.getPeerPulledRemoteClock(universalId: id, peer: peer, docId: docId) {
call.resolve([
"docId": clock.docId,
"timestamp": clock.timestamp,
])
} else {
call.resolve()
}
} catch {
call.reject("Failed to get peer pulled remote clock, \(error)", nil, error)
@@ -424,6 +431,26 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
}
}
@objc func getPeerPushedClock(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let peer = try call.getStringEnsure("peer")
let docId = try call.getStringEnsure("docId")
if let clock = try await docStoragePool.getPeerPushedClock(universalId: id, peer: peer, docId: docId) {
call.resolve([
"docId": clock.docId,
"timestamp": clock.timestamp,
])
} else {
call.resolve()
}
} catch {
call.reject("Failed to get peer pushed clock, \(error)", nil, error)
}
}
}
@objc func getPeerPushedClocks(_ call: CAPPluginCall) {
Task {
do {
@@ -0,0 +1,36 @@
//
// SafeWKURLSchemeTask.swift
// App
//
// Created by EYHN on 2025/1/11.
//
import WebKit
class SafeWKURLSchemeTask: WKURLSchemeTask, NSObject {
var origin: any WKURLSchemeTask
init(origin: any WKURLSchemeTask) {
self.origin = origin
self.request = origin.request
}
var request: URLRequest
func didReceive(_ response: URLResponse) {
<#code#>
}
func didReceive(_ data: Data) {
self.origin.didReceive(<#T##response: URLResponse##URLResponse#>)
}
func didFinish() {
self.origin.didFinish()
}
func didFailWithError(_ error: any Error) {
self.origin.didFailWithError(error)
}
}
File diff suppressed because it is too large Load Diff
@@ -321,6 +321,11 @@ uint64_t uniffi_affine_mobile_native_fn_method_docstoragepool_get_peer_pulled_re
uint64_t uniffi_affine_mobile_native_fn_method_docstoragepool_get_peer_pulled_remote_clocks(void*_Nonnull ptr, RustBuffer universal_id, RustBuffer peer
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_AFFINE_MOBILE_NATIVE_FN_METHOD_DOCSTORAGEPOOL_GET_PEER_PUSHED_CLOCK
#define UNIFFI_FFIDEF_UNIFFI_AFFINE_MOBILE_NATIVE_FN_METHOD_DOCSTORAGEPOOL_GET_PEER_PUSHED_CLOCK
uint64_t uniffi_affine_mobile_native_fn_method_docstoragepool_get_peer_pushed_clock(void*_Nonnull ptr, RustBuffer universal_id, RustBuffer peer, RustBuffer doc_id
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_AFFINE_MOBILE_NATIVE_FN_METHOD_DOCSTORAGEPOOL_GET_PEER_PUSHED_CLOCKS
#define UNIFFI_FFIDEF_UNIFFI_AFFINE_MOBILE_NATIVE_FN_METHOD_DOCSTORAGEPOOL_GET_PEER_PUSHED_CLOCKS
uint64_t uniffi_affine_mobile_native_fn_method_docstoragepool_get_peer_pushed_clocks(void*_Nonnull ptr, RustBuffer universal_id, RustBuffer peer
@@ -759,6 +764,12 @@ uint16_t uniffi_affine_mobile_native_checksum_method_docstoragepool_get_peer_pul
#define UNIFFI_FFIDEF_UNIFFI_AFFINE_MOBILE_NATIVE_CHECKSUM_METHOD_DOCSTORAGEPOOL_GET_PEER_PULLED_REMOTE_CLOCKS
uint16_t uniffi_affine_mobile_native_checksum_method_docstoragepool_get_peer_pulled_remote_clocks(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_AFFINE_MOBILE_NATIVE_CHECKSUM_METHOD_DOCSTORAGEPOOL_GET_PEER_PUSHED_CLOCK
#define UNIFFI_FFIDEF_UNIFFI_AFFINE_MOBILE_NATIVE_CHECKSUM_METHOD_DOCSTORAGEPOOL_GET_PEER_PUSHED_CLOCK
uint16_t uniffi_affine_mobile_native_checksum_method_docstoragepool_get_peer_pushed_clock(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_AFFINE_MOBILE_NATIVE_CHECKSUM_METHOD_DOCSTORAGEPOOL_GET_PEER_PUSHED_CLOCKS
@@ -14,10 +14,10 @@ const config: CapacitorConfig = {
},
plugins: {
CapacitorCookies: {
enabled: true,
enabled: false,
},
CapacitorHttp: {
enabled: true,
enabled: false,
},
Keyboard: {
resize: KeyboardResize.Native,
+1
View File
@@ -26,6 +26,7 @@
"@capacitor/keyboard": "^6.0.3",
"@sentry/react": "^8.44.0",
"@toeverything/infra": "workspace:^",
"async-call-rpc": "^6.4.2",
"next-themes": "^0.4.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+54 -23
View File
@@ -12,23 +12,22 @@ import {
DefaultServerService,
ServersService,
ValidatorProvider,
WebSocketAuthProvider,
} from '@affine/core/modules/cloud';
import { DocsService } from '@affine/core/modules/doc';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { I18nProvider } from '@affine/core/modules/i18n';
import { LifecycleService } from '@affine/core/modules/lifecycle';
import { configureLocalStorageStateStorageImpls } from '@affine/core/modules/storage';
import {
configureLocalStorageStateStorageImpls,
NbstoreProvider,
} from '@affine/core/modules/storage';
import { PopupWindowProvider } from '@affine/core/modules/url';
import { ClientSchemeProvider } from '@affine/core/modules/url/providers/client-schema';
import { configureIndexedDBUserspaceStorageProvider } from '@affine/core/modules/userspace';
import { configureBrowserWorkbenchModule } from '@affine/core/modules/workbench';
import { WorkspacesService } from '@affine/core/modules/workspace';
import {
configureBrowserWorkspaceFlavours,
configureIndexedDBWorkspaceEngineStorageProvider,
} from '@affine/core/modules/workspace-engine';
import { configureBrowserWorkspaceFlavours } from '@affine/core/modules/workspace-engine';
import { I18n } from '@affine/i18n';
import { WorkerClient } from '@affine/nbstore/worker/client';
import {
defaultBlockMarkdownAdapterMatchers,
docLinkBaseURLMiddleware,
@@ -44,16 +43,17 @@ import { Browser } from '@capacitor/browser';
import { Haptics } from '@capacitor/haptics';
import { Keyboard, KeyboardStyle } from '@capacitor/keyboard';
import { Framework, FrameworkRoot, getCurrentStore } from '@toeverything/infra';
import { OpClient } from '@toeverything/infra/op';
import { AsyncCall } from 'async-call-rpc';
import { useTheme } from 'next-themes';
import { Suspense, useEffect } from 'react';
import { RouterProvider } from 'react-router-dom';
import { BlocksuiteMenuConfigProvider } from './bs-menu-config';
import { configureFetchProvider } from './fetch';
import { ModalConfigProvider } from './modal-config';
import { Cookie } from './plugins/cookie';
import { Hashcash } from './plugins/hashcash';
import { Intelligents } from './plugins/intelligents';
import { NbStoreNativeDBApis } from './plugins/nbstore';
import { enableNavigationGesture$ } from './web-navigation-control';
const future = {
@@ -65,9 +65,52 @@ configureCommonModules(framework);
configureBrowserWorkbenchModule(framework);
configureLocalStorageStateStorageImpls(framework);
configureBrowserWorkspaceFlavours(framework);
configureIndexedDBWorkspaceEngineStorageProvider(framework);
configureIndexedDBUserspaceStorageProvider(framework);
configureMobileModules(framework);
framework.impl(NbstoreProvider, {
openStore(_key, options) {
const worker = new Worker(
new URL(
/* webpackChunkName: "nbstore-worker" */ './worker.ts',
import.meta.url
)
);
const { port1: nativeDBApiChannelServer, port2: nativeDBApiChannelClient } =
new MessageChannel();
AsyncCall<typeof NbStoreNativeDBApis>(NbStoreNativeDBApis, {
channel: {
on(listener) {
const f = (e: MessageEvent<any>) => {
listener(e.data);
};
nativeDBApiChannelServer.addEventListener('message', f);
return () => {
nativeDBApiChannelServer.removeEventListener('message', f);
};
},
send(data) {
nativeDBApiChannelServer.postMessage(data);
},
},
log: false,
});
nativeDBApiChannelServer.start();
worker.postMessage(
{
type: 'native-db-api-channel',
port: nativeDBApiChannelClient,
},
[nativeDBApiChannelClient]
);
const client = new WorkerClient(new OpClient(worker), options);
return {
store: client,
dispose: () => {
worker.terminate();
nativeDBApiChannelServer.close();
},
};
},
});
framework.impl(PopupWindowProvider, {
open: (url: string) => {
Browser.open({
@@ -81,18 +124,6 @@ framework.impl(ClientSchemeProvider, {
return 'affine';
},
});
configureFetchProvider(framework);
framework.impl(WebSocketAuthProvider, {
getAuthToken: async url => {
const cookies = await Cookie.getCookies({
url,
});
return {
userId: cookies['affine_user_id'],
token: cookies['affine_session'],
};
},
});
framework.impl(ValidatorProvider, {
async validate(_challenge, resource) {
const res = await Hashcash.hash({ challenge: resource });
-191
View File
@@ -1,191 +0,0 @@
/**
* this file is modified from part of https://github.com/ionic-team/capacitor/blob/74c3e9447e1e32e73f818d252eb12f453d849e8d/ios/Capacitor/Capacitor/assets/native-bridge.js#L466
*
* for support arraybuffer response type
*/
import { RawFetchProvider } from '@affine/core/modules/cloud/provider/fetch';
import { CapacitorHttp } from '@capacitor/core';
import type { Framework } from '@toeverything/infra';
const readFileAsBase64 = (file: File) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const data = reader.result;
if (data === null) {
reject(new Error('Failed to read file'));
} else {
resolve(btoa(data as string));
}
};
reader.onerror = reject;
reader.readAsBinaryString(file);
});
const convertFormData = async (formData: FormData) => {
const newFormData = [];
for (const pair of formData.entries()) {
const [key, value] = pair;
if (value instanceof File) {
const base64File = await readFileAsBase64(value);
newFormData.push({
key,
value: base64File,
type: 'base64File',
contentType: value.type,
fileName: value.name,
});
} else {
newFormData.push({ key, value, type: 'string' });
}
}
return newFormData;
};
const convertBody = async (body: unknown, contentType: string) => {
if (body instanceof ReadableStream || body instanceof Uint8Array) {
let encodedData;
if (body instanceof ReadableStream) {
const reader = body.getReader();
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
const concatenated = new Uint8Array(
chunks.reduce((acc, chunk) => acc + chunk.length, 0)
);
let position = 0;
for (const chunk of chunks) {
concatenated.set(chunk, position);
position += chunk.length;
}
encodedData = concatenated;
} else {
encodedData = body;
}
let data = new TextDecoder().decode(encodedData);
let type;
if (contentType === 'application/json') {
try {
data = JSON.parse(data);
} catch {
// ignore
}
type = 'json';
} else if (contentType === 'multipart/form-data') {
type = 'formData';
} else if (
contentType === null || contentType === void 0
? void 0
: contentType.startsWith('image')
) {
type = 'image';
} else if (contentType === 'application/octet-stream') {
type = 'binary';
} else {
type = 'text';
}
return {
data,
type,
headers: { 'Content-Type': contentType || 'application/octet-stream' },
};
} else if (body instanceof URLSearchParams) {
return {
data: body.toString(),
type: 'text',
};
} else if (body instanceof FormData) {
const formData = await convertFormData(body);
return {
data: formData,
type: 'formData',
};
} else if (body instanceof File) {
const fileData = await readFileAsBase64(body);
return {
data: fileData,
type: 'file',
headers: { 'Content-Type': body.type },
};
}
return { data: body, type: 'json' };
};
function base64ToUint8Array(base64: string) {
const binaryString = atob(base64);
const binaryArray = [...binaryString].map(function (char) {
return char.charCodeAt(0);
});
return new Uint8Array(binaryArray);
}
export function configureFetchProvider(framework: Framework) {
framework.override(RawFetchProvider, {
fetch: async (input, init) => {
const request = new Request(input, init);
const { method } = request;
const tag = `CapacitorHttp fetch ${Date.now()} ${input}`;
console.time(tag);
try {
const { body } = request;
const optionHeaders = Object.fromEntries(request.headers.entries());
const {
data: requestData,
type,
headers,
} = await convertBody(
(init === null || init === void 0 ? void 0 : init.body) ||
body ||
undefined,
optionHeaders['Content-Type'] || optionHeaders['content-type']
);
const accept = optionHeaders['Accept'] || optionHeaders['accept'];
const nativeResponse = await CapacitorHttp.request({
url: request.url,
method: method,
data: requestData,
dataType: type as any,
responseType:
accept === 'application/octet-stream' ? 'arraybuffer' : undefined,
headers: Object.assign(Object.assign({}, headers), optionHeaders),
});
const contentType =
nativeResponse.headers['Content-Type'] ||
nativeResponse.headers['content-type'];
let data =
accept === 'application/octet-stream'
? base64ToUint8Array(nativeResponse.data)
: contentType === null || contentType === void 0
? void 0
: contentType.startsWith('application/json')
? JSON.stringify(nativeResponse.data)
: contentType === 'application/octet-stream'
? base64ToUint8Array(nativeResponse.data)
: nativeResponse.data;
// use null data for 204 No Content HTTP response
if (nativeResponse.status === 204) {
data = null;
}
// intercept & parse response before returning
const response = new Response(new Blob([data], { type: contentType }), {
headers: nativeResponse.headers,
status: nativeResponse.status,
});
/*
* copy url to response, `cordova-plugin-ionic` uses this url from the response
* we need `Object.defineProperty` because url is an inherited getter on the Response
* see: https://stackoverflow.com/a/57382543
* */
Object.defineProperty(response, 'url', {
value: nativeResponse.url,
});
console.timeEnd(tag);
return response;
} catch (error) {
console.timeEnd(tag);
throw error;
}
},
});
}
+9 -9
View File
@@ -1,5 +1,8 @@
import './setup';
import '@affine/component/theme';
import '@affine/core/mobile/styles/mobile.css';
import { bindNativeDBApis } from '@affine/nbstore/sqlite';
import {
init,
reactRouterV6BrowserTracingIntegration,
@@ -15,18 +18,15 @@ import {
} from 'react-router-dom';
import { App } from './app';
import { NbStoreNativeDBApis } from './plugins/nbstore';
bindNativeDBApis(NbStoreNativeDBApis);
// TODO(@L-Sun) Uncomment this when the `show` method implement by `@capacitor/keyboard` in ios
// import './virtual-keyboard';
function main() {
if (BUILD_CONFIG.debug || window.SENTRY_RELEASE) {
// workaround for Capacitor HttpPlugin
// capacitor-http-plugin will replace window.XMLHttpRequest with its own implementation
// but XMLHttpRequest.prototype is not defined which is used by sentry
// see: https://github.com/ionic-team/capacitor/blob/74c3e9447e1e32e73f818d252eb12f453d849e8d/core/native-bridge.ts#L581
if ('CapacitorWebXMLHttpRequest' in window) {
window.XMLHttpRequest.prototype = (
window.CapacitorWebXMLHttpRequest as any
).prototype;
}
// https://docs.sentry.io/platforms/javascript/guides/react/#configure
init({
dsn: process.env.SENTRY_DSN,
@@ -1,6 +0,0 @@
export interface CookiePlugin {
/**
* Returns the screen's current orientation.
*/
getCookies(options: { url: string }): Promise<Record<string, string>>;
}
@@ -1,8 +0,0 @@
import { registerPlugin } from '@capacitor/core';
import type { CookiePlugin } from './definitions';
const Cookie = registerPlugin<CookiePlugin>('Cookie');
export * from './definitions';
export { Cookie };
@@ -70,12 +70,12 @@ export interface NbStorePlugin {
timestamps: number[];
}) => Promise<{ count: number }>;
deleteDoc: (options: { id: string; docId: string }) => Promise<void>;
getDocClocks: (options: { id: string; after?: number | null }) => Promise<
{
getDocClocks: (options: { id: string; after?: number | null }) => Promise<{
clocks: {
docId: string;
timestamp: number;
}[]
>;
}[];
}>;
getDocClock: (options: { id: string; docId: string }) => Promise<
| {
docId: string;
@@ -95,47 +95,47 @@ export interface NbStorePlugin {
getPeerRemoteClocks: (options: {
id: string;
peer: string;
}) => Promise<Array<DocClock>>;
}) => Promise<{ clocks: Array<DocClock> }>;
getPeerRemoteClock: (options: {
id: string;
peer: string;
docId: string;
}) => Promise<DocClock>;
}) => Promise<DocClock | null>;
setPeerRemoteClock: (options: {
id: string;
peer: string;
docId: string;
clock: number;
timestamp: number;
}) => Promise<void>;
getPeerPushedClocks: (options: {
id: string;
peer: string;
}) => Promise<Array<DocClock>>;
}) => Promise<{ clocks: Array<DocClock> }>;
getPeerPushedClock: (options: {
id: string;
peer: string;
docId: string;
}) => Promise<DocClock>;
}) => Promise<DocClock | null>;
setPeerPushedClock: (options: {
id: string;
peer: string;
docId: string;
clock: number;
timestamp: number;
}) => Promise<void>;
getPeerPulledRemoteClocks: (options: {
id: string;
peer: string;
}) => Promise<Array<DocClock>>;
}) => Promise<{ clocks: Array<DocClock> }>;
getPeerPulledRemoteClock: (options: {
id: string;
peer: string;
docId: string;
}) => Promise<DocClock>;
}) => Promise<DocClock | null>;
setPeerPulledRemoteClock: (options: {
id: string;
peer: string;
docId: string;
clock: number;
timestamp: number;
}) => Promise<void>;
clearClocks: (options: { id: string }) => Promise<void>;
}
@@ -96,10 +96,12 @@ export const NbStoreNativeDBApis: NativeDBApis = {
id: string,
after?: Date | undefined | null
): Promise<DocClock[]> {
const clocks = await NbStore.getDocClocks({
id,
after: after?.getTime(),
});
const clocks = (
await NbStore.getDocClocks({
id,
after: after?.getTime(),
})
).clocks;
return clocks.map(c => ({
docId: c.docId,
timestamp: new Date(c.timestamp),
@@ -176,30 +178,30 @@ export const NbStoreNativeDBApis: NativeDBApis = {
id: string,
peer: string
): Promise<DocClock[]> {
const clocks = await NbStore.getPeerRemoteClocks({
id,
peer,
});
const clocks = (
await NbStore.getPeerRemoteClocks({
id,
peer,
})
).clocks;
return clocks.map(c => ({
docId: c.docId,
timestamp: new Date(c.timestamp),
}));
},
getPeerRemoteClock: async function (
id: string,
peer: string,
docId: string
): Promise<DocClock> {
getPeerRemoteClock: async function (id: string, peer: string, docId: string) {
const clock = await NbStore.getPeerRemoteClock({
id,
peer,
docId,
});
return {
docId: clock.docId,
timestamp: new Date(clock.timestamp),
};
return clock
? {
docId: clock.docId,
timestamp: new Date(clock.timestamp),
}
: null;
},
setPeerRemoteClock: async function (
id: string,
@@ -211,17 +213,19 @@ export const NbStoreNativeDBApis: NativeDBApis = {
id,
peer,
docId,
clock: clock.getTime(),
timestamp: clock.getTime(),
});
},
getPeerPulledRemoteClocks: async function (
id: string,
peer: string
): Promise<DocClock[]> {
const clocks = await NbStore.getPeerPulledRemoteClocks({
id,
peer,
});
const clocks = (
await NbStore.getPeerPulledRemoteClocks({
id,
peer,
})
).clocks;
return clocks.map(c => ({
docId: c.docId,
timestamp: new Date(c.timestamp),
@@ -231,16 +235,18 @@ export const NbStoreNativeDBApis: NativeDBApis = {
id: string,
peer: string,
docId: string
): Promise<DocClock> {
) {
const clock = await NbStore.getPeerPulledRemoteClock({
id,
peer,
docId,
});
return {
docId: clock.docId,
timestamp: new Date(clock.timestamp),
};
return clock
? {
docId: clock.docId,
timestamp: new Date(clock.timestamp),
}
: null;
},
setPeerPulledRemoteClock: async function (
id: string,
@@ -252,17 +258,19 @@ export const NbStoreNativeDBApis: NativeDBApis = {
id,
peer,
docId,
clock: clock.getTime(),
timestamp: clock.getTime(),
});
},
getPeerPushedClocks: async function (
id: string,
peer: string
): Promise<DocClock[]> {
const clocks = await NbStore.getPeerPushedClocks({
id,
peer,
});
const clocks = (
await NbStore.getPeerPushedClocks({
id,
peer,
})
).clocks;
return clocks.map(c => ({
docId: c.docId,
timestamp: new Date(c.timestamp),
@@ -272,16 +280,18 @@ export const NbStoreNativeDBApis: NativeDBApis = {
id: string,
peer: string,
docId: string
): Promise<DocClock> {
): Promise<DocClock | null> {
const clock = await NbStore.getPeerPushedClock({
id,
peer,
docId,
});
return {
docId: clock.docId,
timestamp: new Date(clock.timestamp),
};
return clock
? {
docId: clock.docId,
timestamp: new Date(clock.timestamp),
}
: null;
},
setPeerPushedClock: async function (
id: string,
@@ -293,7 +303,7 @@ export const NbStoreNativeDBApis: NativeDBApis = {
id,
peer,
docId,
clock: clock.getTime(),
timestamp: clock.getTime(),
});
},
clearClocks: async function (id: string): Promise<void> {
+189 -4
View File
@@ -1,6 +1,191 @@
import '@affine/core/bootstrap/browser';
import '@affine/component/theme';
import '@affine/core/mobile/styles/mobile.css';
// TODO(@L-Sun) Uncomment this when the `show` method implement by `@capacitor/keyboard` in ios
// import './virtual-keyboard';
/**
* the below code includes the custom fetch and websocket implementation for ios webview.
* should be included in the entry file of the app or webworker.
*/
/*
* we override the browser's fetch function with our custom fetch function to
* overcome the restrictions of cross-domain and third-party cookies in ios webview.
*
* the custom fetch function will convert the request to `affine-http://` or `affine-https://`
* and send the request to the server.
*/
const rawFetch = globalThis.fetch;
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url,
globalThis.location.origin
);
if (url.protocol === 'capacitor:') {
return rawFetch(input, init);
}
if (url.protocol === 'http:') {
url.protocol = 'affine-http:';
}
if (url.protocol === 'https:') {
url.protocol = 'affine-https:';
}
return rawFetch(url, input instanceof Request ? input : init);
};
/**
* we create a custom websocket class to simulate the browser's websocket connection
* through the custom url scheme handler.
*
* to overcome the restrictions of cross-domain and third-party cookies in ios webview,
* the front-end opens a websocket connection and sends a message by sending a request
* to `affine-ws://` or `affine-wss://`.
*
* the scheme has two endpoints:
*
* `affine-ws:///open?uuid={uuid}&url={wsUrl}`: opens a websocket connection and returns
* the received data via the SSE protocol.
* If the front-end closes the http connection, the websocket connection will also be closed.
*
* `affine-ws:///send?uuid={uuid}`: sends the request body data to the websocket connection
* with the specified uuid.
*/
class WrappedWebSocket {
static CLOSED = WebSocket.CLOSED;
static CLOSING = WebSocket.CLOSING;
static CONNECTING = WebSocket.CONNECTING;
static OPEN = WebSocket.OPEN;
readonly isWss: boolean;
readonly uuid = crypto.randomUUID();
readyState: number = WebSocket.CONNECTING;
events: Record<string, ((event: any) => void)[]> = {};
onopen: ((event: any) => void) | undefined = undefined;
onclose: ((event: any) => void) | undefined = undefined;
onerror: ((event: any) => void) | undefined = undefined;
onmessage: ((event: any) => void) | undefined = undefined;
eventSource: EventSource;
constructor(
readonly url: string,
_protocols?: string | string[] // not supported yet
) {
const parsedUrl = new URL(url);
this.isWss = parsedUrl.protocol === 'wss:';
this.eventSource = new EventSource(
`${this.isWss ? 'affine-wss' : 'affine-ws'}:///open?uuid=${this.uuid}&url=${encodeURIComponent(this.url)}`
);
this.eventSource.addEventListener('open', () => {
this.emitOpen(new Event('open'));
});
this.eventSource.addEventListener('error', () => {
this.eventSource.close();
this.emitError(new Event('error'));
this.emitClose(new CloseEvent('close'));
});
this.eventSource.addEventListener('message', data => {
const decodedData = JSON.parse(data.data);
if (decodedData.type === 'message') {
this.emitMessage(
new MessageEvent('message', { data: decodedData.data })
);
}
});
}
send(data: string) {
rawFetch(
`${this.isWss ? 'affine-wss' : 'affine-ws'}:///send?uuid=${this.uuid}`,
{
method: 'POST',
headers: {
'Content-Type': 'text/plain',
},
body: data,
}
).catch(e => {
console.error('Failed to send message', e);
});
}
close() {
this.eventSource.close();
this.emitClose(new CloseEvent('close'));
}
addEventListener(type: string, listener: (event: any) => void) {
this.events[type] = this.events[type] || [];
this.events[type].push(listener);
}
removeEventListener(type: string, listener: (event: any) => void) {
this.events[type] = this.events[type] || [];
this.events[type] = this.events[type].filter(l => l !== listener);
}
private emitOpen(event: Event) {
this.readyState = WebSocket.OPEN;
this.events['open']?.forEach(listener => {
try {
listener(event);
} catch (e) {
console.error(e);
}
});
try {
this.onopen?.(event);
} catch (e) {
console.error(e);
}
}
private emitClose(event: CloseEvent) {
this.readyState = WebSocket.CLOSED;
this.events['close']?.forEach(listener => {
try {
listener(event);
} catch (e) {
console.error(e);
}
});
try {
this.onclose?.(event);
} catch (e) {
console.error(e);
}
}
private emitMessage(event: MessageEvent) {
this.events['message']?.forEach(listener => {
try {
listener(event);
} catch (e) {
console.error(e);
}
});
try {
this.onmessage?.(event);
} catch (e) {
console.error(e);
}
}
private emitError(event: Event) {
this.events['error']?.forEach(listener => {
try {
listener(event);
} catch (e) {
console.error(e);
}
});
try {
this.onerror?.(event);
} catch (e) {
console.error(e);
}
}
}
globalThis.WebSocket = WrappedWebSocket as any;
+52
View File
@@ -0,0 +1,52 @@
import './setup';
import { broadcastChannelStorages } from '@affine/nbstore/broadcast-channel';
import { cloudStorages } from '@affine/nbstore/cloud';
import {
bindNativeDBApis,
type NativeDBApis,
sqliteStorages,
} from '@affine/nbstore/sqlite';
import {
WorkerConsumer,
type WorkerOps,
} from '@affine/nbstore/worker/consumer';
import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op';
import { AsyncCall } from 'async-call-rpc';
globalThis.addEventListener('message', e => {
if (e.data.type === 'native-db-api-channel') {
const port = e.ports[0] as MessagePort;
const rpc = AsyncCall<NativeDBApis>(
{},
{
channel: {
on(listener) {
const f = (e: MessageEvent<any>) => {
listener(e.data);
};
port.addEventListener('message', f);
return () => {
port.removeEventListener('message', f);
};
},
send(data) {
port.postMessage(data);
},
},
}
);
bindNativeDBApis(rpc);
port.start();
}
});
const consumer = new OpConsumer<WorkerOps>(globalThis as MessageCommunicapable);
const worker = new WorkerConsumer([
...sqliteStorages,
...broadcastChannelStorages,
...cloudStorages,
]);
worker.bindConsumer(consumer);
@@ -12,6 +12,7 @@
"@affine/component": "workspace:*",
"@affine/core": "workspace:*",
"@affine/i18n": "workspace:*",
"@affine/nbstore": "workspace:*",
"@blocksuite/affine": "workspace:*",
"@blocksuite/icons": "2.2.2",
"@sentry/react": "^8.44.0",
+44 -9
View File
@@ -6,15 +6,16 @@ import { router } from '@affine/core/mobile/router';
import { configureCommonModules } from '@affine/core/modules';
import { I18nProvider } from '@affine/core/modules/i18n';
import { LifecycleService } from '@affine/core/modules/lifecycle';
import { configureLocalStorageStateStorageImpls } from '@affine/core/modules/storage';
import { PopupWindowProvider } from '@affine/core/modules/url';
import { configureIndexedDBUserspaceStorageProvider } from '@affine/core/modules/userspace';
import { configureBrowserWorkbenchModule } from '@affine/core/modules/workbench';
import {
configureBrowserWorkspaceFlavours,
configureIndexedDBWorkspaceEngineStorageProvider,
} from '@affine/core/modules/workspace-engine';
configureLocalStorageStateStorageImpls,
NbstoreProvider,
} from '@affine/core/modules/storage';
import { PopupWindowProvider } from '@affine/core/modules/url';
import { configureBrowserWorkbenchModule } from '@affine/core/modules/workbench';
import { configureBrowserWorkspaceFlavours } from '@affine/core/modules/workspace-engine';
import { WorkerClient } from '@affine/nbstore/worker/client';
import { Framework, FrameworkRoot, getCurrentStore } from '@toeverything/infra';
import { OpClient } from '@toeverything/infra/op';
import { Suspense } from 'react';
import { RouterProvider } from 'react-router-dom';
@@ -27,9 +28,43 @@ configureCommonModules(framework);
configureBrowserWorkbenchModule(framework);
configureLocalStorageStateStorageImpls(framework);
configureBrowserWorkspaceFlavours(framework);
configureIndexedDBWorkspaceEngineStorageProvider(framework);
configureIndexedDBUserspaceStorageProvider(framework);
configureMobileModules(framework);
framework.impl(NbstoreProvider, {
openStore(key, options) {
if (window.SharedWorker) {
const worker = new SharedWorker(
new URL(
/* webpackChunkName: "nbstore" */ './nbstore.ts',
import.meta.url
),
{ name: key }
);
const client = new WorkerClient(new OpClient(worker.port), options);
worker.port.start();
return {
store: client,
dispose: () => {
worker.port.postMessage({ type: '__close__' });
worker.port.close();
},
};
} else {
const worker = new Worker(
new URL(
/* webpackChunkName: "nbstore" */ './nbstore.ts',
import.meta.url
)
);
const client = new WorkerClient(new OpClient(worker), options);
return {
store: client,
dispose: () => {
worker.terminate();
},
};
}
},
});
framework.impl(PopupWindowProvider, {
open: (target: string) => {
const targetUrl = new URL(target);
+1 -1
View File
@@ -41,7 +41,7 @@ function main() {
}
function mountApp() {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line no-non-null-assertion
const root = document.getElementById('app')!;
createRoot(root).render(
<StrictMode>
@@ -0,0 +1,46 @@
import '@affine/core/bootstrap/browser';
import { broadcastChannelStorages } from '@affine/nbstore/broadcast-channel';
import { cloudStorages } from '@affine/nbstore/cloud';
import { idbStorages } from '@affine/nbstore/idb';
import { idbV1Storages } from '@affine/nbstore/idb/v1';
import {
WorkerConsumer,
type WorkerOps,
} from '@affine/nbstore/worker/consumer';
import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op';
const consumer = new WorkerConsumer([
...idbStorages,
...idbV1Storages,
...broadcastChannelStorages,
...cloudStorages,
]);
if ('onconnect' in globalThis) {
// if in shared worker
let activeConnectionCount = 0;
(globalThis as any).onconnect = (event: MessageEvent) => {
activeConnectionCount++;
const port = event.ports[0];
port.addEventListener('message', (event: MessageEvent) => {
if (event.data.type === '__close__') {
activeConnectionCount--;
if (activeConnectionCount === 0) {
globalThis.close();
}
}
});
const opConsumer = new OpConsumer<WorkerOps>(port);
consumer.bindConsumer(opConsumer);
};
} else {
// if in worker
const opConsumer = new OpConsumer<WorkerOps>(
globalThis as MessageCommunicapable
);
consumer.bindConsumer(opConsumer);
}
@@ -10,6 +10,7 @@
{ "path": "../../component" },
{ "path": "../../core" },
{ "path": "../../i18n" },
{ "path": "../../../common/nbstore" },
{ "path": "../../../../blocksuite/affine/all" },
{ "path": "../../../common/infra" }
]
+1
View File
@@ -12,6 +12,7 @@
"@affine/component": "workspace:*",
"@affine/core": "workspace:*",
"@affine/i18n": "workspace:*",
"@affine/nbstore": "workspace:*",
"@emotion/react": "^11.14.0",
"@sentry/react": "^8.44.0",
"@toeverything/infra": "workspace:*",
+43 -9
View File
@@ -5,17 +5,18 @@ import { configureCommonModules } from '@affine/core/modules';
import { I18nProvider } from '@affine/core/modules/i18n';
import { LifecycleService } from '@affine/core/modules/lifecycle';
import { OpenInAppGuard } from '@affine/core/modules/open-in-app';
import { configureLocalStorageStateStorageImpls } from '@affine/core/modules/storage';
import { PopupWindowProvider } from '@affine/core/modules/url';
import { configureIndexedDBUserspaceStorageProvider } from '@affine/core/modules/userspace';
import { configureBrowserWorkbenchModule } from '@affine/core/modules/workbench';
import {
configureBrowserWorkspaceFlavours,
configureIndexedDBWorkspaceEngineStorageProvider,
} from '@affine/core/modules/workspace-engine';
configureLocalStorageStateStorageImpls,
NbstoreProvider,
} from '@affine/core/modules/storage';
import { PopupWindowProvider } from '@affine/core/modules/url';
import { configureBrowserWorkbenchModule } from '@affine/core/modules/workbench';
import { configureBrowserWorkspaceFlavours } from '@affine/core/modules/workspace-engine';
import createEmotionCache from '@affine/core/utils/create-emotion-cache';
import { WorkerClient } from '@affine/nbstore/worker/client';
import { CacheProvider } from '@emotion/react';
import { Framework, FrameworkRoot, getCurrentStore } from '@toeverything/infra';
import { OpClient } from '@toeverything/infra/op';
import { Suspense } from 'react';
import { RouterProvider } from 'react-router-dom';
@@ -30,8 +31,41 @@ configureCommonModules(framework);
configureBrowserWorkbenchModule(framework);
configureLocalStorageStateStorageImpls(framework);
configureBrowserWorkspaceFlavours(framework);
configureIndexedDBWorkspaceEngineStorageProvider(framework);
configureIndexedDBUserspaceStorageProvider(framework);
framework.impl(NbstoreProvider, {
openStore(key, options) {
if (window.SharedWorker) {
const worker = new SharedWorker(
new URL(
/* webpackChunkName: "nbstore" */ './nbstore.ts',
import.meta.url
),
{ name: key }
);
const client = new WorkerClient(new OpClient(worker.port), options);
return {
store: client,
dispose: () => {
worker.port.postMessage({ type: '__close__' });
worker.port.close();
},
};
} else {
const worker = new Worker(
new URL(
/* webpackChunkName: "nbstore" */ './nbstore.ts',
import.meta.url
)
);
const client = new WorkerClient(new OpClient(worker), options);
return {
store: client,
dispose: () => {
worker.terminate();
},
};
}
},
});
framework.impl(PopupWindowProvider, {
open: (target: string) => {
const targetUrl = new URL(target);
+46
View File
@@ -0,0 +1,46 @@
import '@affine/core/bootstrap/browser';
import { broadcastChannelStorages } from '@affine/nbstore/broadcast-channel';
import { cloudStorages } from '@affine/nbstore/cloud';
import { idbStorages } from '@affine/nbstore/idb';
import { idbV1Storages } from '@affine/nbstore/idb/v1';
import {
WorkerConsumer,
type WorkerOps,
} from '@affine/nbstore/worker/consumer';
import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op';
const consumer = new WorkerConsumer([
...idbStorages,
...idbV1Storages,
...broadcastChannelStorages,
...cloudStorages,
]);
if ('onconnect' in globalThis) {
// if in shared worker
let activeConnectionCount = 0;
(globalThis as any).onconnect = (event: MessageEvent) => {
activeConnectionCount++;
const port = event.ports[0];
port.addEventListener('message', (event: MessageEvent) => {
if (event.data.type === '__close__') {
activeConnectionCount--;
if (activeConnectionCount === 0) {
globalThis.close();
}
}
});
const opConsumer = new OpConsumer<WorkerOps>(port);
consumer.bindConsumer(opConsumer);
};
} else {
// if in worker
const opConsumer = new OpConsumer<WorkerOps>(
globalThis as MessageCommunicapable
);
consumer.bindConsumer(opConsumer);
}
+1
View File
@@ -10,6 +10,7 @@
{ "path": "../../component" },
{ "path": "../../core" },
{ "path": "../../i18n" },
{ "path": "../../../common/nbstore" },
{ "path": "../../../common/infra" }
]
}
+1
View File
@@ -14,6 +14,7 @@
"@affine/env": "workspace:*",
"@affine/graphql": "workspace:*",
"@affine/i18n": "workspace:*",
"@affine/nbstore": "workspace:*",
"@affine/templates": "workspace:*",
"@affine/track": "workspace:*",
"@blocksuite/affine": "workspace:*",
@@ -1,4 +1,5 @@
// ORDER MATTERS
import './env';
import './public-path';
import './shared-worker';
import './polyfill/browser';
@@ -1,4 +1,5 @@
// ORDER MATTERS
import './env';
import './public-path';
import './shared-worker';
import './polyfill/electron';
@@ -1,11 +1,9 @@
import { polyfillDispose } from './dispose';
import { polyfillIteratorHelpers } from './iterator-helpers';
import { polyfillPromise } from './promise-with-resolvers';
import './dispose';
import './iterator-helpers';
import './promise-with-resolvers';
import { polyfillEventLoop } from './request-idle-callback';
import { polyfillResizeObserver } from './resize-observer';
polyfillResizeObserver();
polyfillEventLoop();
await polyfillPromise();
await polyfillDispose();
await polyfillIteratorHelpers();
@@ -1,8 +1,2 @@
export async function polyfillDispose() {
if (typeof Symbol.dispose !== 'symbol') {
// @ts-expect-error ignore
await import('core-js/modules/esnext.symbol.async-dispose');
// @ts-expect-error ignore
await import('core-js/modules/esnext.symbol.dispose');
}
}
import 'core-js/modules/esnext.symbol.async-dispose';
import 'core-js/modules/esnext.symbol.dispose';
@@ -1,7 +1 @@
export async function polyfillIteratorHelpers() {
if (typeof globalThis['Iterator'] !== 'function') {
// @ts-expect-error ignore
// https://github.com/zloirock/core-js/blob/master/packages/core-js/proposals/iterator-helpers-stage-3.js
await import('core-js/proposals/iterator-helpers-stage-3');
}
}
import 'core-js/proposals/iterator-helpers-stage-3';
@@ -1,6 +1 @@
export async function polyfillPromise() {
if (typeof Promise.withResolvers !== 'function') {
// @ts-expect-error ignore
await import('core-js/features/promise/with-resolvers');
}
}
import 'core-js/features/promise/with-resolvers';
@@ -1,6 +1,6 @@
export function polyfillEventLoop() {
window.requestIdleCallback =
window.requestIdleCallback ||
globalThis.requestIdleCallback =
globalThis.requestIdleCallback ||
function (cb) {
const start = Date.now();
return setTimeout(function () {
@@ -13,8 +13,8 @@ export function polyfillEventLoop() {
}, 1);
};
window.cancelIdleCallback =
window.cancelIdleCallback ||
globalThis.cancelIdleCallback =
globalThis.cancelIdleCallback ||
function (id) {
clearTimeout(id);
};
@@ -1,5 +1,7 @@
import { ResizeObserver } from '@juggle/resize-observer';
export function polyfillResizeObserver() {
window.ResizeObserver = ResizeObserver;
if (typeof window !== 'undefined') {
window.ResizeObserver = ResizeObserver;
}
}
@@ -0,0 +1,25 @@
/**
* This is a wrapper for SharedWorker,
* added the `name` parameter to the `SharedWorker` URL so that
* multiple `SharedWorkers` can share one script file.
*/
const rawSharedWorker = globalThis.SharedWorker;
// TODO(@eyhn): remove this when we can use single shared worker for all workspaces
function PatchedSharedWorker(
urlParam: URL | string,
options?: string | { name: string }
) {
const url = typeof urlParam === 'string' ? new URL(urlParam) : urlParam;
if (options) {
url.searchParams.append(
typeof options === 'string' ? options : options.name,
''
);
}
return new rawSharedWorker(url, options);
}
// if SharedWorker is not supported, do nothing
if (rawSharedWorker) {
globalThis.SharedWorker = PatchedSharedWorker as any;
}
@@ -1,7 +1,12 @@
import { useDocMetaHelper } from '@affine/core/components/hooks/use-block-suite-page-meta';
import { useDocCollectionPage } from '@affine/core/components/hooks/use-block-suite-workspace-page';
import { FetchService, GraphQLService } from '@affine/core/modules/cloud';
import { getAFFiNEWorkspaceSchema } from '@affine/core/modules/workspace';
import {
getAFFiNEWorkspaceSchema,
type WorkspaceFlavourProvider,
WorkspaceService,
WorkspacesService,
} from '@affine/core/modules/workspace';
import { WorkspaceImpl } from '@affine/core/modules/workspace/impls/workspace';
import { DebugLogger } from '@affine/debug';
import type { ListHistoryQuery } from '@affine/graphql';
@@ -25,7 +30,6 @@ import {
useMutation,
} from '../../../components/hooks/use-mutation';
import { useQueryInfinite } from '../../../components/hooks/use-query';
import { CloudBlobStorage } from '../../../modules/workspace-engine/impls/engine/blob-cloud';
const logger = new DebugLogger('page-history');
@@ -105,19 +109,28 @@ const docCollectionMap = new Map<string, Workspace>();
// assume the workspace is a cloud workspace since the history feature is only enabled for cloud workspace
const getOrCreateShellWorkspace = (
workspaceId: string,
fetchService: FetchService,
graphQLService: GraphQLService
flavourProvider?: WorkspaceFlavourProvider
) => {
let docCollection = docCollectionMap.get(workspaceId);
if (!docCollection) {
const blobStorage = new CloudBlobStorage(
workspaceId,
fetchService,
graphQLService
);
docCollection = new WorkspaceImpl({
id: workspaceId,
blobSource: blobStorage,
blobSource: {
name: 'cloud',
readonly: true,
async get(key) {
return flavourProvider?.getWorkspaceBlob(workspaceId, key) ?? null;
},
set() {
return Promise.resolve('');
},
delete() {
return Promise.resolve();
},
list() {
return Promise.resolve([]);
},
},
schema: getAFFiNEWorkspaceSchema(),
});
docCollectionMap.set(workspaceId, docCollection);
@@ -150,6 +163,8 @@ export const useSnapshotPage = (
pageDocId: string,
ts?: string
) => {
const affineWorkspace = useService(WorkspaceService).workspace;
const workspacesService = useService(WorkspacesService);
const fetchService = useService(FetchService);
const graphQLService = useService(GraphQLService);
const snapshot = usePageHistory(docCollection.id, pageDocId, ts);
@@ -160,8 +175,7 @@ export const useSnapshotPage = (
const pageId = pageDocId + '-' + ts;
const historyShellWorkspace = getOrCreateShellWorkspace(
docCollection.id,
fetchService,
graphQLService
workspacesService.getWorkspaceFlavourProvider(affineWorkspace.meta)
);
let page = historyShellWorkspace.getDoc(pageId);
if (!page && snapshot) {
@@ -175,19 +189,31 @@ export const useSnapshotPage = (
}); // must load before applyUpdate
}
return page ?? undefined;
}, [ts, pageDocId, docCollection.id, fetchService, graphQLService, snapshot]);
}, [
ts,
pageDocId,
docCollection.id,
workspacesService,
affineWorkspace.meta,
snapshot,
]);
useEffect(() => {
const historyShellWorkspace = getOrCreateShellWorkspace(
docCollection.id,
fetchService,
graphQLService
workspacesService.getWorkspaceFlavourProvider(affineWorkspace.meta)
);
// apply the rootdoc's update to the current workspace
// this makes sure the page reference links are not deleted ones in the preview
const update = encodeStateAsUpdate(docCollection.doc);
applyUpdate(historyShellWorkspace.doc, update);
}, [docCollection, fetchService, graphQLService]);
}, [
affineWorkspace.meta,
docCollection,
fetchService,
graphQLService,
workspacesService,
]);
return page;
};
@@ -68,11 +68,11 @@ export const CloudQuotaModal = () => {
}, [userQuota, isOwner, workspaceQuota, t]);
const onAbortLargeBlob = useAsyncCallback(
async (blob: Blob) => {
async (byteSize: number) => {
// wait for quota revalidation
await workspaceQuotaService.quota.waitForRevalidation();
if (
blob.size > (workspaceQuotaService.quota.quota$.value?.blobLimit ?? 0)
byteSize > (workspaceQuotaService.quota.quota$.value?.blobLimit ?? 0)
) {
setOpen(true);
}
@@ -85,10 +85,10 @@ export const CloudQuotaModal = () => {
return;
}
currentWorkspace.engine.blob.singleBlobSizeLimit = workspaceQuota.blobLimit;
currentWorkspace.engine.blob.setMaxBlobSize(workspaceQuota.blobLimit);
const disposable =
currentWorkspace.engine.blob.onAbortLargeBlob(onAbortLargeBlob);
currentWorkspace.engine.blob.onReachedMaxBlobSize(onAbortLargeBlob);
return () => {
disposable();
};
@@ -16,7 +16,7 @@ export const LocalQuotaModal = () => {
}, [setOpen]);
useEffect(() => {
const disposable = currentWorkspace.engine.blob.onAbortLargeBlob(() => {
const disposable = currentWorkspace.engine.blob.onReachedMaxBlobSize(() => {
setOpen(true);
});
return () => {
@@ -57,7 +57,6 @@ import {
patchForClipboardInElectron,
patchForEdgelessNoteConfig,
patchForMobile,
patchForSharedPage,
patchGenerateDocUrlExtension,
patchNotificationService,
patchOpenDocExtension,
@@ -93,7 +92,7 @@ interface BlocksuiteEditorProps {
defaultOpenProperty?: DefaultOpenProperty;
}
const usePatchSpecs = (shared: boolean, mode: DocMode) => {
const usePatchSpecs = (mode: DocMode) => {
const [reactToLit, portals] = useLitPortalFactory();
const {
peekViewService,
@@ -168,9 +167,6 @@ const usePatchSpecs = (shared: boolean, mode: DocMode) => {
patched = patched.concat(patchParseDocUrlExtension(framework));
patched = patched.concat(patchGenerateDocUrlExtension(framework));
patched = patched.concat(patchQuickSearchService(framework));
if (shared) {
patched = patched.concat(patchForSharedPage());
}
if (BUILD_CONFIG.isMobileEdition) {
patched = patched.concat(patchForMobile());
}
@@ -190,7 +186,6 @@ const usePatchSpecs = (shared: boolean, mode: DocMode) => {
peekViewService,
reactToLit,
referenceRenderer,
shared,
specs,
featureFlagService,
]);
@@ -261,7 +256,7 @@ export const BlocksuiteDocEditor = forwardRef<
[externalTitleRef]
);
const [specs, portals] = usePatchSpecs(!!shared, 'page');
const [specs, portals] = usePatchSpecs('page');
const displayBiDirectionalLink = useLiveData(
editorSettingService.editorSetting.settings$.selector(
@@ -349,8 +344,8 @@ export const BlocksuiteDocEditor = forwardRef<
export const BlocksuiteEdgelessEditor = forwardRef<
EdgelessEditor,
BlocksuiteEditorProps
>(function BlocksuiteEdgelessEditor({ page, shared }, ref) {
const [specs, portals] = usePatchSpecs(!!shared, 'edgeless');
>(function BlocksuiteEdgelessEditor({ page }, ref) {
const [specs, portals] = usePatchSpecs('edgeless');
const editorRef = useRef<EdgelessEditor | null>(null);
const onDocRef = useCallback(
@@ -3,6 +3,7 @@ import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import type { BlobSyncState } from '@affine/nbstore';
import { useLiveData, useService } from '@toeverything/infra';
import { debounce } from 'lodash-es';
import { useCallback, useEffect } from 'react';
@@ -31,8 +32,8 @@ export const OverCapacityNotification = () => {
// debounce sync engine status
useEffect(() => {
const disposableOverCapacity =
currentWorkspace.engine.blob.isStorageOverCapacity$.subscribe(
debounce((isStorageOverCapacity: boolean) => {
currentWorkspace.engine.blob.state$.subscribe(
debounce(({ isStorageOverCapacity }: BlobSyncState) => {
const isOver = isStorageOverCapacity;
if (!isOver) {
return;
@@ -20,11 +20,11 @@ import {
TeamWorkspaceIcon,
UnsyncIcon,
} from '@blocksuite/icons/rc';
import { useLiveData } from '@toeverything/infra';
import { LiveData, useLiveData } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import clsx from 'clsx';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useEffect, useState } from 'react';
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
import { useCatchEventCallback } from '../../hooks/use-catch-event-hook';
import { WorkspaceAvatar } from '../../workspace-avatar';
@@ -85,7 +85,11 @@ const useSyncEngineSyncProgress = (meta: WorkspaceMetadata) => {
const workspace = useWorkspace(meta);
const engineState = useLiveData(
workspace?.engine.docEngineState$.throttleTime(100)
useMemo(() => {
return workspace
? LiveData.from(workspace.engine.doc.state$, null).throttleTime(100)
: null;
}, [workspace])
);
if (!engineState || !workspace) {
@@ -94,7 +98,7 @@ const useSyncEngineSyncProgress = (meta: WorkspaceMetadata) => {
const progress =
(engineState.total - engineState.syncing) / engineState.total;
const syncing = engineState.syncing > 0 || engineState.retrying;
const syncing = engineState.syncing > 0 || engineState.syncRetrying;
let content;
// TODO(@eyhn): add i18n
@@ -106,9 +110,9 @@ const useSyncEngineSyncProgress = (meta: WorkspaceMetadata) => {
}
} else if (!isOnline) {
content = 'Disconnected, please check your network connection';
} else if (engineState.retrying && engineState.errorMessage) {
content = `${engineState.errorMessage}, reconnecting.`;
} else if (engineState.retrying) {
} else if (engineState.syncRetrying && engineState.syncErrorMessage) {
content = `${engineState.syncErrorMessage}, reconnecting.`;
} else if (engineState.syncRetrying) {
content = 'Sync disconnected due to unexpected issues, reconnecting.';
} else if (syncing) {
content =
@@ -123,7 +127,7 @@ const useSyncEngineSyncProgress = (meta: WorkspaceMetadata) => {
return SyncingWorkspaceStatus({
progress: progress ? Math.max(progress, 0.2) : undefined,
});
} else if (engineState.retrying) {
} else if (engineState.syncRetrying) {
return UnSyncWorkspaceStatus();
} else {
return CloudWorkspaceStatus();
@@ -145,7 +149,7 @@ const useSyncEngineSyncProgress = (meta: WorkspaceMetadata) => {
progress,
active:
workspace.flavour !== 'local' &&
((syncing && progress !== undefined) || engineState.retrying), // active if syncing or retrying,
((syncing && progress !== undefined) || engineState.syncRetrying), // active if syncing or retrying,
};
};
@@ -1,8 +1,7 @@
import { PropertyName, PropertyRoot, PropertyValue } from '@affine/component';
import { DocsService } from '@affine/core/modules/doc';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { i18nTime, useI18n } from '@affine/i18n';
import { DateTimeIcon, HistoryIcon } from '@blocksuite/icons/rc';
import { DateTimeIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import type { ConfigType } from 'dayjs';
@@ -19,11 +18,7 @@ export const TimeRow = ({
className?: string;
}) => {
const t = useI18n();
const workspaceService = useService(WorkspaceService);
const docsService = useService(DocsService);
const { syncing, retrying, serverClock } = useLiveData(
workspaceService.workspace.engine.doc.docState$(docId)
);
const docRecord = useLiveData(docsService.list.doc$(docId));
const docMeta = useLiveData(docRecord?.meta$);
@@ -43,38 +38,14 @@ export const TimeRow = ({
: null;
return (
<>
<PropertyRoot>
<PropertyName name={t['Created']()} icon={<DateTimeIcon />} />
<PropertyValue>
{docMeta ? formatI18nTime(docMeta.createDate) : localizedCreateTime}
</PropertyValue>
</PropertyRoot>
{serverClock ? (
<PropertyRoot>
<PropertyName
name={t[
!syncing && !retrying ? 'Updated' : 'com.affine.syncing'
]()}
icon={<HistoryIcon />}
/>
<PropertyValue>
{!syncing && !retrying
? formatI18nTime(serverClock)
: docMeta?.updatedDate
? formatI18nTime(docMeta.updatedDate)
: null}
</PropertyValue>
</PropertyRoot>
) : docMeta?.updatedDate ? (
<PropertyRoot>
<PropertyName name={t['Updated']()} icon={<HistoryIcon />} />
<PropertyValue>{formatI18nTime(docMeta.updatedDate)}</PropertyValue>
</PropertyRoot>
) : null}
</>
<PropertyRoot>
<PropertyName name={t['Created']()} icon={<DateTimeIcon />} />
<PropertyValue>
{docMeta ? formatI18nTime(docMeta.createDate) : localizedCreateTime}
</PropertyValue>
</PropertyRoot>
);
}, [docMeta, retrying, serverClock, syncing, t]);
}, [docMeta, t]);
const dTimestampElement = useDebouncedValue(timestampElement, 500);
@@ -63,7 +63,6 @@ const SettingModalInner = ({
const currentServerId = useLiveData(
globalContextService.globalContext.serverId.$
);
console.log(currentServerId);
const serversService = useService(ServersService);
const defaultServerService = useService(DefaultServerService);
const currentServer =
@@ -39,8 +39,8 @@ export const DesktopExportPanel = ({ workspace }: ExportPanelProps) => {
type: 'workspace',
});
if (isOnline) {
await workspace.engine.waitForDocSynced();
await workspace.engine.blob.sync();
await workspace.engine.doc.waitForSynced();
await workspace.engine.blob.fullSync();
}
const result = await desktopApi.handler?.dialog.saveDBFileAs(workspaceId);
@@ -8,9 +8,7 @@ import { WorkspaceServerService } from '@affine/core/modules/cloud';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
import { useI18n } from '@affine/i18n';
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
import { FrameworkScope, useService } from '@toeverything/infra';
import { useCallback } from 'react';
import { DeleteLeaveWorkspace } from './delete-leave-workspace';
import { EnableCloudPanel } from './enable-cloud';
@@ -34,17 +32,6 @@ export const WorkspaceSettingDetail = ({
const workspaceInfo = useWorkspaceInfo(workspace);
const handleResetSyncStatus = useCallback(() => {
workspace?.engine.doc
.resetSyncStatus()
.then(() => {
window.location.reload();
})
.catch(err => {
console.error(err);
});
}, [workspace]);
if (!workspace) {
return null;
}
@@ -71,8 +58,10 @@ export const WorkspaceSettingDetail = ({
<TemplateDocSetting />
<SettingWrapper title={t['com.affine.brand.affineCloud']()}>
<EnableCloudPanel onCloseSetting={onCloseSetting} />
<WorkspaceQuotaPanel />
<MembersPanel onChangeSettingState={onChangeSettingState} />
{workspace.flavour !== 'local' && <WorkspaceQuotaPanel />}
{workspace.flavour !== 'local' && (
<MembersPanel onChangeSettingState={onChangeSettingState} />
)}
</SettingWrapper>
<SharingPanel />
{BUILD_CONFIG.isElectron && (
@@ -82,19 +71,6 @@ export const WorkspaceSettingDetail = ({
)}
<SettingWrapper>
<DeleteLeaveWorkspace onCloseSetting={onCloseSetting} />
<SettingRow
name={
<span style={{ color: 'var(--affine-text-secondary-color)' }}>
{t['com.affine.resetSyncStatus.button']()}
</span>
}
desc={t['com.affine.resetSyncStatus.description']()}
style={{ cursor: 'pointer' }}
onClick={handleResetSyncStatus}
data-testid="reset-sync-status"
>
<ArrowRightSmallIcon />
</SettingRow>
</SettingWrapper>
</FrameworkScope>
</FrameworkScope>
@@ -9,9 +9,10 @@ import { validateAndReduceImage } from '@affine/core/utils/reduce-image';
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
import { useI18n } from '@affine/i18n';
import { CameraIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { LiveData, useLiveData, useService } from '@toeverything/infra';
import type { KeyboardEvent } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { map } from 'rxjs';
import * as style from './style.css';
@@ -24,8 +25,18 @@ export const ProfilePanel = () => {
useEffect(() => {
permissionService.permission.revalidate();
}, [permissionService]);
const workspaceIsReady = useLiveData(workspace?.engine.rootDocState$)?.ready;
const workspaceIsReady = useLiveData(
useMemo(() => {
return workspace
? LiveData.from(
workspace.engine.doc
.docState$(workspace.id)
.pipe(map(v => v.ready)),
false
)
: null;
}, [workspace])
);
const [name, setName] = useState('');
useEffect(() => {
@@ -33,7 +33,7 @@ const useLoadAttachment = (pageId: string, attachmentId: string) => {
if (!doc.blockSuiteDoc.ready) {
doc.blockSuiteDoc.load();
}
doc.setPriorityLoad(10);
const dispose = doc.addPriorityLoad(10);
doc
.waitForSyncReady()
@@ -47,6 +47,7 @@ const useLoadAttachment = (pageId: string, attachmentId: string) => {
return () => {
release();
dispose();
};
}, [docRecord, docsService, pageId, attachmentId]);
@@ -41,10 +41,7 @@ const useLoadDoc = (pageId: string) => {
// set sync engine priority target
useEffect(() => {
currentWorkspace.engine.doc.setPriority(pageId, 10);
return () => {
currentWorkspace.engine.doc.setPriority(pageId, 5);
};
return currentWorkspace.engine.doc.addPriority(pageId, 10);
}, [currentWorkspace, pageId]);
const isInTrash = useLiveData(doc?.meta$.map(meta => meta.trash));
@@ -16,6 +16,7 @@ import {
import { ZipTransformer } from '@blocksuite/affine/blocks';
import {
FrameworkScope,
LiveData,
useLiveData,
useService,
useServices,
@@ -28,6 +29,7 @@ import {
useParams,
useSearchParams,
} from 'react-router-dom';
import { map } from 'rxjs';
import * as _Y from 'yjs';
import { AffineErrorBoundary } from '../../../components/affine/affine-error-boundary';
@@ -247,7 +249,20 @@ const WorkspacePage = ({ meta }: { meta: WorkspaceMetadata }) => {
}, [meta, workspacesService]);
const isRootDocReady =
useLiveData(workspace?.engine.rootDocState$.map(v => v.ready)) ?? false;
useLiveData(
useMemo(
() =>
workspace
? LiveData.from(
workspace.engine.doc
.docState$(workspace.id)
.pipe(map(v => v.ready)),
false
)
: null,
[workspace]
)
) ?? false;
useEffect(() => {
if (workspace) {
@@ -25,13 +25,13 @@ export const WorkspaceLayout = function WorkspaceLayout({
<WorkspaceDialogs />
{/* ---- some side-effect components ---- */}
{currentWorkspace?.flavour === 'local' ? (
<LocalQuotaModal />
) : (
{currentWorkspace?.flavour !== 'local' ? (
<>
<CloudQuotaModal />
<QuotaCheck workspaceMeta={currentWorkspace.meta} />
</>
) : (
<LocalQuotaModal />
)}
<AiLoginRequiredModal />
<WorkspaceSideEffects />
@@ -5,12 +5,7 @@ import { usePageDocumentTitle } from '@affine/core/components/hooks/use-global-s
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
import { PageDetailEditor } from '@affine/core/components/page-detail-editor';
import { AppContainer } from '@affine/core/desktop/components/app-container';
import {
AuthService,
FetchService,
GraphQLService,
ServerService,
} from '@affine/core/modules/cloud';
import { AuthService, ServerService } from '@affine/core/modules/cloud';
import { type Doc, DocsService } from '@affine/core/modules/doc';
import {
type Editor,
@@ -19,13 +14,11 @@ import {
EditorsService,
} from '@affine/core/modules/editor';
import { PeekViewManagerModal } from '@affine/core/modules/peek-view';
import { ShareReaderService } from '@affine/core/modules/share-doc';
import { ViewIcon, ViewTitle } from '@affine/core/modules/workbench';
import {
type Workspace,
WorkspacesService,
} from '@affine/core/modules/workspace';
import { CloudBlobStorage } from '@affine/core/modules/workspace-engine';
import { useI18n } from '@affine/i18n';
import {
type DocMode,
@@ -35,22 +28,9 @@ import {
import type { AffineEditorContainer } from '@blocksuite/affine/presets';
import { DisposableGroup } from '@blocksuite/global/utils';
import { Logo1Icon } from '@blocksuite/icons/rc';
import {
EmptyBlobStorage,
FrameworkScope,
ReadonlyDocStorage,
useLiveData,
useService,
useServices,
} from '@toeverything/infra';
import { FrameworkScope, useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { PageNotFound } from '../../404';
@@ -65,15 +45,6 @@ export const SharePage = ({
workspaceId: string;
docId: string;
}) => {
const { shareReaderService, serverService } = useServices({
ShareReaderService,
ServerService,
});
const isLoading = useLiveData(shareReaderService.reader.isLoading$);
const error = useLiveData(shareReaderService.reader.error$);
const data = useLiveData(shareReaderService.reader.data$);
const location = useLocation();
const { mode, selector, isTemplate, templateName, templateSnapshotUrl } =
@@ -105,47 +76,26 @@ export const SharePage = ({
};
}, [location.search]);
useEffect(() => {
shareReaderService.reader.loadShare({
serverId: serverService.server.id,
workspaceId,
docId,
});
}, [shareReaderService, docId, workspaceId, serverService.server.id]);
let element: ReactNode = null;
if (isLoading) {
element = null;
} else if (data) {
element = (
return (
<AppContainer>
<SharePageInner
workspaceId={data.workspaceId}
docId={data.docId}
workspaceBinary={data.workspaceBinary}
docBinary={data.docBinary}
publishMode={mode || data.publishMode}
workspaceId={workspaceId}
docId={docId}
key={workspaceId + ':' + docId}
publishMode={mode ?? undefined}
selector={selector}
isTemplate={isTemplate}
templateName={templateName}
templateSnapshotUrl={templateSnapshotUrl}
/>
);
} else if (error) {
// TODO(@JimmFly): handle error
element = <PageNotFound />;
} else {
element = <PageNotFound noPermission />;
}
return <AppContainer fallback={!element}>{element}</AppContainer>;
</AppContainer>
);
};
const SharePageInner = ({
workspaceId,
docId,
workspaceBinary,
docBinary,
publishMode = 'page' as DocMode,
publishMode = 'page',
selector,
isTemplate,
templateName,
@@ -153,20 +103,18 @@ const SharePageInner = ({
}: {
workspaceId: string;
docId: string;
workspaceBinary: Uint8Array;
docBinary: Uint8Array;
publishMode?: DocMode;
selector?: EditorSelector;
isTemplate?: boolean;
templateName?: string;
templateSnapshotUrl?: string;
}) => {
const serverService = useService(ServerService);
const workspacesService = useService(WorkspacesService);
const fetchService = useService(FetchService);
const graphQLService = useService(GraphQLService);
const [workspace, setWorkspace] = useState<Workspace | null>(null);
const [page, setPage] = useState<Doc | null>(null);
const [editor, setEditor] = useState<Editor | null>(null);
const [noPermission, setNoPermission] = useState(false);
const [editorContainer, setActiveBlocksuiteEditor] =
useActiveBlocksuiteEditor();
@@ -181,38 +129,41 @@ const SharePageInner = ({
isSharedMode: true,
},
{
getDocStorage() {
return new ReadonlyDocStorage({
[workspaceId]: workspaceBinary,
[docId]: docBinary,
});
},
getAwarenessConnections() {
return [];
},
getDocServer() {
return null;
},
getLocalBlobStorage() {
return EmptyBlobStorage;
},
getRemoteBlobStorages() {
return [
new CloudBlobStorage(workspaceId, fetchService, graphQLService),
];
local: {
doc: {
name: 'StaticCloudDocStorage',
opts: {
id: workspaceId,
serverBaseUrl: serverService.server.baseUrl,
},
},
blob: {
name: 'CloudBlobStorage',
opts: {
id: workspaceId,
serverBaseUrl: serverService.server.baseUrl,
},
},
},
remotes: {},
}
);
setWorkspace(workspace);
workspace.engine
.waitForRootDocReady()
.then(() => {
workspace.engine.doc
.waitForDocLoaded(workspace.id)
.then(async () => {
const { doc } = workspace.scope.get(DocsService).open(docId);
doc.blockSuiteDoc.load();
doc.blockSuiteDoc.readonly = true;
await workspace.engine.doc.waitForDocLoaded(docId);
if (!doc.blockSuiteDoc.root) {
throw new Error('Doc is empty');
}
setPage(doc);
const editor = doc.scope.get(EditorsService).createEditor();
@@ -226,6 +177,7 @@ const SharePageInner = ({
})
.catch(err => {
console.error(err);
setNoPermission(true);
});
}, [
docId,
@@ -233,10 +185,7 @@ const SharePageInner = ({
workspacesService,
publishMode,
selector,
workspaceBinary,
docBinary,
fetchService,
graphQLService,
serverService.server.baseUrl,
]);
const t = useI18n();
@@ -281,6 +230,10 @@ const SharePageInner = ({
[editor, setActiveBlocksuiteEditor, jumpToPageBlock, openPage, workspaceId]
);
if (noPermission) {
return <PageNotFound noPermission />;
}
if (!workspace || !page || !editor) {
return;
}
@@ -17,13 +17,20 @@ import type {
WorkspaceMetadata,
} from '@affine/core/modules/workspace';
import { WorkspacesService } from '@affine/core/modules/workspace';
import { FrameworkScope, useLiveData, useServices } from '@toeverything/infra';
import {
FrameworkScope,
LiveData,
useLiveData,
useServices,
} from '@toeverything/infra';
import {
type PropsWithChildren,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from 'react';
import { map } from 'rxjs';
import { AppFallback } from '../../components/app-fallback';
import { WorkspaceDialogs } from '../../dialogs';
@@ -33,11 +40,11 @@ declare global {
/**
* @internal debug only
*/
// eslint-disable-next-line no-var
// oxlint-disable-next-line no-var
var currentWorkspace: Workspace | undefined;
// eslint-disable-next-line no-var
// oxlint-disable-next-line no-var
var exportWorkspaceSnapshot: (docs?: string[]) => Promise<void>;
// eslint-disable-next-line no-var
// oxlint-disable-next-line no-var
var importWorkspaceSnapshot: () => Promise<void>;
interface WindowEventMap {
'affine:workspace:change': CustomEvent<{ id: string }>;
@@ -106,7 +113,20 @@ export const WorkspaceLayout = ({
]);
const isRootDocReady =
useLiveData(workspace?.engine.rootDocState$.map(v => v.ready)) ?? false;
useLiveData(
useMemo(
() =>
workspace
? LiveData.from(
workspace.engine.doc
.docState$(workspace.id)
.pipe(map(v => v.ready)),
false
)
: null,
[workspace]
)
) ?? false;
if (!workspace) {
return null; // skip this, workspace will be set in layout effect
@@ -125,10 +145,10 @@ export const WorkspaceLayout = ({
{/* ---- some side-effect components ---- */}
<PeekViewManagerModal />
{workspace?.flavour === 'local' ? (
<LocalQuotaModal />
) : (
{workspace?.flavour !== 'local' ? (
<CloudQuotaModal />
) : (
<LocalQuotaModal />
)}
<AiLoginRequiredModal />
<WorkspaceSideEffects />
@@ -11,9 +11,7 @@ export { AccountChanged } from './events/account-changed';
export { AccountLoggedIn } from './events/account-logged-in';
export { AccountLoggedOut } from './events/account-logged-out';
export { ServerInitialized } from './events/server-initialized';
export { RawFetchProvider } from './provider/fetch';
export { ValidatorProvider } from './provider/validator';
export { WebSocketAuthProvider } from './provider/websocket-auth';
export { AuthService } from './services/auth';
export { CaptchaService } from './services/captcha';
export { DefaultServerService } from './services/default-server';
@@ -27,7 +25,6 @@ export { SubscriptionService } from './services/subscription';
export { UserCopilotQuotaService } from './services/user-copilot-quota';
export { UserFeatureService } from './services/user-feature';
export { UserQuotaService } from './services/user-quota';
export { WebSocketService } from './services/websocket';
export { WorkspaceInvoicesService } from './services/workspace-invoices';
export { WorkspaceServerService } from './services/workspace-server';
export { WorkspaceSubscriptionService } from './services/workspace-subscription';
@@ -51,9 +48,7 @@ import { UserFeature } from './entities/user-feature';
import { UserQuota } from './entities/user-quota';
import { WorkspaceInvoices } from './entities/workspace-invoices';
import { WorkspaceSubscription } from './entities/workspace-subscription';
import { DefaultRawFetchProvider, RawFetchProvider } from './provider/fetch';
import { ValidatorProvider } from './provider/validator';
import { WebSocketAuthProvider } from './provider/websocket-auth';
import { ServerScope } from './scopes/server';
import { AuthService } from './services/auth';
import { CaptchaService } from './services/captcha';
@@ -69,7 +64,6 @@ import { SubscriptionService } from './services/subscription';
import { UserCopilotQuotaService } from './services/user-copilot-quota';
import { UserFeatureService } from './services/user-feature';
import { UserQuotaService } from './services/user-quota';
import { WebSocketService } from './services/websocket';
import { WorkspaceInvoicesService } from './services/workspace-invoices';
import { WorkspaceServerService } from './services/workspace-server';
import { WorkspaceSubscriptionService } from './services/workspace-subscription';
@@ -85,26 +79,16 @@ import { UserQuotaStore } from './stores/user-quota';
export function configureCloudModule(framework: Framework) {
framework
.impl(RawFetchProvider, DefaultRawFetchProvider)
.service(ServersService, [ServerListStore, ServerConfigStore])
.service(DefaultServerService, [ServersService])
.store(ServerListStore, [GlobalStateService])
.store(ServerConfigStore, [RawFetchProvider])
.store(ServerConfigStore)
.entity(Server, [ServerListStore])
.scope(ServerScope)
.service(ServerService, [ServerScope])
.service(FetchService, [RawFetchProvider, ServerService])
.service(FetchService, [ServerService])
.service(EventSourceService, [ServerService])
.service(GraphQLService, [FetchService])
.service(
WebSocketService,
f =>
new WebSocketService(
f.get(ServerService),
f.get(AuthService),
f.getOptional(WebSocketAuthProvider)
)
)
.service(CaptchaService, f => {
return new CaptchaService(
f.get(ServerService),
@@ -1,17 +0,0 @@
import { createIdentifier } from '@toeverything/infra';
import type { FetchInit } from '../services/fetch';
export interface RawFetchProvider {
/**
* standard fetch, in ios&android, we can use native fetch to implement this
*/
fetch: (input: string | URL, init?: FetchInit) => Promise<Response>;
}
export const RawFetchProvider =
createIdentifier<RawFetchProvider>('FetchProvider');
export const DefaultRawFetchProvider = {
fetch: globalThis.fetch.bind(globalThis),
};
@@ -1,22 +0,0 @@
import { createIdentifier } from '@toeverything/infra';
export interface WebSocketAuthProvider {
/**
* Returns the token and userId for WebSocket authentication
*
* Useful when cookies are not available for WebSocket connections
*
* @param url - The URL of the WebSocket endpoint
*/
getAuthToken: (url: string) => Promise<
| {
token?: string;
userId?: string;
}
| undefined
>;
}
export const WebSocketAuthProvider = createIdentifier<WebSocketAuthProvider>(
'WebSocketAuthProvider'
);
@@ -3,7 +3,6 @@ import { UserFriendlyError } from '@affine/graphql';
import { fromPromise, Service } from '@toeverything/infra';
import { BackendError, NetworkError } from '../error';
import type { RawFetchProvider } from '../provider/fetch';
import type { ServerService } from './server';
const logger = new DebugLogger('affine:fetch');
@@ -11,10 +10,7 @@ const logger = new DebugLogger('affine:fetch');
export type FetchInit = RequestInit & { timeout?: number };
export class FetchService extends Service {
constructor(
private readonly fetchProvider: RawFetchProvider,
private readonly serverService: ServerService
) {
constructor(private readonly serverService: ServerService) {
super();
}
rxFetch = (
@@ -50,7 +46,7 @@ export class FetchService extends Service {
abortController.abort('timeout');
}, timeout);
const res = await this.fetchProvider
const res = await globalThis
.fetch(new URL(input, this.serverService.server.serverMetadata.baseUrl), {
...init,
signal: abortController.signal,
@@ -1,66 +0,0 @@
import { OnEvent, Service } from '@toeverything/infra';
import { Manager } from 'socket.io-client';
import { ApplicationStarted } from '../../lifecycle';
import { AccountChanged } from '../events/account-changed';
import type { WebSocketAuthProvider } from '../provider/websocket-auth';
import type { AuthService } from './auth';
import type { ServerService } from './server';
@OnEvent(AccountChanged, e => e.update)
@OnEvent(ApplicationStarted, e => e.update)
export class WebSocketService extends Service {
ioManager: Manager = new Manager(`${this.serverService.server.baseUrl}/`, {
autoConnect: false,
transports: ['websocket'],
secure: location.protocol === 'https:',
});
socket = this.ioManager.socket('/', {
auth: this.webSocketAuthProvider
? cb => {
this.webSocketAuthProvider
?.getAuthToken(`${this.serverService.server.baseUrl}/`)
.then(v => {
cb(v ?? {});
})
.catch(e => {
console.error('Failed to get auth token for websocket', e);
});
}
: undefined,
});
refCount = 0;
constructor(
private readonly serverService: ServerService,
private readonly authService: AuthService,
private readonly webSocketAuthProvider?: WebSocketAuthProvider
) {
super();
}
/**
* Connect socket, with automatic connect and reconnect logic.
* External code should not call `socket.connect()` or `socket.disconnect()` manually.
* When socket is no longer needed, call `dispose()` to clean up resources.
*/
connect() {
this.refCount++;
this.update();
return {
socket: this.socket,
dispose: () => {
this.refCount--;
this.update();
},
};
}
update(): void {
if (this.authService.session.account$.value && this.refCount > 0) {
this.socket.connect();
} else {
this.socket.disconnect();
}
}
}
@@ -8,13 +8,11 @@ import {
} from '@affine/graphql';
import { Store } from '@toeverything/infra';
import type { RawFetchProvider } from '../provider/fetch';
export type ServerConfigType = ServerConfigQuery['serverConfig'] &
OauthProvidersQuery['serverConfig'];
export class ServerConfigStore extends Store {
constructor(private readonly fetcher: RawFetchProvider) {
constructor() {
super();
}
@@ -22,10 +20,7 @@ export class ServerConfigStore extends Store {
serverBaseUrl: string,
abortSignal?: AbortSignal
): Promise<ServerConfigType> {
const gql = gqlFetcherFactory(
`${serverBaseUrl}/graphql`,
this.fetcher.fetch
);
const gql = gqlFetcherFactory(`${serverBaseUrl}/graphql`, globalThis.fetch);
const serverConfigData = await gql({
query: serverConfigQuery,
context: {
@@ -2,7 +2,8 @@ import type {
Table as OrmTable,
TableSchemaBuilder,
} from '@toeverything/infra';
import { Entity } from '@toeverything/infra';
import { Entity, LiveData } from '@toeverything/infra';
import { map } from 'rxjs';
import type { WorkspaceService } from '../../workspace';
@@ -18,13 +19,19 @@ export class WorkspaceDBTable<
super();
}
isSyncing$ = this.workspaceService.workspace.engine.doc
.docState$(this.props.storageDocId)
.map(docState => docState.syncing);
isSyncing$ = LiveData.from(
this.workspaceService.workspace.engine.doc
.docState$(this.props.storageDocId)
.pipe(map(docState => docState.syncing)),
false
);
isLoading$ = this.workspaceService.workspace.engine.doc
.docState$(this.props.storageDocId)
.map(docState => docState.loading);
isLoading$ = LiveData.from(
this.workspaceService.workspace.engine.doc
.docState$(this.props.storageDocId)
.pipe(map(docState => !docState.loaded)),
false
);
create = this.table.create.bind(this.table) as typeof this.table.create;
update = this.table.update.bind(this.table) as typeof this.table.update;
@@ -46,11 +46,11 @@ export class WorkspaceDBService extends Service {
new YjsDBAdapter(AFFiNE_WORKSPACE_DB_SCHEMA, {
getDoc: guid => {
const ydoc = new YDoc({
// guid format: db${workspaceId}${guid}
guid: `db$${this.workspaceService.workspace.id}$${guid}`,
// guid format: db${guid}
guid: `db$${guid}`,
});
this.workspaceService.workspace.engine.doc.addDoc(ydoc, false);
this.workspaceService.workspace.engine.doc.setPriority(
this.workspaceService.workspace.engine.doc.connectDoc(ydoc);
this.workspaceService.workspace.engine.doc.addPriority(
ydoc.guid,
50
);
@@ -59,8 +59,7 @@ export class WorkspaceDBService extends Service {
})
),
schema: AFFiNE_WORKSPACE_DB_SCHEMA,
storageDocId: tableName =>
`db$${this.workspaceService.workspace.id}$${tableName}`,
storageDocId: tableName => `db$${tableName}`,
}
) as WorkspaceDBWithTables<AFFiNEWorkspaceDbSchema>;
}
@@ -79,11 +78,11 @@ export class WorkspaceDBService extends Service {
new YjsDBAdapter(AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA, {
getDoc: guid => {
const ydoc = new YDoc({
// guid format: userdata${userId}${workspaceId}${guid}
guid: `userdata$${userId}$${this.workspaceService.workspace.id}$${guid}`,
// guid format: userdata${userId}${guid}
guid: `userdata$${userId}$${guid}`,
});
this.workspaceService.workspace.engine.doc.addDoc(ydoc, false);
this.workspaceService.workspace.engine.doc.setPriority(
this.workspaceService.workspace.engine.doc.connectDoc(ydoc);
this.workspaceService.workspace.engine.doc.addPriority(
ydoc.guid,
50
);
@@ -92,8 +91,7 @@ export class WorkspaceDBService extends Service {
})
),
schema: AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA,
storageDocId: tableName =>
`userdata$${userId}$${this.workspaceService.workspace.id}$${tableName}`,
storageDocId: tableName => `userdata$${userId}$${tableName}`,
}
);
+17 -11
View File
@@ -1,4 +1,4 @@
import type { DocStorage } from '@toeverything/infra';
import type { DocStorage } from '@affine/nbstore';
import {
AFFiNE_WORKSPACE_DB_SCHEMA,
@@ -6,27 +6,33 @@ import {
} from './schema';
export async function transformWorkspaceDBLocalToCloud(
localWorkspaceId: string,
cloudWorkspaceId: string,
_localWorkspaceId: string,
_cloudWorkspaceId: string,
localDocStorage: DocStorage,
cloudDocStorage: DocStorage,
accountId: string
) {
for (const tableName of Object.keys(AFFiNE_WORKSPACE_DB_SCHEMA)) {
const localDocName = `db$${localWorkspaceId}$${tableName}`;
const localDoc = await localDocStorage.doc.get(localDocName);
const localDocName = `db$${tableName}`;
const localDoc = await localDocStorage.getDoc(localDocName);
if (localDoc) {
const cloudDocName = `db$${cloudWorkspaceId}$${tableName}`;
await cloudDocStorage.doc.set(cloudDocName, localDoc);
const cloudDocName = `db$${tableName}`;
await cloudDocStorage.pushDocUpdate({
docId: cloudDocName,
bin: localDoc.bin,
});
}
}
for (const tableName of Object.keys(AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA)) {
const localDocName = `userdata$__local__$${localWorkspaceId}$${tableName}`;
const localDoc = await localDocStorage.doc.get(localDocName);
const localDocName = `userdata$__local__$${tableName}`;
const localDoc = await localDocStorage.getDoc(localDocName);
if (localDoc) {
const cloudDocName = `userdata$${accountId}$${cloudWorkspaceId}$${tableName}`;
await cloudDocStorage.doc.set(cloudDocName, localDoc);
const cloudDocName = `userdata$${accountId}$${tableName}`;
await cloudDocStorage.pushDocUpdate({
docId: cloudDocName,
bin: localDoc.bin,
});
}
}
}
@@ -28,8 +28,9 @@ export class DocDatabaseBacklinksService extends Service {
if (!docRef.doc.blockSuiteDoc.ready) {
docRef.doc.blockSuiteDoc.load();
}
docRef.doc.setPriorityLoad(10);
const disposePriorityLoad = docRef.doc.addPriorityLoad(10);
await docRef.doc.waitForSyncReady();
disposePriorityLoad();
return docRef;
}
@@ -81,8 +81,8 @@ export class Doc extends Entity {
return this.store.waitForDocLoadReady(this.id);
}
setPriorityLoad(priority: number) {
return this.store.setPriorityLoad(this.id, priority);
addPriorityLoad(priority: number) {
return this.store.addPriorityLoad(this.id, priority);
}
changeDocTitle(newTitle: string) {
@@ -107,7 +107,6 @@ export class DocsService extends Service {
) {
const doc = this.store.createBlockSuiteDoc();
initDocFromProps(doc, options.docProps);
this.store.markDocSyncStateAsReady(doc.id);
const docRecord = this.list.doc$(doc.id).value;
if (!docRecord) {
throw new Unreachable();
@@ -124,8 +123,9 @@ export class DocsService extends Service {
async addLinkedDoc(targetDocId: string, linkedDocId: string) {
const { doc, release } = this.open(targetDocId);
doc.setPriorityLoad(10);
const disposePriorityLoad = doc.addPriorityLoad(10);
await doc.waitForSyncReady();
disposePriorityLoad();
const text = new Text([
{
insert: ' ',
@@ -149,8 +149,9 @@ export class DocsService extends Service {
async changeDocTitle(docId: string, newTitle: string) {
const { doc, release } = this.open(docId);
doc.setPriorityLoad(10);
const disposePriorityLoad = doc.addPriorityLoad(10);
await doc.waitForSyncReady();
disposePriorityLoad();
doc.changeDocTitle(newTitle);
release();
}
@@ -126,9 +126,9 @@ export class DocsStore extends Store {
}
watchDocListReady() {
return this.workspaceService.workspace.engine.rootDocState$
.map(state => !state.syncing)
.asObservable();
return this.workspaceService.workspace.engine.doc
.docState$(this.workspaceService.workspace.id)
.pipe(map(state => state.synced));
}
setDocMeta(id: string, meta: Partial<DocMeta>) {
@@ -153,14 +153,10 @@ export class DocsStore extends Store {
}
waitForDocLoadReady(id: string) {
return this.workspaceService.workspace.engine.doc.waitForReady(id);
return this.workspaceService.workspace.engine.doc.waitForDocLoaded(id);
}
setPriorityLoad(id: string, priority: number) {
return this.workspaceService.workspace.engine.doc.setPriority(id, priority);
}
markDocSyncStateAsReady(id: string) {
this.workspaceService.workspace.engine.doc.markAsReady(id);
addPriorityLoad(id: string, priority: number) {
return this.workspaceService.workspace.engine.doc.addPriority(id, priority);
}
}
@@ -25,7 +25,7 @@ const logger = new DebugLogger('crawler');
const WORKSPACE_DOCS_INDEXER_VERSION_KEY = 'docs-indexer-version';
interface IndexerJobPayload {
storageDocId: string;
docId: string;
}
export class DocsIndexer extends Entity {
@@ -81,26 +81,31 @@ export class DocsIndexer extends Entity {
}
setupListener() {
this.disposables.push(
this.workspaceEngine.doc.storage.eventBus.on(event => {
if (WorkspaceDBService.isDBDocId(event.docId)) {
// skip db doc
return;
}
if (event.clientId === this.workspaceEngine.doc.clientId) {
this.jobQueue
.enqueue([
{
batchKey: event.docId,
payload: { storageDocId: event.docId },
},
])
.catch(err => {
console.error('Error enqueueing job', err);
});
}
this.workspaceEngine.doc.storage.connection
.waitForConnected()
.then(() => {
this.disposables.push(
this.workspaceEngine.doc.storage.subscribeDocUpdate(updated => {
if (WorkspaceDBService.isDBDocId(updated.docId)) {
// skip db doc
return;
}
this.jobQueue
.enqueue([
{
batchKey: updated.docId,
payload: { docId: updated.docId },
},
])
.catch(err => {
console.error('Error enqueueing job', err);
});
})
);
})
);
.catch(err => {
console.error('Error waiting for doc storage connection', err);
});
}
async execJob(jobs: Job<IndexerJobPayload>[], signal: AbortSignal) {
@@ -119,20 +124,19 @@ export class DocsIndexer extends Entity {
const isUpgrade = dbVersion < DocsIndexer.INDEXER_VERSION;
// jobs should have the same storage docId, so we just pick the first one
const storageDocId = jobs[0].payload.storageDocId;
const docId = jobs[0].payload.docId;
const worker = await this.ensureWorker(signal);
const startTime = performance.now();
logger.debug('Start crawling job for storageDocId:', storageDocId);
logger.debug('Start crawling job for docId:', docId);
let workerOutput;
if (storageDocId === this.workspaceId) {
const rootDocBuffer =
await this.workspaceEngine.doc.storage.loadDocFromLocal(
this.workspaceId
);
if (docId === this.workspaceId) {
const rootDocBuffer = (
await this.workspaceEngine.doc.storage.getDoc(this.workspaceId)
)?.bin;
if (!rootDocBuffer) {
return;
}
@@ -147,15 +151,13 @@ export class DocsIndexer extends Entity {
rootDocId: this.workspaceId,
});
} else {
const rootDocBuffer =
await this.workspaceEngine.doc.storage.loadDocFromLocal(
this.workspaceId
);
const rootDocBuffer = (
await this.workspaceEngine.doc.storage.getDoc(this.workspaceId)
)?.bin;
const docBuffer =
(await this.workspaceEngine.doc.storage.loadDocFromLocal(
storageDocId
)) ?? new Uint8Array(0);
(await this.workspaceEngine.doc.storage.getDoc(docId))?.bin ??
new Uint8Array(0);
if (!rootDocBuffer) {
return;
@@ -164,7 +166,7 @@ export class DocsIndexer extends Entity {
workerOutput = await worker.run({
type: 'doc',
docBuffer,
storageDocId,
docId,
rootDocBuffer,
rootDocId: this.workspaceId,
});
@@ -231,9 +233,9 @@ export class DocsIndexer extends Entity {
if (workerOutput.reindexDoc) {
await this.jobQueue.enqueue(
workerOutput.reindexDoc.map(({ storageDocId }) => ({
batchKey: storageDocId,
payload: { storageDocId },
workerOutput.reindexDoc.map(({ docId }) => ({
batchKey: docId,
payload: { docId },
}))
);
}
@@ -244,11 +246,7 @@ export class DocsIndexer extends Entity {
const duration = performance.now() - startTime;
logger.debug(
'Finish crawling job for storageDocId:' +
storageDocId +
' in ' +
duration +
'ms '
'Finish crawling job for docId:' + docId + ' in ' + duration + 'ms '
);
}
@@ -259,7 +257,7 @@ export class DocsIndexer extends Entity {
.enqueue([
{
batchKey: this.workspaceId,
payload: { storageDocId: this.workspaceId },
payload: { docId: this.workspaceId },
},
])
.catch(err => {
@@ -1,4 +1,3 @@
import { getElectronAPIs } from '@affine/electron-api/web-worker';
import type {
AttachmentBlockModel,
BookmarkBlockModel,
@@ -50,11 +49,6 @@ const LRU_CACHE_SIZE = 5;
// lru cache for ydoc instances, last used at the end of the array
const lruCache = [] as { doc: YDoc; hash: string }[];
const electronAPIs = BUILD_CONFIG.isElectron ? getElectronAPIs() : null;
// @ts-expect-error test
globalThis.__electronAPIs = electronAPIs;
async function digest(data: Uint8Array) {
if (
globalThis.crypto &&
@@ -478,7 +472,7 @@ function unindentMarkdown(markdown: string) {
async function crawlingDocData({
docBuffer,
storageDocId,
docId,
rootDocBuffer,
rootDocId,
}: WorkerInput & { type: 'doc' }): Promise<WorkerOutput> {
@@ -489,18 +483,6 @@ async function crawlingDocData({
const yRootDoc = await getOrCreateCachedYDoc(rootDocBuffer);
let docId = null;
for (const [id, subdoc] of yRootDoc.getMap('spaces')) {
if (subdoc instanceof YDoc && storageDocId === subdoc.guid) {
docId = id;
break;
}
}
if (docId === null) {
return {};
}
let docExists: boolean | null = null;
(
@@ -1,5 +1,4 @@
import { DebugLogger } from '@affine/debug';
import { connectWebWorker } from '@affine/electron-api/web-worker';
import { MANUALLY_STOP, throwIfAborted } from '@toeverything/infra';
import type {
@@ -13,7 +12,6 @@ const logger = new DebugLogger('affine:indexer-worker');
export async function createWorker(abort: AbortSignal) {
let worker: Worker | null = null;
let electronApiCleanup: (() => void) | null = null;
while (throwIfAborted(abort)) {
try {
worker = await new Promise<Worker>((resolve, reject) => {
@@ -32,10 +30,6 @@ export async function createWorker(abort: AbortSignal) {
});
worker.postMessage({ type: 'init', msgId: 0 } as WorkerIngoingMessage);
if (BUILD_CONFIG.isElectron) {
electronApiCleanup = connectWebWorker(worker);
}
setTimeout(() => {
reject('timeout');
}, 1000 * 30 /* 30 sec */);
@@ -104,7 +98,6 @@ export async function createWorker(abort: AbortSignal) {
dispose: () => {
terminateAbort.abort(MANUALLY_STOP);
worker.terminate();
electronApiCleanup?.();
},
};
}
@@ -36,14 +36,14 @@ export type WorkerInput =
}
| {
type: 'doc';
storageDocId: string;
docId: string;
rootDocId: string;
rootDocBuffer: Uint8Array;
docBuffer: Uint8Array;
};
export interface WorkerOutput {
reindexDoc?: { docId: string; storageDocId: string }[];
reindexDoc?: { docId: string }[];
addedDoc?: {
id: string;
blocks: Document<BlockIndexSchema>[];
@@ -1,6 +1,5 @@
import { type Framework } from '@toeverything/infra';
import { RawFetchProvider } from '../cloud';
import { WorkspacesService } from '../workspace';
import { ImportTemplateDialog } from './entities/dialog';
import { TemplateDownloader } from './entities/downloader';
@@ -16,6 +15,6 @@ export function configureImportTemplateModule(framework: Framework) {
.entity(ImportTemplateDialog)
.service(TemplateDownloaderService)
.entity(TemplateDownloader, [TemplateDownloaderStore])
.store(TemplateDownloaderStore, [RawFetchProvider])
.store(TemplateDownloaderStore)
.service(ImportTemplateService, [WorkspacesService]);
}
@@ -18,7 +18,7 @@ export class ImportTemplateService extends Service {
this.workspacesService.open({
metadata: workspaceMetadata,
});
await workspace.engine.waitForRootDocReady();
await workspace.engine.doc.waitForDocReady(workspace.id); // wait for root doc ready
const [importedDoc] = await ZipTransformer.importDocs(
workspace.docCollection,
new Blob([docBinary], {
@@ -42,7 +42,7 @@ export class ImportTemplateService extends Service {
docBinary: Uint8Array
// todo: support doc mode on init
) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line @typescript-eslint/no-non-null-assertion
let docId: string = null!;
const { id: workspaceId } = await this.workspacesService.create(
flavour,
@@ -51,7 +51,10 @@ export class ImportTemplateService extends Service {
docCollection.meta.setName(workspaceName);
const doc = docCollection.createDoc();
docId = doc.id;
await docStorage.doc.set(doc.spaceDoc.guid, docBinary);
await docStorage.pushDocUpdate({
docId: doc.spaceDoc.guid,
bin: docBinary,
});
}
);
return { workspaceId, docId };
@@ -1,14 +1,12 @@
import { Store } from '@toeverything/infra';
import type { RawFetchProvider } from '../../cloud';
export class TemplateDownloaderStore extends Store {
constructor(private readonly fetchProvider: RawFetchProvider) {
constructor() {
super();
}
async download(snapshotUrl: string) {
const response = await this.fetchProvider.fetch(snapshotUrl, {
const response = await globalThis.fetch(snapshotUrl, {
priority: 'high',
} as any);
const arrayBuffer = await response.arrayBuffer();
+2 -2
View File
@@ -38,7 +38,7 @@ import { configureShareDocsModule } from './share-doc';
import { configureShareSettingModule } from './share-setting';
import {
configureCommonGlobalStorageImpls,
configureGlobalStorageModule,
configureStorageModule,
} from './storage';
import { configureSystemFontFamilyModule } from './system-font-family';
import { configureTagModule } from './tag';
@@ -55,7 +55,7 @@ export function configureCommonModules(framework: Framework) {
configureWorkspaceModule(framework);
configureDocModule(framework);
configureWorkspaceDBModule(framework);
configureGlobalStorageModule(framework);
configureStorageModule(framework);
configureGlobalContextModule(framework);
configureLifecycleModule(framework);
configureFeatureFlagModule(framework);
@@ -49,7 +49,6 @@ export class PDF extends Entity<AttachmentBlockModel> {
constructor() {
super();
this.renderer.listen();
this.disposables.push(() => this.pages.clear());
}
@@ -1,4 +1,8 @@
import { OpConsumer, transfer } from '@toeverything/infra/op';
import {
type MessageCommunicapable,
OpConsumer,
transfer,
} from '@toeverything/infra/op';
import type { Document } from '@toeverything/pdf-viewer';
import {
createPDFium,
@@ -23,6 +27,11 @@ import type { ClientOps } from './ops';
import type { PDFMeta, RenderPageOpts } from './types';
class PDFRendererBackend extends OpConsumer<ClientOps> {
constructor(port: MessageCommunicapable) {
super(port);
this.register('open', this.open.bind(this));
this.register('render', this.render.bind(this));
}
private readonly viewer$: Observable<Viewer> = from(
createPDFium().then(pdfium => {
return new Viewer(new Runtime(pdfium));
@@ -147,13 +156,6 @@ class PDFRendererBackend extends OpConsumer<ClientOps> {
return imageBitmap;
}
override listen(): void {
this.register('open', this.open.bind(this));
this.register('render', this.render.bind(this));
super.listen();
}
}
// @ts-expect-error how could we get correct postMessage signature for worker, exclude `window.postMessage`
new PDFRendererBackend(self).listen();
new PDFRendererBackend(self as MessageCommunicapable);
@@ -52,10 +52,7 @@ export const useEditor = (
// set sync engine priority target
useEffect(() => {
currentWorkspace.engine.doc.setPriority(pageId, 10);
return () => {
currentWorkspace.engine.doc.setPriority(pageId, 5);
};
return currentWorkspace.engine.doc.addPriority(pageId, 10);
}, [currentWorkspace, pageId]);
return { doc, editor, workspace: currentWorkspace, loading: !docListReady };
@@ -5,7 +5,7 @@ import {
catchErrorInto,
effect,
Entity,
exhaustMapSwitchUntilChanged,
exhaustMapWithTrailing,
fromPromise,
LiveData,
onComplete,
@@ -13,7 +13,7 @@ import {
} from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import bytes from 'bytes';
import { EMPTY, map, mergeMap } from 'rxjs';
import { EMPTY, mergeMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../../cloud';
import type { WorkspaceService } from '../../workspace';
@@ -68,49 +68,40 @@ export class WorkspaceQuota extends Entity {
}
revalidate = effect(
map(() => ({
workspaceId: this.workspaceService.workspace.id,
})),
exhaustMapSwitchUntilChanged(
(a, b) => a.workspaceId === b.workspaceId,
({ workspaceId }) => {
return fromPromise(async signal => {
if (!workspaceId) {
return; // no quota if no workspace
}
const data = await this.store.fetchWorkspaceQuota(
this.workspaceService.workspace.id,
signal
);
return { quota: data, used: data.usedSize };
}).pipe(
backoffRetry({
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
count: 3,
}),
mergeMap(data => {
if (data) {
const { quota, used } = data;
this.quota$.next(quota);
this.used$.next(used);
} else {
this.quota$.next(null);
this.used$.next(null);
}
return EMPTY;
}),
catchErrorInto(this.error$, error => {
logger.error('Failed to fetch workspace quota', error);
}),
onStart(() => this.isRevalidating$.setValue(true)),
onComplete(() => this.isRevalidating$.setValue(false))
exhaustMapWithTrailing(() => {
return fromPromise(async signal => {
const data = await this.store.fetchWorkspaceQuota(
this.workspaceService.workspace.id,
signal
);
}
)
return { quota: data, used: data.usedSize };
}).pipe(
backoffRetry({
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
count: 3,
}),
mergeMap(data => {
if (data) {
const { quota, used } = data;
this.quota$.next(quota);
this.used$.next(used);
} else {
this.quota$.next(null);
this.used$.next(null);
}
return EMPTY;
}),
catchErrorInto(this.error$, error => {
logger.error('Failed to fetch workspace quota', error);
}),
onStart(() => this.isRevalidating$.setValue(true)),
onComplete(() => this.isRevalidating$.setValue(false))
);
})
);
waitForRevalidation(signal?: AbortSignal) {
@@ -1,76 +0,0 @@
import { UserFriendlyError } from '@affine/graphql';
import type { DocMode } from '@blocksuite/affine/blocks';
import {
effect,
Entity,
fromPromise,
LiveData,
onComplete,
onStart,
} from '@toeverything/infra';
import { catchError, EMPTY, mergeMap, switchMap } from 'rxjs';
import type { ShareReaderStore } from '../stores/share-reader';
export class ShareReader extends Entity {
isLoading$ = new LiveData<boolean>(false);
error$ = new LiveData<UserFriendlyError | null>(null);
data$ = new LiveData<{
workspaceId: string;
docId: string;
workspaceBinary: Uint8Array;
docBinary: Uint8Array;
// Used for old share server-side mode control
publishMode?: DocMode;
} | null>(null);
constructor(private readonly store: ShareReaderStore) {
super();
}
loadShare = effect(
switchMap(
({
serverId,
workspaceId,
docId,
}: {
serverId: string;
workspaceId: string;
docId: string;
}) => {
return fromPromise(
this.store.loadShare(serverId, workspaceId, docId)
).pipe(
mergeMap(data => {
if (!data) {
this.data$.next(null);
} else {
this.data$.next({
workspaceId,
docId,
workspaceBinary: data.workspace,
docBinary: data.doc,
publishMode: data.publishMode ?? undefined,
});
}
return EMPTY;
}),
catchError((error: any) => {
this.error$.next(UserFriendlyError.fromAnyError(error));
return EMPTY;
}),
onStart(() => {
this.isLoading$.next(true);
this.data$.next(null);
this.error$.next(null);
}),
onComplete(() => {
this.isLoading$.next(false);
})
);
}
)
);
}

Some files were not shown because too many files have changed in this diff Show More