mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-11 20:08:37 +00:00
feat: isolated plugin system (#2742)
This commit is contained in:
@@ -7,6 +7,7 @@ import { registerEvents } from './events';
|
||||
import { registerHandlers } from './handlers';
|
||||
import { logger } from './logger';
|
||||
import { restoreOrCreateWindow } from './main-window';
|
||||
import { registerPlugin } from './plugin';
|
||||
import { registerProtocol } from './protocol';
|
||||
import { registerUpdater } from './updater';
|
||||
|
||||
@@ -60,6 +61,7 @@ app
|
||||
.then(registerProtocol)
|
||||
.then(registerHandlers)
|
||||
.then(registerEvents)
|
||||
.then(registerPlugin)
|
||||
.then(restoreOrCreateWindow)
|
||||
.then(createApplicationMenu)
|
||||
.then(registerUpdater)
|
||||
|
||||
57
apps/electron/layers/main/src/plugin.ts
Normal file
57
apps/electron/layers/main/src/plugin.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { join } from 'node:path';
|
||||
import { Worker } from 'node:worker_threads';
|
||||
|
||||
import { AsyncCall } from 'async-call-rpc';
|
||||
import { ipcMain } from 'electron';
|
||||
|
||||
import { ThreadWorkerChannel } from './utils';
|
||||
|
||||
declare global {
|
||||
// fixme(himself65):
|
||||
// remove this when bookmark block plugin is migrated to plugin-infra
|
||||
// eslint-disable-next-line no-var
|
||||
var asyncCall: Record<string, (...args: any) => PromiseLike<any>>;
|
||||
}
|
||||
|
||||
export async function registerPlugin() {
|
||||
const pluginWorkerPath = join(__dirname, './workers/plugin.worker.js');
|
||||
const asyncCall = AsyncCall<
|
||||
Record<string, (...args: any) => PromiseLike<any>>
|
||||
>(
|
||||
{},
|
||||
{
|
||||
channel: new ThreadWorkerChannel(new Worker(pluginWorkerPath)),
|
||||
}
|
||||
);
|
||||
globalThis.asyncCall = asyncCall;
|
||||
await import('@toeverything/plugin-infra/manager').then(
|
||||
({ rootStore, affinePluginsAtom }) => {
|
||||
const bookmarkPluginPath = join(
|
||||
process.env.PLUGIN_DIR ?? '../../plugins',
|
||||
'./bookmark-block/index.mjs'
|
||||
);
|
||||
import(bookmarkPluginPath);
|
||||
let dispose: () => void = () => {
|
||||
// noop
|
||||
};
|
||||
rootStore.sub(affinePluginsAtom, () => {
|
||||
dispose();
|
||||
const plugins = rootStore.get(affinePluginsAtom);
|
||||
Object.values(plugins).forEach(plugin => {
|
||||
plugin.definition.commands.forEach(command => {
|
||||
ipcMain.handle(command, (event, ...args) =>
|
||||
asyncCall[command](...args)
|
||||
);
|
||||
});
|
||||
});
|
||||
dispose = () => {
|
||||
Object.values(plugins).forEach(plugin => {
|
||||
plugin.definition.commands.forEach(command => {
|
||||
ipcMain.removeHandler(command);
|
||||
});
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,9 @@
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { app, BrowserWindow, nativeTheme } from 'electron';
|
||||
|
||||
import type { NamespaceHandlers } from '../type';
|
||||
import { isMacOS } from '../utils';
|
||||
import { getGoogleOauthCode } from './google-auth';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const handlers = require(join(
|
||||
process.env.PLUGIN_DIR ?? '../../plugins',
|
||||
'./bookmark-block/server'
|
||||
)).default as NamespaceHandlers;
|
||||
|
||||
export const uiHandlers = {
|
||||
handleThemeChange: async (_, theme: (typeof nativeTheme)['themeSource']) => {
|
||||
nativeTheme.themeSource = theme;
|
||||
@@ -47,5 +39,12 @@ export const uiHandlers = {
|
||||
getGoogleOauthCode: async () => {
|
||||
return getGoogleOauthCode();
|
||||
},
|
||||
...handlers,
|
||||
/**
|
||||
* @deprecated Remove this when bookmark block plugin is migrated to plugin-infra
|
||||
*/
|
||||
getBookmarkDataByLink: async (_, link: string) => {
|
||||
return globalThis.asyncCall[
|
||||
'com.blocksuite.bookmark-block.get-bookmark-data-by-link'
|
||||
](link);
|
||||
},
|
||||
} satisfies NamespaceHandlers;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type { MessagePort, Worker } from 'node:worker_threads';
|
||||
|
||||
import type { EventBasedChannel } from 'async-call-rpc';
|
||||
|
||||
export function getTime() {
|
||||
return new Date().getTime();
|
||||
}
|
||||
@@ -9,3 +13,33 @@ export const isMacOS = () => {
|
||||
export const isWindows = () => {
|
||||
return process.platform === 'win32';
|
||||
};
|
||||
|
||||
export class ThreadWorkerChannel implements EventBasedChannel {
|
||||
constructor(private worker: Worker) {}
|
||||
|
||||
on(listener: (data: unknown) => void) {
|
||||
this.worker.addListener('message', listener);
|
||||
return () => {
|
||||
this.worker.removeListener('message', listener);
|
||||
};
|
||||
}
|
||||
|
||||
send(data: unknown) {
|
||||
this.worker.postMessage(data);
|
||||
}
|
||||
}
|
||||
|
||||
export class MessagePortChannel implements EventBasedChannel {
|
||||
constructor(private port: MessagePort) {}
|
||||
|
||||
on(listener: (data: unknown) => void) {
|
||||
this.port.addListener('message', listener);
|
||||
return () => {
|
||||
this.port.removeListener('message', listener);
|
||||
};
|
||||
}
|
||||
|
||||
send(data: unknown) {
|
||||
this.port.postMessage(data);
|
||||
}
|
||||
}
|
||||
|
||||
43
apps/electron/layers/main/src/workers/plugin.worker.ts
Normal file
43
apps/electron/layers/main/src/workers/plugin.worker.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { join } from 'node:path';
|
||||
import { parentPort } from 'node:worker_threads';
|
||||
|
||||
import { AsyncCall } from 'async-call-rpc';
|
||||
|
||||
import { MessagePortChannel } from '../utils';
|
||||
|
||||
const commandProxy: Record<string, (...args: any[]) => Promise<any>> = {};
|
||||
|
||||
if (!parentPort) {
|
||||
throw new Error('parentPort is undefined');
|
||||
}
|
||||
|
||||
AsyncCall(commandProxy, {
|
||||
channel: new MessagePortChannel(parentPort),
|
||||
});
|
||||
|
||||
import('@toeverything/plugin-infra/manager').then(
|
||||
({ rootStore, affinePluginsAtom }) => {
|
||||
const bookmarkPluginPath = join(
|
||||
process.env.PLUGIN_DIR ?? '../../../plugins',
|
||||
'./bookmark-block/index.mjs'
|
||||
);
|
||||
|
||||
import(bookmarkPluginPath);
|
||||
rootStore.sub(affinePluginsAtom, () => {
|
||||
const plugins = rootStore.get(affinePluginsAtom);
|
||||
Object.values(plugins).forEach(plugin => {
|
||||
if (plugin.serverAdapter) {
|
||||
plugin.serverAdapter({
|
||||
registerCommand: (command, fn) => {
|
||||
console.log('register command', command);
|
||||
commandProxy[command] = fn;
|
||||
},
|
||||
unregisterCommand: command => {
|
||||
delete commandProxy[command];
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -7,36 +7,21 @@ import { contextBridge, ipcRenderer } from 'electron';
|
||||
contextBridge.exposeInMainWorld('appInfo', affineApis.appInfo);
|
||||
|
||||
// Credit to microsoft/vscode
|
||||
function validateIPC(channel: string) {
|
||||
if (!channel || !channel.startsWith('affine:')) {
|
||||
throw new Error(`Unsupported event IPC channel '${channel}'`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const globals = {
|
||||
ipcRenderer: {
|
||||
send(channel: string, ...args: any[]) {
|
||||
if (validateIPC(channel)) {
|
||||
ipcRenderer.send(channel, ...args);
|
||||
}
|
||||
ipcRenderer.send(channel, ...args);
|
||||
},
|
||||
|
||||
invoke(channel: string, ...args: any[]) {
|
||||
if (validateIPC(channel)) {
|
||||
return ipcRenderer.invoke(channel, ...args);
|
||||
}
|
||||
return void 0;
|
||||
return ipcRenderer.invoke(channel, ...args);
|
||||
},
|
||||
|
||||
on(
|
||||
channel: string,
|
||||
listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void
|
||||
) {
|
||||
if (validateIPC(channel)) {
|
||||
ipcRenderer.on(channel, listener);
|
||||
}
|
||||
ipcRenderer.on(channel, listener);
|
||||
return this;
|
||||
},
|
||||
|
||||
@@ -44,9 +29,7 @@ import { contextBridge, ipcRenderer } from 'electron';
|
||||
channel: string,
|
||||
listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void
|
||||
) {
|
||||
if (validateIPC(channel)) {
|
||||
ipcRenderer.once(channel, listener);
|
||||
}
|
||||
ipcRenderer.once(channel, listener);
|
||||
return this;
|
||||
},
|
||||
|
||||
@@ -54,9 +37,7 @@ import { contextBridge, ipcRenderer } from 'electron';
|
||||
channel: string,
|
||||
listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void
|
||||
) {
|
||||
if (validateIPC(channel)) {
|
||||
ipcRenderer.removeListener(channel, listener);
|
||||
}
|
||||
ipcRenderer.removeListener(channel, listener);
|
||||
return this;
|
||||
},
|
||||
},
|
||||
@@ -65,6 +46,6 @@ import { contextBridge, ipcRenderer } from 'electron';
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('affine', globals);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error('Failed to expose affine APIs to window object!', error);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -55,6 +55,8 @@
|
||||
"zx": "^7.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@toeverything/plugin-infra": "workspace:*",
|
||||
"async-call-rpc": "^6.3.1",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"electron-updater": "^5.3.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
#!/usr/bin/env zx
|
||||
import 'zx/globals';
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as esbuild from 'esbuild';
|
||||
|
||||
import { config } from './common.mjs';
|
||||
import { config, rootDir } from './common.mjs';
|
||||
|
||||
const NODE_ENV =
|
||||
process.env.NODE_ENV === 'development' ? 'development' : 'production';
|
||||
@@ -16,6 +19,11 @@ if (process.platform === 'win32') {
|
||||
async function buildLayers() {
|
||||
const common = config();
|
||||
await esbuild.build(common.preload);
|
||||
console.log('Build plugin infra');
|
||||
spawnSync('yarn', ['build'], {
|
||||
stdio: 'inherit',
|
||||
cwd: resolve(rootDir, './packages/plugin-infra'),
|
||||
});
|
||||
|
||||
console.log('Build plugins');
|
||||
await import('./plugins/build-plugins.mjs');
|
||||
|
||||
@@ -41,12 +41,20 @@ export const config = () => {
|
||||
electronDir,
|
||||
'./layers/main/src/workers/merge-update.worker.ts'
|
||||
),
|
||||
resolve(electronDir, './layers/main/src/workers/plugin.worker.ts'),
|
||||
],
|
||||
outdir: resolve(electronDir, './dist/layers/main'),
|
||||
bundle: true,
|
||||
target: `node${NODE_MAJOR_VERSION}`,
|
||||
platform: 'node',
|
||||
external: ['electron', 'yjs', 'electron-updater', '@toeverything/infra'],
|
||||
external: [
|
||||
'electron',
|
||||
'yjs',
|
||||
'better-sqlite3',
|
||||
'electron-updater',
|
||||
'@toeverything/plugin-infra',
|
||||
'async-call-rpc',
|
||||
],
|
||||
define: define,
|
||||
format: 'cjs',
|
||||
loader: {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* eslint-disable no-async-promise-executor */
|
||||
import { spawn } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import path, { resolve } from 'node:path';
|
||||
|
||||
import electronPath from 'electron';
|
||||
import * as esbuild from 'esbuild';
|
||||
|
||||
import { config, electronDir } from './common.mjs';
|
||||
import { config, electronDir, rootDir } from './common.mjs';
|
||||
|
||||
// this means we don't spawn electron windows, mainly for testing
|
||||
const watchMode = process.argv.includes('--watch');
|
||||
@@ -69,6 +69,10 @@ function spawnOrReloadElectron() {
|
||||
const common = config();
|
||||
|
||||
async function watchPlugins() {
|
||||
spawn('yarn', ['dev'], {
|
||||
stdio: 'inherit',
|
||||
cwd: resolve(rootDir, './packages/plugin-infra'),
|
||||
});
|
||||
await import('./plugins/dev-plugins.mjs');
|
||||
}
|
||||
|
||||
|
||||
@@ -5,5 +5,16 @@ import { definePluginServerConfig } from './utils.mjs';
|
||||
|
||||
await build({
|
||||
...definePluginServerConfig('bookmark-block'),
|
||||
external: ['cheerio', 'electron', 'puppeteer', 'foxact'],
|
||||
external: [
|
||||
// server.ts
|
||||
'link-preview-js',
|
||||
// ui.ts
|
||||
'@toeverything/plugin-infra',
|
||||
'@affine/component',
|
||||
'@blocksuite/store',
|
||||
'@blocksuite/blocks',
|
||||
'react',
|
||||
'react-dom',
|
||||
'foxact',
|
||||
],
|
||||
});
|
||||
|
||||
@@ -5,7 +5,18 @@ import { definePluginServerConfig } from './utils.mjs';
|
||||
|
||||
const plugin = await context({
|
||||
...definePluginServerConfig('bookmark-block'),
|
||||
external: ['cheerio', 'electron', 'puppeteer', 'foxact'],
|
||||
external: [
|
||||
// server.ts
|
||||
'link-preview-js',
|
||||
// ui.ts
|
||||
'@toeverything/plugin-infra',
|
||||
'@affine/component',
|
||||
'@blocksuite/store',
|
||||
'@blocksuite/blocks',
|
||||
'react',
|
||||
'react-dom',
|
||||
'foxact',
|
||||
],
|
||||
});
|
||||
|
||||
await plugin.watch();
|
||||
|
||||
@@ -18,12 +18,17 @@ export const pluginDir = resolve(rootDir, 'plugins');
|
||||
*/
|
||||
export function definePluginServerConfig(pluginDirName) {
|
||||
const pluginRootDir = resolve(pluginDir, pluginDirName);
|
||||
const serverEntryFile = resolve(pluginRootDir, 'src/server.ts');
|
||||
const mainEntryFile = resolve(pluginRootDir, 'src/index.ts');
|
||||
const serverOutputDir = resolve(electronOutputDir, pluginDirName);
|
||||
return {
|
||||
entryPoints: [serverEntryFile],
|
||||
platform: 'node',
|
||||
entryPoints: [mainEntryFile],
|
||||
platform: 'neutral',
|
||||
format: 'esm',
|
||||
outExtension: {
|
||||
'.js': '.mjs',
|
||||
},
|
||||
outdir: serverOutputDir,
|
||||
bundle: true,
|
||||
splitting: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,10 +17,7 @@
|
||||
"exclude": ["node_modules", "out", "dist"],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tests/tsconfig.json"
|
||||
"path": "../../packages/plugin-infra"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/native"
|
||||
@@ -28,6 +25,14 @@
|
||||
{
|
||||
"path": "../../packages/infra"
|
||||
},
|
||||
|
||||
// Tests
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tests/tsconfig.json"
|
||||
},
|
||||
{ "path": "../../tests/kit" }
|
||||
],
|
||||
"ts-node": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ESNext",
|
||||
|
||||
Reference in New Issue
Block a user