refactor(electron): create electron api package (#5334)

This commit is contained in:
EYHN
2023-12-27 06:38:37 +00:00
parent ce17daba42
commit 4e861d8118
53 changed files with 307 additions and 690 deletions

View File

@@ -20,6 +20,7 @@
},
"dependencies": {
"@affine/debug": "workspace:*",
"@affine/electron-api": "workspace:*",
"@affine/graphql": "workspace:*",
"@affine/i18n": "workspace:*",
"@affine/workspace": "workspace:*",

View File

@@ -1,3 +1,4 @@
import { apis } from '@affine/electron-api';
import { ThemeProvider as NextThemeProvider, useTheme } from 'next-themes';
import type { PropsWithChildren } from 'react';
import { memo, useRef } from 'react';
@@ -10,7 +11,7 @@ const DesktopThemeSync = memo(function DesktopThemeSync() {
const onceRef = useRef(false);
if (lastThemeRef.current !== theme || !onceRef.current) {
if (environment.isDesktop && theme) {
window.apis?.ui
apis?.ui
.handleThemeChange(theme as 'dark' | 'light' | 'system')
.catch(err => {
console.error(err);

View File

@@ -14,6 +14,9 @@
{
"path": "../../frontend/hooks"
},
{
"path": "../../frontend/electron-api"
},
{ "path": "../../frontend/workspace" },
{
"path": "../../common/debug"

View File

@@ -20,6 +20,7 @@
"@affine/cmdk": "workspace:*",
"@affine/component": "workspace:*",
"@affine/debug": "workspace:*",
"@affine/electron-api": "workspace:*",
"@affine/env": "workspace:*",
"@affine/graphql": "workspace:*",
"@affine/i18n": "workspace:*",

View File

@@ -1,3 +1,4 @@
import { apis } from '@affine/electron-api';
import type { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ResetIcon } from '@blocksuite/icons';
import { updateReadyAtom } from '@toeverything/hooks/use-app-updater';
@@ -21,7 +22,7 @@ export function registerAffineUpdatesCommands({
label: t['com.affine.cmdk.affine.restart-to-upgrade'](),
preconditionStrategy: () => !!store.get(updateReadyAtom),
run() {
window.apis?.updater.quitAndInstall().catch(err => {
apis?.updater.quitAndInstall().catch(err => {
// TODO: add error toast here
console.error(err);
});

View File

@@ -1,3 +1,4 @@
import { apis } from '@affine/electron-api';
import { fetchWithTraceReport } from '@affine/graphql';
import { Turnstile } from '@marsidev/react-turnstile';
import { atom, useAtom, useSetAtom } from 'jotai';
@@ -32,7 +33,7 @@ const generateChallengeResponse = async (challenge: string) => {
return undefined;
}
return await window.apis?.ui?.getChallengeResponse(challenge);
return await apis?.ui?.getChallengeResponse(challenge);
};
const captchaAtom = atom<string | undefined>(undefined);

View File

@@ -5,6 +5,7 @@ import {
Modal,
} from '@affine/component/ui/modal';
import { DebugLogger } from '@affine/debug';
import { apis } from '@affine/electron-api';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { workspaceManagerAtom } from '@affine/workspace/atom';
@@ -14,7 +15,6 @@ import {
buildShowcaseWorkspace,
initEmptyPage,
} from '@toeverything/infra/blocksuite';
import type { LoadDBFileResult } from '@toeverything/infra/type';
import { useAtomValue } from 'jotai';
import type { KeyboardEvent } from 'react';
import { useLayoutEffect } from 'react';
@@ -112,12 +112,12 @@ export const CreateWorkspaceModal = ({
// after it is done, it will effectively add a new workspace to app-data folder
// so after that, we will be able to load it via importLocalWorkspace
(async () => {
if (!window.apis) {
if (!apis) {
return;
}
logger.info('load db file');
setStep(undefined);
const result: LoadDBFileResult = await window.apis.dialog.loadDBFile();
const result = await apis.dialog.loadDBFile();
if (result.workspaceId && !canceled) {
workspaceManager._addLocalWorkspace(result.workspaceId);
onCreate(result.workspaceId);

View File

@@ -1,10 +1,10 @@
import { pushNotificationAtom } from '@affine/component/notification-center';
import { SettingRow } from '@affine/component/setting-components';
import { Button } from '@affine/component/ui/button';
import { apis } from '@affine/electron-api';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { Workspace, WorkspaceMetadata } from '@affine/workspace';
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
import type { SaveDBFileResult } from '@toeverything/infra/type';
import { useSetAtom } from 'jotai';
import { useState } from 'react';
@@ -30,8 +30,7 @@ export const ExportPanel = ({
try {
await workspace.engine.sync.waitForSynced();
await workspace.engine.blob.sync();
const result: SaveDBFileResult =
await window.apis?.dialog.saveDBFileAs(workspaceId);
const result = await apis?.dialog.saveDBFileAs(workspaceId);
if (result?.error) {
throw new Error(result.error);
} else if (!result?.canceled) {

View File

@@ -2,17 +2,17 @@ import { FlexWrapper, toast } from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import { Button } from '@affine/component/ui/button';
import { Tooltip } from '@affine/component/ui/tooltip';
import { apis, events } from '@affine/electron-api';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { WorkspaceMetadata } from '@affine/workspace/metadata';
import type { MoveDBFileResult } from '@toeverything/infra/type';
import { useMemo } from 'react';
import { useCallback, useEffect, useState } from 'react';
const useDBFileSecondaryPath = (workspaceId: string) => {
const [path, setPath] = useState<string | undefined>(undefined);
useEffect(() => {
if (window.apis && window.events && environment.isDesktop) {
window.apis?.workspace
if (apis && events && environment.isDesktop) {
apis?.workspace
.getMeta(workspaceId)
.then(meta => {
setPath(meta.secondaryDBPath);
@@ -20,7 +20,7 @@ const useDBFileSecondaryPath = (workspaceId: string) => {
.catch(err => {
console.error(err);
});
return window.events.workspace.onMetaChange((newMeta: any) => {
return events.workspace.onMetaChange((newMeta: any) => {
if (newMeta.workspaceId === workspaceId) {
const meta = newMeta.meta;
setPath(meta.secondaryDBPath);
@@ -43,7 +43,7 @@ export const StoragePanel = ({ workspaceMetadata }: StoragePanelProps) => {
const [moveToInProgress, setMoveToInProgress] = useState<boolean>(false);
const onRevealDBFile = useCallback(() => {
window.apis?.dialog.revealDBFile(workspaceId).catch(err => {
apis?.dialog.revealDBFile(workspaceId).catch(err => {
console.error(err);
});
}, [workspaceId]);
@@ -53,9 +53,9 @@ export const StoragePanel = ({ workspaceMetadata }: StoragePanelProps) => {
return;
}
setMoveToInProgress(true);
window.apis?.dialog
apis?.dialog
.moveDBFile(workspaceId)
.then((result: MoveDBFileResult) => {
.then(result => {
if (!result?.error && !result?.canceled) {
toast(t['Move folder success']());
} else if (result?.error) {

View File

@@ -1,3 +1,4 @@
import { apis, events } from '@affine/electron-api';
import { useAtomValue } from 'jotai';
import { atomWithObservable } from 'jotai/utils';
import { useCallback } from 'react';
@@ -8,7 +9,7 @@ import * as style from './style.css';
const maximizedAtom = atomWithObservable(() => {
return new Observable<boolean>(subscriber => {
subscriber.next(false);
return window.events?.ui.onMaximized(maximized => {
return events?.ui.onMaximized(maximized => {
return subscriber.next(maximized);
});
});
@@ -76,17 +77,17 @@ const unmaximizedSVG = (
export const WindowsAppControls = () => {
const handleMinimizeApp = useCallback(() => {
window.apis?.ui.handleMinimizeApp().catch(err => {
apis?.ui.handleMinimizeApp().catch(err => {
console.error(err);
});
}, []);
const handleMaximizeApp = useCallback(() => {
window.apis?.ui.handleMaximizeApp().catch(err => {
apis?.ui.handleMaximizeApp().catch(err => {
console.error(err);
});
}, []);
const handleCloseApp = useCallback(() => {
window.apis?.ui.handleCloseApp().catch(err => {
apis?.ui.handleCloseApp().catch(err => {
console.error(err);
});
}, []);

View File

@@ -20,6 +20,7 @@ import {
} from '@affine/component/page-list';
import { Menu } from '@affine/component/ui/menu';
import { collectionsCRUDAtom } from '@affine/core/atoms/collections';
import { apis, events } from '@affine/electron-api';
import { WorkspaceSubPath } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { Workspace } from '@affine/workspace';
@@ -141,7 +142,7 @@ export const RootAppSidebar = ({
// Listen to the "New Page" action from the menu
useEffect(() => {
if (environment.isDesktop) {
return window.events?.applicationMenu.onNewPageAction(onClickNewPage);
return events?.applicationMenu.onNewPageAction(onClickNewPage);
}
return;
}, [onClickNewPage]);
@@ -149,7 +150,7 @@ export const RootAppSidebar = ({
const sidebarOpen = useAtomValue(appSidebarOpenAtom);
useEffect(() => {
if (environment.isDesktop) {
window.apis?.ui.handleSidebarVisibilityChange(sidebarOpen).catch(err => {
apis?.ui.handleSidebarVisibilityChange(sidebarOpen).catch(err => {
console.error(err);
});
}

View File

@@ -3,6 +3,7 @@ import {
resolveGlobalLoadingEventAtom,
} from '@affine/component/global-loading';
import { pushNotificationAtom } from '@affine/component/notification-center';
import { apis } from '@affine/electron-api';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
HtmlTransformer,
@@ -49,7 +50,7 @@ async function exportHandler({ page, type }: ExportHandlerOptions) {
break;
case 'pdf':
if (environment.isDesktop && page.meta.mode === 'page') {
await window.apis?.export.savePDFFileAs(
await apis?.export.savePDFFileAs(
(page.root as PageBlockModel).title.toString()
);
} else {

View File

@@ -1,3 +1,5 @@
import { apis } from '@affine/electron-api';
import { assertExists } from '@blocksuite/global/utils';
import {
type AppConfigSchema,
AppConfigStorage,
@@ -13,11 +15,13 @@ class AppConfigProxy {
value: AppConfigSchema = defaultAppConfig;
async getSync(): Promise<AppConfigSchema> {
return (this.value = await window.apis.configStorage.get());
assertExists(apis);
return (this.value = await apis.configStorage.get());
}
async setSync(): Promise<void> {
await window.apis.configStorage.set(this.value);
assertExists(apis);
await apis.configStorage.set(this.value);
}
get(): AppConfigSchema {

View File

@@ -1,3 +1,5 @@
import { apis } from '@affine/electron-api';
import { assertExists } from '@blocksuite/global/utils';
import { useCallback } from 'react';
import { redirect } from 'react-router-dom';
@@ -23,7 +25,8 @@ export const Component = () => {
const openApp = useCallback(() => {
if (environment.isDesktop) {
window.apis.ui.handleOpenMainApp().catch(err => {
assertExists(apis);
apis.ui.handleOpenMainApp().catch(err => {
console.log('failed to open main app', err);
});
} else {

View File

@@ -23,6 +23,9 @@
{
"path": "../../frontend/workspace"
},
{
"path": "../../frontend/electron-api"
},
{
"path": "../../common/debug"
},

View File

@@ -0,0 +1,14 @@
{
"name": "@affine/electron-api",
"version": "0.10.3-canary.2",
"type": "module",
"private": true,
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"devDependencies": {
"@toeverything/infra": "workspace:*",
"electron": "^27.1.0"
}
}

View File

@@ -0,0 +1,37 @@
import type {
events as helperEvents,
handlers as helperHandlers,
} from '@affine/electron/helper/exposed';
import type {
events as mainEvents,
handlers as mainHandlers,
} from '@affine/electron/main/exposed';
import type {
affine as exposedAffineGlobal,
appInfo as exposedAppInfo,
} from '@affine/electron/preload/electron-api';
type MainHandlers = typeof mainHandlers;
type HelperHandlers = typeof helperHandlers;
type HelperEvents = typeof helperEvents;
type MainEvents = typeof mainEvents;
type ClientHandler = {
[namespace in keyof MainHandlers]: {
[method in keyof MainHandlers[namespace]]: MainHandlers[namespace][method] extends (
arg0: any,
...rest: infer A
) => any
? (...args: A) => Promise<ReturnType<MainHandlers[namespace][method]>>
: never;
};
} & HelperHandlers;
type ClientEvents = MainEvents & HelperEvents;
export const appInfo = (window as any).appInfo as typeof exposedAppInfo | null;
export const apis = (window as any).apis as ClientHandler | null;
export const events = (window as any).events as ClientEvents | null;
export const affine = (window as any).affine as
| typeof exposedAffineGlobal
| null;
export type { UpdateMeta } from '@affine/electron/main/updater/event';

View File

@@ -0,0 +1,17 @@
{
"extends": "../../../tsconfig.json",
"include": ["./src"],
"compilerOptions": {
"composite": true,
"noEmit": false,
"outDir": "lib"
},
"references": [
{
"path": "../../common/infra"
},
{
"path": "../../frontend/electron"
}
]
}

View File

@@ -2,13 +2,6 @@ import path from 'node:path';
import { ValidationResult } from '@affine/native';
import { WorkspaceVersion } from '@toeverything/infra/blocksuite';
import type {
FakeDialogResult,
LoadDBFileResult,
MoveDBFileResult,
SaveDBFileResult,
SelectDBFileLocationResult,
} from '@toeverything/infra/type';
import fs from 'fs-extra';
import { nanoid } from 'nanoid';
@@ -28,6 +21,45 @@ import {
getWorkspacesBasePath,
} from '../workspace/meta';
export type ErrorMessage =
| 'DB_FILE_ALREADY_LOADED'
| 'DB_FILE_PATH_INVALID'
| 'DB_FILE_INVALID'
| 'DB_FILE_MIGRATION_FAILED'
| 'FILE_ALREADY_EXISTS'
| 'UNKNOWN_ERROR';
export interface LoadDBFileResult {
workspaceId?: string;
error?: ErrorMessage;
canceled?: boolean;
}
export interface SaveDBFileResult {
filePath?: string;
canceled?: boolean;
error?: ErrorMessage;
}
export interface SelectDBFileLocationResult {
filePath?: string;
error?: ErrorMessage;
canceled?: boolean;
}
export interface MoveDBFileResult {
filePath?: string;
error?: ErrorMessage;
canceled?: boolean;
}
// provide a backdoor to set dialog path for testing in playwright
export interface FakeDialogResult {
canceled?: boolean;
filePath?: string;
filePaths?: string[];
}
// NOTE:
// we are using native dialogs because HTML dialogs do not give full file paths

View File

@@ -1,25 +1,13 @@
import type {
DBHandlers,
DialogHandlers,
WorkspaceHandlers,
} from '@toeverything/infra/type';
import { dbEvents, dbHandlers } from './db';
import { dialogHandlers } from './dialog';
import { provideExposed } from './provide';
import { workspaceEvents, workspaceHandlers } from './workspace';
type AllHandlers = {
db: DBHandlers;
workspace: WorkspaceHandlers;
dialog: DialogHandlers;
};
export const handlers = {
db: dbHandlers,
workspace: workspaceHandlers,
dialog: dialogHandlers,
} satisfies AllHandlers;
};
export const events = {
db: dbEvents,

View File

@@ -1,6 +1,6 @@
import type { RendererToHelper } from '@toeverything/infra/preload/electron';
import { AsyncCall } from 'async-call-rpc';
import type { RendererToHelper } from '../shared/type';
import { events, handlers } from './exposed';
import { logger } from './logger';

View File

@@ -1,10 +1,7 @@
import { assertExists } from '@blocksuite/global/utils';
import type {
HelperToMain,
MainToHelper,
} from '@toeverything/infra/preload/electron';
import { AsyncCall } from 'async-call-rpc';
import type { HelperToMain, MainToHelper } from '../shared/type';
import { exposed } from './provide';
const helperToMainServer: HelperToMain = {

View File

@@ -1,4 +1,4 @@
import type { ExposedMeta } from '@toeverything/infra/preload/electron';
import type { ExposedMeta } from '../shared/type';
/**
* A naive DI implementation to get rid of circular dependency.

View File

@@ -1,12 +1,3 @@
import type {
ClipboardHandlerManager,
ConfigStorageHandlerManager,
DebugHandlerManager,
ExportHandlerManager,
UIHandlerManager,
UnwrapManagerHandlerToServerSide,
UpdaterHandlerManager,
} from '@toeverything/infra/index';
import { ipcMain } from 'electron';
import { clipboardHandlers } from './clipboard';
@@ -25,33 +16,6 @@ export const debugHandlers = {
},
};
type AllHandlers = {
debug: UnwrapManagerHandlerToServerSide<
Electron.IpcMainInvokeEvent,
DebugHandlerManager
>;
clipboard: UnwrapManagerHandlerToServerSide<
Electron.IpcMainInvokeEvent,
ClipboardHandlerManager
>;
export: UnwrapManagerHandlerToServerSide<
Electron.IpcMainInvokeEvent,
ExportHandlerManager
>;
ui: UnwrapManagerHandlerToServerSide<
Electron.IpcMainInvokeEvent,
UIHandlerManager
>;
updater: UnwrapManagerHandlerToServerSide<
Electron.IpcMainInvokeEvent,
UpdaterHandlerManager
>;
configStorage: UnwrapManagerHandlerToServerSide<
Electron.IpcMainInvokeEvent,
ConfigStorageHandlerManager
>;
};
// Note: all of these handlers will be the single-source-of-truth for the apis exposed to the renderer process
export const allHandlers = {
debug: debugHandlers,
@@ -60,7 +24,7 @@ export const allHandlers = {
export: exportHandlers,
updater: updaterHandlers,
configStorage: configStorageHandlers,
} satisfies AllHandlers;
};
export const registerHandlers = () => {
// TODO: listen to namespace instead of individual event types

View File

@@ -1,9 +1,5 @@
import path from 'node:path';
import type {
HelperToMain,
MainToHelper,
} from '@toeverything/infra/preload/electron';
import { type _AsyncVersionOf, AsyncCall } from 'async-call-rpc';
import {
app,
@@ -15,6 +11,7 @@ import {
type WebContents,
} from 'electron';
import type { HelperToMain, MainToHelper } from '../shared/type';
import { MessageEventChannel } from '../shared/utils';
import { logger } from './logger';

View File

@@ -1,57 +1,15 @@
import { contextBridge, ipcRenderer } from 'electron';
import { contextBridge } from 'electron';
(async () => {
const { appInfo, getElectronAPIs } = await import(
'@toeverything/infra/preload/electron'
);
const { apis, events } = getElectronAPIs();
import { affine, appInfo, getElectronAPIs } from './electron-api';
contextBridge.exposeInMainWorld('appInfo', appInfo);
contextBridge.exposeInMainWorld('apis', apis);
contextBridge.exposeInMainWorld('events', events);
const { apis, events } = getElectronAPIs();
// Credit to microsoft/vscode
const globals = {
ipcRenderer: {
send(channel: string, ...args: any[]) {
ipcRenderer.send(channel, ...args);
},
contextBridge.exposeInMainWorld('appInfo', appInfo);
contextBridge.exposeInMainWorld('apis', apis);
contextBridge.exposeInMainWorld('events', events);
invoke(channel: string, ...args: any[]) {
return ipcRenderer.invoke(channel, ...args);
},
on(
channel: string,
listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void
) {
ipcRenderer.on(channel, listener);
return this;
},
once(
channel: string,
listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void
) {
ipcRenderer.once(channel, listener);
return this;
},
removeListener(
channel: string,
listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void
) {
ipcRenderer.removeListener(channel, listener);
return this;
},
},
};
try {
contextBridge.exposeInMainWorld('affine', globals);
} catch (error) {
console.error('Failed to expose affine APIs to window object!', error);
}
})().catch(err => {
console.error('Failed to bootstrap preload script!', err);
});
try {
contextBridge.exposeInMainWorld('affine', affine);
} catch (error) {
console.error('Failed to expose affine APIs to window object!', error);
}

View File

@@ -0,0 +1,246 @@
// Please add modules to `external` in `rollupOptions` to avoid wrong bundling.
import { AsyncCall, type EventBasedChannel } from 'async-call-rpc';
import { ipcRenderer } from 'electron';
import { Subject } from 'rxjs';
import { z } from 'zod';
import type {
ExposedMeta,
HelperToRenderer,
RendererToHelper,
} from '../shared/type';
export const affine = {
ipcRenderer: {
send(channel: string, ...args: any[]) {
ipcRenderer.send(channel, ...args);
},
invoke(channel: string, ...args: any[]) {
return ipcRenderer.invoke(channel, ...args);
},
on(
channel: string,
listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void
) {
ipcRenderer.on(channel, listener);
return this;
},
once(
channel: string,
listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void
) {
ipcRenderer.once(channel, listener);
return this;
},
removeListener(
channel: string,
listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void
) {
ipcRenderer.removeListener(channel, listener);
return this;
},
},
};
export function getElectronAPIs() {
const mainAPIs = getMainAPIs();
const helperAPIs = getHelperAPIs();
return {
apis: {
...mainAPIs.apis,
...helperAPIs.apis,
},
events: {
...mainAPIs.events,
...helperAPIs.events,
},
};
}
// todo: remove duplicated codes
const ReleaseTypeSchema = z.enum(['stable', 'beta', 'canary', 'internal']);
const envBuildType = (process.env.BUILD_TYPE || 'canary').trim().toLowerCase();
const buildType = ReleaseTypeSchema.parse(envBuildType);
const isDev = process.env.NODE_ENV === 'development';
let schema = buildType === 'stable' ? 'affine' : `affine-${envBuildType}`;
schema = isDev ? 'affine-dev' : schema;
export const appInfo = {
electron: true,
windowName: process.argv
.find(arg => arg.startsWith('--window-name='))
?.split('=')[1],
schema,
};
function getMainAPIs() {
const meta: ExposedMeta = (() => {
const val = process.argv
.find(arg => arg.startsWith('--main-exposed-meta='))
?.split('=')[1];
return val ? JSON.parse(val) : null;
})();
// main handlers that can be invoked from the renderer process
const apis: any = (() => {
const { handlers: handlersMeta } = meta;
const all = handlersMeta.map(([namespace, functionNames]) => {
const namespaceApis = functionNames.map(name => {
const channel = `${namespace}:${name}`;
return [
name,
(...args: any[]) => {
return ipcRenderer.invoke(channel, ...args);
},
];
});
return [namespace, Object.fromEntries(namespaceApis)];
});
return Object.fromEntries(all);
})();
// main events that can be listened to from the renderer process
const events: any = (() => {
const { events: eventsMeta } = meta;
// NOTE: ui may try to listen to a lot of the same events, so we increase the limit...
ipcRenderer.setMaxListeners(100);
const all = eventsMeta.map(([namespace, eventNames]) => {
const namespaceEvents = eventNames.map(name => {
const channel = `${namespace}:${name}`;
return [
name,
(callback: (...args: any[]) => void) => {
const fn: (
event: Electron.IpcRendererEvent,
...args: any[]
) => void = (_, ...args) => {
callback(...args);
};
ipcRenderer.on(channel, fn);
return () => {
ipcRenderer.off(channel, fn);
};
},
];
});
return [namespace, Object.fromEntries(namespaceEvents)];
});
return Object.fromEntries(all);
})();
return { apis, events };
}
const helperPort$ = new Promise<MessagePort>(resolve =>
ipcRenderer.on('helper-connection', e => {
console.info('[preload] helper-connection', e);
resolve(e.ports[0]);
})
);
const createMessagePortChannel = (port: MessagePort): EventBasedChannel => {
return {
on(listener) {
port.onmessage = e => {
listener(e.data);
};
port.start();
return () => {
port.onmessage = null;
port.close();
};
},
send(data) {
port.postMessage(data);
},
};
};
function getHelperAPIs() {
const events$ = new Subject<{ channel: string; args: any[] }>();
const meta: ExposedMeta | null = (() => {
const val = process.argv
.find(arg => arg.startsWith('--helper-exposed-meta='))
?.split('=')[1];
return val ? JSON.parse(val) : null;
})();
const rendererToHelperServer: RendererToHelper = {
postEvent: (channel, ...args) => {
events$.next({ channel, args });
},
};
const rpc = AsyncCall<HelperToRenderer>(rendererToHelperServer, {
channel: helperPort$.then(helperPort =>
createMessagePortChannel(helperPort)
),
log: false,
});
const toHelperHandler = (namespace: string, name: string) => {
return rpc[`${namespace}:${name}`];
};
const toHelperEventSubscriber = (namespace: string, name: string) => {
return (callback: (...args: any[]) => void) => {
const subscription = events$.subscribe(({ channel, args }) => {
if (channel === `${namespace}:${name}`) {
callback(...args);
}
});
return () => {
subscription.unsubscribe();
};
};
};
const setup = (meta: ExposedMeta) => {
const { handlers, events } = meta;
const helperHandlers = Object.fromEntries(
handlers.map(([namespace, functionNames]) => {
return [
namespace,
Object.fromEntries(
functionNames.map(name => {
return [name, toHelperHandler(namespace, name)];
})
),
];
})
);
const helperEvents = Object.fromEntries(
events.map(([namespace, eventNames]) => {
return [
namespace,
Object.fromEntries(
eventNames.map(name => {
return [name, toHelperEventSubscriber(namespace, name)];
})
),
];
})
);
return [helperHandlers, helperEvents];
};
if (meta) {
const [apis, events] = setup(meta);
return { apis, events };
} else {
return { apis: {}, events: {} };
}
}

View File

@@ -0,0 +1,29 @@
import type { app, dialog, shell } from 'electron';
export interface ExposedMeta {
handlers: [string, string[]][];
events: [string, string[]][];
}
// 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'
>;

View File

@@ -18,6 +18,7 @@
},
"devDependencies": {
"@affine/debug": "workspace:*",
"@affine/electron-api": "workspace:*",
"@affine/env": "workspace:*",
"@affine/workspace": "workspace:*",
"@blocksuite/block-std": "0.11.0-nightly-202312220916-e3abcbb",

View File

@@ -1,6 +1,6 @@
import { apis, events, type UpdateMeta } from '@affine/electron-api';
import { isBrowser } from '@affine/env/constant';
import { appSettingAtom } from '@toeverything/infra/atom';
import type { UpdateMeta } from '@toeverything/infra/type';
import { atom, useAtom, useAtomValue } from 'jotai';
import { atomWithObservable, atomWithStorage } from 'jotai/utils';
import { useCallback, useState } from 'react';
@@ -47,21 +47,21 @@ function rpcToObservable<
// download complete, ready to install
export const updateReadyAtom = atomWithObservable(() => {
return rpcToObservable(null as UpdateMeta | null, {
event: window.events?.updater.onUpdateReady,
event: events?.updater.onUpdateReady,
});
});
// update available, but not downloaded yet
export const updateAvailableAtom = atomWithObservable(() => {
return rpcToObservable(null as UpdateMeta | null, {
event: window.events?.updater.onUpdateAvailable,
event: events?.updater.onUpdateAvailable,
});
});
// downloading new update
export const downloadProgressAtom = atomWithObservable(() => {
return rpcToObservable(null as number | null, {
event: window.events?.updater.onDownloadProgress,
event: events?.updater.onDownloadProgress,
});
});
@@ -76,7 +76,7 @@ export const currentVersionAtom = atom(async () => {
if (!isBrowser) {
return null;
}
const currentVersion = await window.apis?.updater.currentVersion();
const currentVersion = await apis?.updater.currentVersion();
return currentVersion;
});
@@ -121,7 +121,7 @@ export const useAppUpdater = () => {
const quitAndInstall = useCallback(() => {
if (updateReady) {
setAppQuitting(true);
window.apis?.updater.quitAndInstall().catch(err => {
apis?.updater.quitAndInstall().catch(err => {
// TODO: add error toast here
console.error(err);
});
@@ -134,7 +134,7 @@ export const useAppUpdater = () => {
}
setCheckingForUpdates(true);
try {
const updateInfo = await window.apis?.updater.checkForUpdates();
const updateInfo = await apis?.updater.checkForUpdates();
return updateInfo?.version ?? false;
} catch (err) {
console.error('Error checking for updates:', err);
@@ -145,7 +145,7 @@ export const useAppUpdater = () => {
}, [checkingForUpdates, setCheckingForUpdates]);
const downloadUpdate = useCallback(() => {
window.apis?.updater.downloadUpdate().catch(err => {
apis?.updater.downloadUpdate().catch(err => {
console.error('Error downloading update:', err);
});
}, []);

View File

@@ -11,6 +11,7 @@
{ "path": "../../common/y-indexeddb" },
{ "path": "../../common/debug" },
{ "path": "../../common/infra" },
{ "path": "../electron-api" },
{ "path": "../workspace" }
]
}

View File

@@ -14,6 +14,7 @@
"dependencies": {
"@affine-test/fixtures": "workspace:*",
"@affine/debug": "workspace:*",
"@affine/electron-api": "workspace:*",
"@affine/env": "workspace:*",
"@affine/graphql": "workspace:*",
"@toeverything/infra": "workspace:*",

View File

@@ -1,15 +1,16 @@
import { apis } from '@affine/electron-api';
import { assertExists } from '@blocksuite/global/utils';
import type { BlobStorage } from '../../engine/blob';
import { bufferToBlob } from '../../utils/buffer-to-blob';
export const createSQLiteBlobStorage = (workspaceId: string): BlobStorage => {
const apis = window.apis;
assertExists(apis);
return {
name: 'sqlite',
readonly: false,
get: async (key: string) => {
assertExists(apis);
const buffer = await apis.db.getBlob(workspaceId, key);
if (buffer) {
return bufferToBlob(buffer);
@@ -17,6 +18,7 @@ export const createSQLiteBlobStorage = (workspaceId: string): BlobStorage => {
return null;
},
set: async (key: string, value: Blob) => {
assertExists(apis);
await apis.db.addBlob(
workspaceId,
key,
@@ -25,9 +27,11 @@ export const createSQLiteBlobStorage = (workspaceId: string): BlobStorage => {
return key;
},
delete: async (key: string) => {
assertExists(apis);
return apis.db.deleteBlob(workspaceId, key);
},
list: async () => {
assertExists(apis);
return apis.db.getBlobKeys(workspaceId);
},
};

View File

@@ -1,3 +1,4 @@
import { apis } from '@affine/electron-api';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store';
import { difference } from 'lodash-es';
@@ -96,8 +97,8 @@ export function createLocalWorkspaceListProvider(): WorkspaceListProvider {
JSON.stringify(allWorkspaceIDs.filter(x => x !== workspaceId))
);
if (window.apis && environment.isDesktop) {
await window.apis.workspace.delete(workspaceId);
if (apis && environment.isDesktop) {
await apis.workspace.delete(workspaceId);
}
// notify all browser tabs, so they can update their workspace list

View File

@@ -1,16 +1,20 @@
import { apis } from '@affine/electron-api';
import { encodeStateVectorFromUpdate } from 'yjs';
import type { SyncStorage } from '../../engine/sync';
export function createSQLiteStorage(workspaceId: string): SyncStorage {
if (!window.apis?.db) {
if (!apis?.db) {
throw new Error('sqlite datasource is not available');
}
return {
name: 'sqlite',
async pull(docId, _state) {
const update = await window.apis.db.getDocAsUpdates(
if (!apis?.db) {
throw new Error('sqlite datasource is not available');
}
const update = await apis.db.getDocAsUpdates(
workspaceId,
workspaceId === docId ? undefined : docId
);
@@ -25,7 +29,10 @@ export function createSQLiteStorage(workspaceId: string): SyncStorage {
return null;
},
async push(docId, data) {
return window.apis.db.applyDocUpdate(
if (!apis?.db) {
throw new Error('sqlite datasource is not available');
}
return apis.db.applyDocUpdate(
workspaceId,
data,
workspaceId === docId ? undefined : docId

View File

@@ -10,6 +10,7 @@
{ "path": "../../common/env" },
{ "path": "../../common/debug" },
{ "path": "../../common/infra" },
{ "path": "../../frontend/graphql" }
{ "path": "../../frontend/graphql" },
{ "path": "../../frontend/electron-api" }
]
}