mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-25 22:38:56 +08:00
feat(core): new worker workspace engine (#9257)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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": "../../../common/infra" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
+4
-4
@@ -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();
|
||||
};
|
||||
|
||||
+1
-1
@@ -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 =
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
+4
-28
@@ -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>
|
||||
|
||||
+15
-4
@@ -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]);
|
||||
|
||||
|
||||
+1
-4
@@ -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}`,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user