mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-17 06:16:59 +08:00
refactor(electron): create electron api package (#5334)
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@affine/debug": "workspace:*",
|
||||
"@affine/electron-api": "workspace:*",
|
||||
"@affine/graphql": "workspace:*",
|
||||
"@affine/i18n": "workspace:*",
|
||||
"@affine/workspace": "workspace:*",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
{
|
||||
"path": "../../frontend/hooks"
|
||||
},
|
||||
{
|
||||
"path": "../../frontend/electron-api"
|
||||
},
|
||||
{ "path": "../../frontend/workspace" },
|
||||
{
|
||||
"path": "../../common/debug"
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
{
|
||||
"path": "../../frontend/workspace"
|
||||
},
|
||||
{
|
||||
"path": "../../frontend/electron-api"
|
||||
},
|
||||
{
|
||||
"path": "../../common/debug"
|
||||
},
|
||||
|
||||
14
packages/frontend/electron-api/package.json
Normal file
14
packages/frontend/electron-api/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
37
packages/frontend/electron-api/src/index.ts
Normal file
37
packages/frontend/electron-api/src/index.ts
Normal 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';
|
||||
17
packages/frontend/electron-api/tsconfig.json
Normal file
17
packages/frontend/electron-api/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"include": ["./src"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": false,
|
||||
"outDir": "lib"
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../../common/infra"
|
||||
},
|
||||
{
|
||||
"path": "../../frontend/electron"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
246
packages/frontend/electron/src/preload/electron-api.ts
Normal file
246
packages/frontend/electron/src/preload/electron-api.ts
Normal 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: {} };
|
||||
}
|
||||
}
|
||||
29
packages/frontend/electron/src/shared/type.ts
Normal file
29
packages/frontend/electron/src/shared/type.ts
Normal 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'
|
||||
>;
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
{ "path": "../../common/y-indexeddb" },
|
||||
{ "path": "../../common/debug" },
|
||||
{ "path": "../../common/infra" },
|
||||
{ "path": "../electron-api" },
|
||||
{ "path": "../workspace" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
{ "path": "../../common/env" },
|
||||
{ "path": "../../common/debug" },
|
||||
{ "path": "../../common/infra" },
|
||||
{ "path": "../../frontend/graphql" }
|
||||
{ "path": "../../frontend/graphql" },
|
||||
{ "path": "../../frontend/electron-api" }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user