feat(electron): move preload to infra (#3011)

This commit is contained in:
Alex Yang 2023-07-05 00:43:30 +08:00 committed by GitHub
parent 24be73ef63
commit dfbec46ded
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 372 additions and 188 deletions

View File

@ -121,3 +121,7 @@ runs:
run: node apps/electron/node_modules/electron/install.js
env:
ELECTRON_OVERRIDE_DIST_PATH: ./node_modules/.cache/electron
- name: Build Infra
shell: bash
run: yarn run build:infra

View File

@ -352,9 +352,6 @@ jobs:
env:
NATIVE_TEST: 'true'
- name: Build Infra
run: yarn run build:infra
- name: Build Plugins
run: yarn run build:plugins
@ -412,9 +409,6 @@ jobs:
with:
electron-install: false
- name: Build Infra
run: yarn run build:infra
- name: Unit Test
run: yarn nx test:coverage @affine/monorepo

View File

@ -123,9 +123,6 @@ jobs:
name: before-make-web-static
path: apps/electron/resources/web-static
- name: Build Infra
run: yarn run build:infra
- name: Build Plugins
run: yarn run build:plugins

View File

@ -123,9 +123,6 @@ jobs:
name: before-make-web-static
path: apps/electron/resources/web-static
- name: Build Infra
run: yarn run build:infra
- name: Build Plugins
run: yarn run build:plugins

View File

@ -4,6 +4,9 @@
# check lockfile is up to date
yarn install --mode=update-lockfile
# build infra code
yarn -T run build:infra
# lint staged files
yarn exec lint-staged

View File

@ -1,3 +1,4 @@
import type { RendererToHelper } from '@toeverything/infra/preload/electron';
import { AsyncCall } from 'async-call-rpc';
import { events, handlers } from './exposed';
@ -30,7 +31,7 @@ function setupRendererConnection(rendererPort: Electron.MessagePortMain) {
});
}
);
const rpc = AsyncCall<PeersAPIs.RendererToHelper>(
const rpc = AsyncCall<RendererToHelper>(
Object.fromEntries(flattenedHandlers),
{
channel: {

View File

@ -1,12 +1,16 @@
import type {
HelperToMain,
MainToHelper,
} from '@toeverything/infra/preload/electron';
import { AsyncCall } from 'async-call-rpc';
import { getExposedMeta } from './exposed';
const helperToMainServer: PeersAPIs.HelperToMain = {
const helperToMainServer: HelperToMain = {
getMeta: () => getExposedMeta(),
};
export const mainRPC = AsyncCall<PeersAPIs.MainToHelper>(helperToMainServer, {
export const mainRPC = AsyncCall<MainToHelper>(helperToMainServer, {
strict: {
unknownMessage: false,
},

View File

@ -1,5 +1,9 @@
import path from 'node:path';
import type {
HelperToMain,
MainToHelper,
} from '@toeverything/infra/preload/electron';
import { type _AsyncVersionOf, AsyncCall } from 'async-call-rpc';
import {
app,
@ -36,7 +40,7 @@ class HelperProcessManager {
#process: UtilityProcess;
// a rpc server for the main process -> helper process
rpc?: _AsyncVersionOf<PeersAPIs.HelperToMain>;
rpc?: _AsyncVersionOf<HelperToMain>;
static instance = new HelperProcessManager();
@ -86,13 +90,13 @@ class HelperProcessManager {
]);
const appMethods = pickAndBind(app, ['getPath']);
const mainToHelperServer: PeersAPIs.MainToHelper = {
const mainToHelperServer: MainToHelper = {
...dialogMethods,
...shellMethods,
...appMethods,
};
this.rpc = AsyncCall<PeersAPIs.HelperToMain>(mainToHelperServer, {
this.rpc = AsyncCall<HelperToMain>(mainToHelperServer, {
strict: {
// the channel is shared for other purposes as well so that we do not want to
// restrict to only JSONRPC messages

View File

@ -1,8 +1,10 @@
import { contextBridge, ipcRenderer } from 'electron';
(async () => {
const { appInfo, getAffineAPIs } = await import('./affine-apis');
const { apis, events } = getAffineAPIs();
const { appInfo, getElectronAPIs } = await import(
'@toeverything/infra/preload/electron'
);
const { apis, events } = getElectronAPIs();
contextBridge.exposeInMainWorld('appInfo', appInfo);
contextBridge.exposeInMainWorld('apis', apis);

View File

@ -1,35 +0,0 @@
declare namespace PeersAPIs {
import type { app, dialog, shell } from 'electron';
interface ExposedMeta {
handlers: [string, string[]][];
events: [string, string[]][];
}
// render <-> helper
interface RendererToHelper {
postEvent: (channel: string, ...args: any[]) => void;
}
interface HelperToRenderer {
[key: string]: (...args: any[]) => Promise<any>;
}
// helper <-> main
interface HelperToMain {
getMeta: () => ExposedMeta;
}
type MainToHelper = Pick<
typeof dialog & typeof shell & typeof app,
| 'showOpenDialog'
| 'showSaveDialog'
| 'openExternal'
| 'showItemInFolder'
| 'getPath'
>;
// render <-> main
// these are handled via IPC
// TODO: fix type
}

View File

@ -1,32 +1,50 @@
{
"name": "@toeverything/infra",
"main": "./src/index.ts",
"module": "./src/index.ts",
"type": "module",
"module": "./dist/index.mjs",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./src/index.ts"
},
"publishConfig": {
"module": "./dist/index.mjs",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"files": [
"dist"
]
"./core/*": {
"types": "./dist/core/*.d.ts",
"import": "./dist/core/*.js",
"require": "./dist/core/*.cjs"
},
"./preload/*": {
"types": "./dist/preload/*.d.ts",
"import": "./dist/preload/*.js",
"require": "./dist/preload/*.cjs"
}
},
"files": [
"dist"
],
"scripts": {
"build": "vite build",
"dev": "vite build --watch"
},
"devDependencies": {
"async-call-rpc": "^6.3.1",
"electron": "link:../../apps/electron/node_modules/electron",
"vite": "^4.3.9",
"vite-plugin-dts": "3.0.2"
},
"peerDependencies": {
"async-call-rpc": "*",
"electron": "*"
},
"peerDependenciesMeta": {
"async-call-rpc": {
"optional": true
},
"electron": {
"optional": true
}
},
"version": "0.7.0-canary.33"
}

3
packages/infra/preload/electron.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
/* eslint-disable */
// @ts-ignore
export * from '../dist/preload/electron';

View File

@ -0,0 +1,3 @@
/* eslint-disable */
/// <reference types="../dist/preload/electron.d.ts" />
export * from '../dist/preload/electron.js';

View File

@ -0,0 +1,74 @@
/**
* The MIT License (MIT)
*
* Copyright (c) 2018 Andy Wermke
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
export type EventMap = {
[key: string]: (...args: any[]) => void;
};
/**
* Type-safe event emitter.
*
* Use it like this:
*
* ```typescript
* type MyEvents = {
* error: (error: Error) => void;
* message: (from: string, content: string) => void;
* }
*
* const myEmitter = new EventEmitter() as TypedEmitter<MyEvents>;
*
* myEmitter.emit("error", "x") // <- Will catch this type error;
* ```
*
* Lifecycle:
* invoke -> handle -> emit -> on/once
*/
export interface TypedEventEmitter<Events extends EventMap> {
addListener<E extends keyof Events>(event: E, listener: Events[E]): this;
on<E extends keyof Events>(event: E, listener: Events[E]): this;
once<E extends keyof Events>(event: E, listener: Events[E]): this;
off<E extends keyof Events>(event: E, listener: Events[E]): this;
removeAllListeners<E extends keyof Events>(event?: E): this;
removeListener<E extends keyof Events>(event: E, listener: Events[E]): this;
emit<E extends keyof Events>(
event: E,
...args: Parameters<Events[E]>
): boolean;
// The sloppy `eventNames()` return type is to mitigate type incompatibilities - see #5
eventNames(): (keyof Events | string | symbol)[];
rawListeners<E extends keyof Events>(event: E): Events[E][];
listeners<E extends keyof Events>(event: E): Events[E][];
listenerCount<E extends keyof Events>(event: E): number;
handle<E extends keyof Events>(event: E, handler: Events[E]): this;
invoke<E extends keyof Events>(
event: E,
...args: Parameters<Events[E]>
): Promise<ReturnType<Events[E]>>;
getMaxListeners(): number;
setMaxListeners(maxListeners: number): this;
}

View File

@ -1,145 +1,51 @@
export interface WorkspaceMeta {
id: string;
mainDBPath: string;
secondaryDBPath?: string; // assume there will be only one
}
export type PrimitiveHandlers = (...args: any[]) => Promise<any>;
type TODO = any;
export abstract class HandlerManager<
Namespace extends string,
Handlers extends Record<string, PrimitiveHandlers>
> {
abstract readonly app: TODO;
abstract readonly namespace: Namespace;
abstract readonly handlers: Handlers;
}
type DBHandlers = {
getDocAsUpdates: (
workspaceId: string,
subdocId?: string
) => Promise<Uint8Array>;
applyDocUpdate: (
id: string,
update: Uint8Array,
subdocId?: string
) => Promise<void>;
addBlob: (
workspaceId: string,
key: string,
data: Uint8Array
) => Promise<void>;
getBlob: (workspaceId: string, key: string) => Promise<any>;
deleteBlob: (workspaceId: string, key: string) => Promise<void>;
getBlobKeys: (workspaceId: string) => Promise<any>;
getDefaultStorageLocation: () => Promise<string>;
};
import type {
ClipboardHandlers,
DBHandlers,
DebugHandlers,
DialogHandlers,
ExportHandlers,
UIHandlers,
UpdaterHandlers,
WorkspaceHandlers,
} from './type';
import { HandlerManager } from './type';
export abstract class DBHandlerManager extends HandlerManager<
'db',
DBHandlers
> {}
type DebugHandlers = {
revealLogFile: () => Promise<string>;
logFilePath: () => Promise<string>;
};
export abstract class DebugHandlerManager extends HandlerManager<
'debug',
DebugHandlers
> {}
type DialogHandlers = {
revealDBFile: (workspaceId: string) => Promise<any>;
loadDBFile: () => Promise<any>;
saveDBFileAs: (workspaceId: string) => Promise<any>;
moveDBFile: (workspaceId: string, dbFileLocation?: string) => Promise<any>;
selectDBFileLocation: () => Promise<any>;
setFakeDialogResult: (result: any) => Promise<any>;
};
export abstract class DialogHandlerManager extends HandlerManager<
'dialog',
DialogHandlers
> {}
type UIHandlers = {
handleThemeChange: (theme: 'system' | 'light' | 'dark') => Promise<any>;
handleSidebarVisibilityChange: (visible: boolean) => Promise<any>;
handleMinimizeApp: () => Promise<any>;
handleMaximizeApp: () => Promise<any>;
handleCloseApp: () => Promise<any>;
getGoogleOauthCode: () => Promise<any>;
};
export abstract class UIHandlerManager extends HandlerManager<
'ui',
UIHandlers
> {}
type ClipboardHandlers = {
copyAsImageFromString: (dataURL: string) => Promise<void>;
};
export abstract class ClipboardHandlerManager extends HandlerManager<
'clipboard',
ClipboardHandlers
> {}
type ExportHandlers = {
savePDFFileAs: (title: string) => Promise<any>;
};
export abstract class ExportHandlerManager extends HandlerManager<
'export',
ExportHandlers
> {}
type UpdaterHandlers = {
currentVersion: () => Promise<any>;
quitAndInstall: () => Promise<any>;
checkForUpdatesAndNotify: () => Promise<any>;
};
export abstract class UpdaterHandlerManager extends HandlerManager<
'updater',
UpdaterHandlers
> {}
type WorkspaceHandlers = {
list: () => Promise<[workspaceId: string, meta: WorkspaceMeta][]>;
delete: (id: string) => Promise<void>;
getMeta: (id: string) => Promise<WorkspaceMeta>;
};
export abstract class WorkspaceHandlerManager extends HandlerManager<
'workspace',
WorkspaceHandlers
> {}
export type UnwrapManagerHandlerToServerSide<
ElectronEvent extends {
frameId: number;
processId: number;
},
Manager extends HandlerManager<string, Record<string, PrimitiveHandlers>>
> = {
[K in keyof Manager['handlers']]: Manager['handlers'][K] extends (
...args: infer Args
) => Promise<infer R>
? (event: ElectronEvent, ...args: Args) => Promise<R>
: never;
};
export type UnwrapManagerHandlerToClientSide<
Manager extends HandlerManager<string, Record<string, PrimitiveHandlers>>
> = {
[K in keyof Manager['handlers']]: Manager['handlers'][K] extends (
...args: infer Args
) => Promise<infer R>
? (...args: Args) => Promise<R>
: never;
};

View File

@ -1 +1,2 @@
export * from './handler';
export * from './type';

View File

@ -1,14 +1,38 @@
// NOTE: we will generate preload types from this file
// Please add modules to `external` in `rollupOptions` to avoid wrong bundling.
import { AsyncCall, type EventBasedChannel } from 'async-call-rpc';
import type { app, dialog, shell } from 'electron';
import { ipcRenderer } from 'electron';
import { Subject } from 'rxjs';
type ExposedMeta = {
handlers: [namespace: string, handlerNames: string[]][];
events: [namespace: string, eventNames: string[]][];
};
export interface ExposedMeta {
handlers: [string, string[]][];
events: [string, string[]][];
}
export function getAffineAPIs() {
// render <-> helper
export interface RendererToHelper {
postEvent: (channel: string, ...args: any[]) => void;
}
export interface HelperToRenderer {
[key: string]: (...args: any[]) => Promise<any>;
}
// helper <-> main
export interface HelperToMain {
getMeta: () => ExposedMeta;
}
export type MainToHelper = Pick<
typeof dialog & typeof shell & typeof app,
| 'showOpenDialog'
| 'showSaveDialog'
| 'openExternal'
| 'showItemInFolder'
| 'getPath'
>;
export function getElectronAPIs() {
const mainAPIs = getMainAPIs();
const helperAPIs = getHelperAPIs();
@ -126,13 +150,13 @@ function getHelperAPIs() {
return val ? JSON.parse(val) : null;
})();
const rendererToHelperServer: PeersAPIs.RendererToHelper = {
const rendererToHelperServer: RendererToHelper = {
postEvent: (channel, ...args) => {
events$.next({ channel, args });
},
};
const rpc = AsyncCall<PeersAPIs.HelperToRenderer>(rendererToHelperServer, {
const rpc = AsyncCall<HelperToRenderer>(rendererToHelperServer, {
channel: helperPort$.then(helperPort =>
createMessagePortChannel(helperPort)
),
@ -157,10 +181,10 @@ function getHelperAPIs() {
};
const setup = (meta: ExposedMeta) => {
const { handlers: handlersMeta, events: eventsMeta } = meta;
const { handlers, events } = meta;
const helperHandlers = Object.fromEntries(
handlersMeta.map(([namespace, functionNames]) => {
handlers.map(([namespace, functionNames]) => {
return [
namespace,
Object.fromEntries(
@ -173,7 +197,7 @@ function getHelperAPIs() {
);
const helperEvents = Object.fromEntries(
eventsMeta.map(([namespace, eventNames]) => {
events.map(([namespace, eventNames]) => {
return [
namespace,
Object.fromEntries(

162
packages/infra/src/type.ts Normal file
View File

@ -0,0 +1,162 @@
import type { TypedEventEmitter } from './core/event-emitter';
export abstract class HandlerManager<
Namespace extends string,
Handlers extends Record<string, PrimitiveHandlers>
> {
static instance: HandlerManager<string, Record<string, PrimitiveHandlers>>;
private _app: App<Namespace, Handlers>;
private _namespace: Namespace;
private _handlers: Handlers;
constructor() {
throw new Error('Method not implemented.');
}
private _initialized = false;
registerHandlers(handlers: Handlers) {
if (this._initialized) {
throw new Error('Already initialized');
}
this._handlers = handlers;
for (const [name, handler] of Object.entries(this._handlers)) {
this._app.handle(`${this._namespace}:${name}`, (async (...args: any[]) =>
handler(...args)) as any);
}
this._initialized = true;
}
invokeHandler<K extends keyof Handlers>(
name: K,
...args: Parameters<Handlers[K]>
): Promise<ReturnType<Handlers[K]>> {
return this._handlers[name](...args);
}
static getInstance(): HandlerManager<
string,
Record<string, PrimitiveHandlers>
> {
throw new Error('Method not implemented.');
}
}
export interface WorkspaceMeta {
id: string;
mainDBPath: string;
secondaryDBPath?: string; // assume there will be only one
}
export type PrimitiveHandlers = (...args: any[]) => Promise<any>;
export type DBHandlers = {
getDocAsUpdates: (
workspaceId: string,
subdocId?: string
) => Promise<Uint8Array>;
applyDocUpdate: (
id: string,
update: Uint8Array,
subdocId?: string
) => Promise<void>;
addBlob: (
workspaceId: string,
key: string,
data: Uint8Array
) => Promise<void>;
getBlob: (workspaceId: string, key: string) => Promise<any>;
deleteBlob: (workspaceId: string, key: string) => Promise<void>;
getBlobKeys: (workspaceId: string) => Promise<any>;
getDefaultStorageLocation: () => Promise<string>;
};
export type DebugHandlers = {
revealLogFile: () => Promise<string>;
logFilePath: () => Promise<string>;
};
export type DialogHandlers = {
revealDBFile: (workspaceId: string) => Promise<any>;
loadDBFile: () => Promise<any>;
saveDBFileAs: (workspaceId: string) => Promise<any>;
moveDBFile: (workspaceId: string, dbFileLocation?: string) => Promise<any>;
selectDBFileLocation: () => Promise<any>;
setFakeDialogResult: (result: any) => Promise<any>;
};
export type UIHandlers = {
handleThemeChange: (theme: 'system' | 'light' | 'dark') => Promise<any>;
handleSidebarVisibilityChange: (visible: boolean) => Promise<any>;
handleMinimizeApp: () => Promise<any>;
handleMaximizeApp: () => Promise<any>;
handleCloseApp: () => Promise<any>;
getGoogleOauthCode: () => Promise<any>;
};
export type ClipboardHandlers = {
copyAsImageFromString: (dataURL: string) => Promise<void>;
};
export type ExportHandlers = {
savePDFFileAs: (title: string) => Promise<any>;
};
export type UpdaterHandlers = {
currentVersion: () => Promise<any>;
quitAndInstall: () => Promise<any>;
checkForUpdatesAndNotify: () => Promise<any>;
};
export type WorkspaceHandlers = {
list: () => Promise<[workspaceId: string, meta: WorkspaceMeta][]>;
delete: (id: string) => Promise<void>;
getMeta: (id: string) => Promise<WorkspaceMeta>;
};
export type EventMap = DBHandlers &
DebugHandlers &
DialogHandlers &
UIHandlers &
ClipboardHandlers &
ExportHandlers &
UpdaterHandlers &
WorkspaceHandlers;
export type UnwrapManagerHandlerToServerSide<
ElectronEvent extends {
frameId: number;
processId: number;
},
Manager extends HandlerManager<string, Record<string, PrimitiveHandlers>>
> = Manager extends HandlerManager<infer _, infer Handlers>
? {
[K in keyof Handlers]: Handlers[K] extends (
...args: infer Args
) => Promise<infer R>
? (event: ElectronEvent, ...args: Args) => Promise<R>
: never;
}
: never;
export type UnwrapManagerHandlerToClientSide<
Manager extends HandlerManager<string, Record<string, PrimitiveHandlers>>
> = Manager extends HandlerManager<infer _, infer Handlers>
? {
[K in keyof Handlers]: Handlers[K] extends (
...args: infer Args
) => Promise<infer R>
? (...args: Args) => Promise<R>
: never;
}
: never;
/**
* @internal
*/
export type App<
Namespace extends string,
Handlers extends Record<string, PrimitiveHandlers>
> = TypedEventEmitter<{
[K in keyof Handlers as `${Namespace}:${K & string}`]: Handlers[K];
}>;

View File

@ -8,13 +8,19 @@ const root = fileURLToPath(new URL('.', import.meta.url));
export default defineConfig({
build: {
minify: false,
lib: {
entry: {
index: resolve(root, 'src/index.ts'),
'core/event-emitter': resolve(root, 'src/core/event-emitter.ts'),
'preload/electron': resolve(root, 'src/preload/electron.ts'),
},
formats: ['es', 'cjs'],
name: 'AffineInfra',
},
rollupOptions: {
external: ['electron', 'async-call-rpc', 'rxjs'],
},
},
plugins: [
dts({

View File

@ -11436,8 +11436,18 @@ __metadata:
version: 0.0.0-use.local
resolution: "@toeverything/infra@workspace:packages/infra"
dependencies:
async-call-rpc: ^6.3.1
electron: "link:../../apps/electron/node_modules/electron"
vite: ^4.3.9
vite-plugin-dts: 3.0.2
peerDependencies:
async-call-rpc: "*"
electron: "*"
peerDependenciesMeta:
async-call-rpc:
optional: true
electron:
optional: true
languageName: unknown
linkType: soft
@ -17052,6 +17062,12 @@ __metadata:
languageName: node
linkType: hard
"electron@link:../../apps/electron/node_modules/electron::locator=%40toeverything%2Finfra%40workspace%3Apackages%2Finfra":
version: 0.0.0-use.local
resolution: "electron@link:../../apps/electron/node_modules/electron::locator=%40toeverything%2Finfra%40workspace%3Apackages%2Finfra"
languageName: node
linkType: soft
"electron@npm:^25.2.0":
version: 25.2.0
resolution: "electron@npm:25.2.0"