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

@@ -1,15 +1,7 @@
{ {
"name": "@toeverything/infra", "name": "@toeverything/infra",
"type": "module", "type": "module",
"module": "./dist/index.js",
"main": "./dist/index.cjs",
"types": "./dist/src/index.d.ts",
"exports": { "exports": {
".": {
"types": "./dist/src/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./blocksuite": { "./blocksuite": {
"types": "./dist/src/blocksuite/index.d.ts", "types": "./dist/src/blocksuite/index.d.ts",
"import": "./dist/blocksuite.js", "import": "./dist/blocksuite.js",
@@ -20,26 +12,11 @@
"import": "./dist/command.js", "import": "./dist/command.js",
"require": "./dist/command.cjs" "require": "./dist/command.cjs"
}, },
"./core/*": {
"types": "./dist/src/core/*.d.ts",
"import": "./dist/core/*.js",
"require": "./dist/core/*.cjs"
},
"./preload/*": {
"types": "./dist/src/preload/*.d.ts",
"import": "./dist/preload/*.js",
"require": "./dist/preload/*.cjs"
},
"./atom": { "./atom": {
"type": "./dist/src/atom.d.ts", "type": "./dist/src/atom.d.ts",
"import": "./dist/atom.js", "import": "./dist/atom.js",
"require": "./dist/atom.cjs" "require": "./dist/atom.cjs"
}, },
"./type": {
"type": "./dist/src/type.d.ts",
"import": "./dist/type.js",
"require": "./dist/type.cjs"
},
"./app-config-storage": { "./app-config-storage": {
"type": "./dist/src/app-config-storage.d.ts", "type": "./dist/src/app-config-storage.d.ts",
"import": "./dist/app-config-storage.js", "import": "./dist/app-config-storage.js",

View File

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

View File

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

View File

@@ -72,12 +72,13 @@ const appSettingEffect = atomEffect(get => {
// some values in settings should be synced into electron side // some values in settings should be synced into electron side
if (environment.isDesktop) { if (environment.isDesktop) {
console.log('set config', settings); console.log('set config', settings);
window.apis?.updater // this api type in @affine/electron-api, but it is circular dependency this package, use any here
(window as any).apis?.updater
.setConfig({ .setConfig({
autoCheckUpdate: settings.autoCheckUpdate, autoCheckUpdate: settings.autoCheckUpdate,
autoDownloadUpdate: settings.autoDownloadUpdate, autoDownloadUpdate: settings.autoDownloadUpdate,
}) })
.catch(err => { .catch((err: any) => {
console.error(err); console.error(err);
}); });
} }

View File

@@ -1,74 +0,0 @@
/**
* 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,57 +0,0 @@
import type {
ClipboardHandlers,
ConfigStorageHandlers,
DBHandlers,
DebugHandlers,
DialogHandlers,
ExportHandlers,
UIHandlers,
UpdaterHandlers,
WorkspaceHandlers,
} from './type.js';
import { HandlerManager } from './type.js';
export abstract class DBHandlerManager extends HandlerManager<
'db',
DBHandlers
> {}
export abstract class DebugHandlerManager extends HandlerManager<
'debug',
DebugHandlers
> {}
export abstract class DialogHandlerManager extends HandlerManager<
'dialog',
DialogHandlers
> {}
export abstract class UIHandlerManager extends HandlerManager<
'ui',
UIHandlers
> {}
export abstract class ClipboardHandlerManager extends HandlerManager<
'clipboard',
ClipboardHandlers
> {}
export abstract class ExportHandlerManager extends HandlerManager<
'export',
ExportHandlers
> {}
export abstract class UpdaterHandlerManager extends HandlerManager<
'updater',
UpdaterHandlers
> {}
export abstract class WorkspaceHandlerManager extends HandlerManager<
'workspace',
WorkspaceHandlers
> {}
export abstract class ConfigStorageHandlerManager extends HandlerManager<
'configStorage',
ConfigStorageHandlers
> {}

View File

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

View File

@@ -1,285 +0,0 @@
import type Buffer from 'buffer';
import { z } from 'zod';
import type { AppConfigSchema } from './app-config-storage.js';
import type { TypedEventEmitter } from './core/event-emitter.js';
type Buffer = Buffer.Buffer;
export const packageJsonInputSchema = z.object({
name: z.string(),
version: z.string(),
description: z.string(),
affinePlugin: z.object({
release: z.union([z.boolean(), z.enum(['development'])]),
entry: z.object({
core: z.string(),
}),
}),
});
export const packageJsonOutputSchema = z.object({
name: z.string(),
version: z.string(),
description: z.string(),
affinePlugin: z.object({
release: z.union([z.boolean(), z.enum(['development'])]),
entry: z.object({
core: z.string(),
}),
assets: z.array(z.string()),
}),
});
export abstract class HandlerManager<
Namespace extends string,
Handlers extends Record<string, PrimitiveHandlers>,
> {
static instance: HandlerManager<string, Record<string, PrimitiveHandlers>>;
private readonly _app: App<Namespace, Handlers>;
private readonly _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 | false>;
applyDocUpdate: (
id: string,
update: Uint8Array,
subdocId?: string
) => Promise<void>;
addBlob: (
workspaceId: string,
key: string,
data: Uint8Array
) => Promise<void>;
getBlob: (workspaceId: string, key: string) => Promise<Buffer | null>;
deleteBlob: (workspaceId: string, key: string) => Promise<void>;
getBlobKeys: (workspaceId: string) => Promise<string[]>;
getDefaultStorageLocation: () => Promise<string>;
};
export type DebugHandlers = {
revealLogFile: () => Promise<string>;
logFilePath: () => Promise<string>;
};
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[];
}
export type DialogHandlers = {
revealDBFile: (workspaceId: string) => Promise<void>;
loadDBFile: () => Promise<LoadDBFileResult>;
saveDBFileAs: (workspaceId: string) => Promise<SaveDBFileResult>;
moveDBFile: (
workspaceId: string,
dbFileLocation?: string
) => Promise<MoveDBFileResult>;
selectDBFileLocation: () => Promise<SelectDBFileLocationResult>;
setFakeDialogResult: (result: any) => Promise<void>;
};
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>;
getChallengeResponse: (resource: string) => Promise<string>;
handleOpenMainApp: () => Promise<any>;
};
export type ClipboardHandlers = {
copyAsImageFromString: (dataURL: string) => Promise<void>;
};
export type ExportHandlers = {
savePDFFileAs: (title: string) => Promise<any>;
};
export interface UpdateMeta {
version: string;
allowAutoUpdate: boolean;
}
export type UpdaterConfig = {
autoCheckUpdate: boolean;
autoDownloadUpdate: boolean;
};
export type UpdaterHandlers = {
currentVersion: () => Promise<string>;
quitAndInstall: () => Promise<void>;
downloadUpdate: () => Promise<void>;
getConfig: () => Promise<UpdaterConfig>;
setConfig: (newConfig: Partial<UpdaterConfig>) => Promise<void>;
checkForUpdates: () => Promise<{ version: string } | null>;
};
export type WorkspaceHandlers = {
list: () => Promise<[workspaceId: string, meta: WorkspaceMeta][]>;
delete: (id: string) => Promise<void>;
getMeta: (id: string) => Promise<WorkspaceMeta>;
clone: (id: string, newId: string) => Promise<void>;
};
export type ConfigStorageHandlers = {
set: (config: AppConfigSchema | Partial<AppConfigSchema>) => Promise<void>;
get: () => Promise<AppConfigSchema>;
};
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];
}>;
export interface UpdaterEvents {
onUpdateAvailable: (fn: (versionMeta: UpdateMeta) => void) => () => void;
onUpdateReady: (fn: (versionMeta: UpdateMeta) => void) => () => void;
onDownloadProgress: (fn: (progress: number) => void) => () => void;
}
export interface ApplicationMenuEvents {
onNewPageAction: (fn: () => void) => () => void;
}
export interface DBEvents {
onExternalUpdate: (
fn: (update: {
workspaceId: string;
update: Uint8Array;
docId?: string;
}) => void
) => () => void;
}
export interface WorkspaceEvents {
onMetaChange: (
fn: (workspaceId: string, meta: WorkspaceMeta) => void
) => () => void;
}
export interface UIEvents {
onMaximized: (fn: (maximized: boolean) => void) => () => void;
}
export interface EventMap {
updater: UpdaterEvents;
applicationMenu: ApplicationMenuEvents;
db: DBEvents;
ui: UIEvents;
workspace: WorkspaceEvents;
}

View File

@@ -12,12 +12,8 @@ export default defineConfig({
lib: { lib: {
entry: { entry: {
blocksuite: resolve(root, 'src/blocksuite/index.ts'), blocksuite: resolve(root, 'src/blocksuite/index.ts'),
index: resolve(root, 'src/index.ts'),
atom: resolve(root, 'src/atom/index.ts'), atom: resolve(root, 'src/atom/index.ts'),
command: resolve(root, 'src/command/index.ts'), command: resolve(root, 'src/command/index.ts'),
type: resolve(root, 'src/type.ts'),
'core/event-emitter': resolve(root, 'src/core/event-emitter.ts'),
'preload/electron': resolve(root, 'src/preload/electron.ts'),
'app-config-storage': resolve(root, 'src/app-config-storage.ts'), 'app-config-storage': resolve(root, 'src/app-config-storage.ts'),
}, },
formats: ['es', 'cjs'], formats: ['es', 'cjs'],

View File

@@ -20,6 +20,7 @@
}, },
"dependencies": { "dependencies": {
"@affine/debug": "workspace:*", "@affine/debug": "workspace:*",
"@affine/electron-api": "workspace:*",
"@affine/graphql": "workspace:*", "@affine/graphql": "workspace:*",
"@affine/i18n": "workspace:*", "@affine/i18n": "workspace:*",
"@affine/workspace": "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 { ThemeProvider as NextThemeProvider, useTheme } from 'next-themes';
import type { PropsWithChildren } from 'react'; import type { PropsWithChildren } from 'react';
import { memo, useRef } from 'react'; import { memo, useRef } from 'react';
@@ -10,7 +11,7 @@ const DesktopThemeSync = memo(function DesktopThemeSync() {
const onceRef = useRef(false); const onceRef = useRef(false);
if (lastThemeRef.current !== theme || !onceRef.current) { if (lastThemeRef.current !== theme || !onceRef.current) {
if (environment.isDesktop && theme) { if (environment.isDesktop && theme) {
window.apis?.ui apis?.ui
.handleThemeChange(theme as 'dark' | 'light' | 'system') .handleThemeChange(theme as 'dark' | 'light' | 'system')
.catch(err => { .catch(err => {
console.error(err); console.error(err);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
import { apis } from '@affine/electron-api';
import { assertExists } from '@blocksuite/global/utils';
import { import {
type AppConfigSchema, type AppConfigSchema,
AppConfigStorage, AppConfigStorage,
@@ -13,11 +15,13 @@ class AppConfigProxy {
value: AppConfigSchema = defaultAppConfig; value: AppConfigSchema = defaultAppConfig;
async getSync(): Promise<AppConfigSchema> { 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> { async setSync(): Promise<void> {
await window.apis.configStorage.set(this.value); assertExists(apis);
await apis.configStorage.set(this.value);
} }
get(): AppConfigSchema { 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 { useCallback } from 'react';
import { redirect } from 'react-router-dom'; import { redirect } from 'react-router-dom';
@@ -23,7 +25,8 @@ export const Component = () => {
const openApp = useCallback(() => { const openApp = useCallback(() => {
if (environment.isDesktop) { if (environment.isDesktop) {
window.apis.ui.handleOpenMainApp().catch(err => { assertExists(apis);
apis.ui.handleOpenMainApp().catch(err => {
console.log('failed to open main app', err); console.log('failed to open main app', err);
}); });
} else { } else {

View File

@@ -23,6 +23,9 @@
{ {
"path": "../../frontend/workspace" "path": "../../frontend/workspace"
}, },
{
"path": "../../frontend/electron-api"
},
{ {
"path": "../../common/debug" "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 { ValidationResult } from '@affine/native';
import { WorkspaceVersion } from '@toeverything/infra/blocksuite'; import { WorkspaceVersion } from '@toeverything/infra/blocksuite';
import type {
FakeDialogResult,
LoadDBFileResult,
MoveDBFileResult,
SaveDBFileResult,
SelectDBFileLocationResult,
} from '@toeverything/infra/type';
import fs from 'fs-extra'; import fs from 'fs-extra';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
@@ -28,6 +21,45 @@ import {
getWorkspacesBasePath, getWorkspacesBasePath,
} from '../workspace/meta'; } 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: // NOTE:
// we are using native dialogs because HTML dialogs do not give full file paths // 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 { dbEvents, dbHandlers } from './db';
import { dialogHandlers } from './dialog'; import { dialogHandlers } from './dialog';
import { provideExposed } from './provide'; import { provideExposed } from './provide';
import { workspaceEvents, workspaceHandlers } from './workspace'; import { workspaceEvents, workspaceHandlers } from './workspace';
type AllHandlers = {
db: DBHandlers;
workspace: WorkspaceHandlers;
dialog: DialogHandlers;
};
export const handlers = { export const handlers = {
db: dbHandlers, db: dbHandlers,
workspace: workspaceHandlers, workspace: workspaceHandlers,
dialog: dialogHandlers, dialog: dialogHandlers,
} satisfies AllHandlers; };
export const events = { export const events = {
db: dbEvents, db: dbEvents,

View File

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

View File

@@ -1,10 +1,7 @@
import { assertExists } from '@blocksuite/global/utils'; import { assertExists } from '@blocksuite/global/utils';
import type {
HelperToMain,
MainToHelper,
} from '@toeverything/infra/preload/electron';
import { AsyncCall } from 'async-call-rpc'; import { AsyncCall } from 'async-call-rpc';
import type { HelperToMain, MainToHelper } from '../shared/type';
import { exposed } from './provide'; import { exposed } from './provide';
const helperToMainServer: HelperToMain = { 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. * 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 { ipcMain } from 'electron';
import { clipboardHandlers } from './clipboard'; 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 // Note: all of these handlers will be the single-source-of-truth for the apis exposed to the renderer process
export const allHandlers = { export const allHandlers = {
debug: debugHandlers, debug: debugHandlers,
@@ -60,7 +24,7 @@ export const allHandlers = {
export: exportHandlers, export: exportHandlers,
updater: updaterHandlers, updater: updaterHandlers,
configStorage: configStorageHandlers, configStorage: configStorageHandlers,
} satisfies AllHandlers; };
export const registerHandlers = () => { export const registerHandlers = () => {
// TODO: listen to namespace instead of individual event types // TODO: listen to namespace instead of individual event types

View File

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

View File

@@ -1,57 +1,15 @@
import { contextBridge, ipcRenderer } from 'electron'; import { contextBridge } from 'electron';
(async () => { import { affine, appInfo, getElectronAPIs } from './electron-api';
const { appInfo, getElectronAPIs } = await import(
'@toeverything/infra/preload/electron'
);
const { apis, events } = getElectronAPIs();
contextBridge.exposeInMainWorld('appInfo', appInfo); const { apis, events } = getElectronAPIs();
contextBridge.exposeInMainWorld('apis', apis);
contextBridge.exposeInMainWorld('events', events);
// Credit to microsoft/vscode contextBridge.exposeInMainWorld('appInfo', appInfo);
const globals = { contextBridge.exposeInMainWorld('apis', apis);
ipcRenderer: { contextBridge.exposeInMainWorld('events', events);
send(channel: string, ...args: any[]) {
ipcRenderer.send(channel, ...args);
},
invoke(channel: string, ...args: any[]) { try {
return ipcRenderer.invoke(channel, ...args); contextBridge.exposeInMainWorld('affine', affine);
}, } catch (error) {
console.error('Failed to expose affine APIs to window object!', error);
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);
});

View File

@@ -1,37 +1,50 @@
// Please add modules to `external` in `rollupOptions` to avoid wrong bundling. // Please add modules to `external` in `rollupOptions` to avoid wrong bundling.
import { AsyncCall, type EventBasedChannel } from 'async-call-rpc'; import { AsyncCall, type EventBasedChannel } from 'async-call-rpc';
import type { app, dialog, shell } from 'electron';
import { ipcRenderer } from 'electron'; import { ipcRenderer } from 'electron';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { z } from 'zod'; import { z } from 'zod';
export interface ExposedMeta { import type {
handlers: [string, string[]][]; ExposedMeta,
events: [string, string[]][]; HelperToRenderer,
} RendererToHelper,
} from '../shared/type';
// render <-> helper export const affine = {
export interface RendererToHelper { ipcRenderer: {
postEvent: (channel: string, ...args: any[]) => void; send(channel: string, ...args: any[]) {
} ipcRenderer.send(channel, ...args);
},
export interface HelperToRenderer { invoke(channel: string, ...args: any[]) {
[key: string]: (...args: any[]) => Promise<any>; return ipcRenderer.invoke(channel, ...args);
} },
// helper <-> main on(
export interface HelperToMain { channel: string,
getMeta: () => ExposedMeta; listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void
} ) {
ipcRenderer.on(channel, listener);
return this;
},
export type MainToHelper = Pick< once(
typeof dialog & typeof shell & typeof app, channel: string,
| 'showOpenDialog' listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void
| 'showSaveDialog' ) {
| 'openExternal' ipcRenderer.once(channel, listener);
| 'showItemInFolder' return this;
| 'getPath' },
>;
removeListener(
channel: string,
listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void
) {
ipcRenderer.removeListener(channel, listener);
return this;
},
},
};
export function getElectronAPIs() { export function getElectronAPIs() {
const mainAPIs = getMainAPIs(); const mainAPIs = getMainAPIs();

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import { apis } from '@affine/electron-api';
import { WorkspaceFlavour } from '@affine/env/workspace'; import { WorkspaceFlavour } from '@affine/env/workspace';
import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store';
import { difference } from 'lodash-es'; import { difference } from 'lodash-es';
@@ -96,8 +97,8 @@ export function createLocalWorkspaceListProvider(): WorkspaceListProvider {
JSON.stringify(allWorkspaceIDs.filter(x => x !== workspaceId)) JSON.stringify(allWorkspaceIDs.filter(x => x !== workspaceId))
); );
if (window.apis && environment.isDesktop) { if (apis && environment.isDesktop) {
await window.apis.workspace.delete(workspaceId); await apis.workspace.delete(workspaceId);
} }
// notify all browser tabs, so they can update their workspace list // 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 { encodeStateVectorFromUpdate } from 'yjs';
import type { SyncStorage } from '../../engine/sync'; import type { SyncStorage } from '../../engine/sync';
export function createSQLiteStorage(workspaceId: string): SyncStorage { export function createSQLiteStorage(workspaceId: string): SyncStorage {
if (!window.apis?.db) { if (!apis?.db) {
throw new Error('sqlite datasource is not available'); throw new Error('sqlite datasource is not available');
} }
return { return {
name: 'sqlite', name: 'sqlite',
async pull(docId, _state) { 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,
workspaceId === docId ? undefined : docId workspaceId === docId ? undefined : docId
); );
@@ -25,7 +29,10 @@ export function createSQLiteStorage(workspaceId: string): SyncStorage {
return null; return null;
}, },
async push(docId, data) { 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, workspaceId,
data, data,
workspaceId === docId ? undefined : docId workspaceId === docId ? undefined : docId

View File

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

View File

@@ -1,10 +1,17 @@
import path from 'node:path'; import path from 'node:path';
import type { apis } from '@affine/electron-api';
import { test } from '@affine-test/kit/electron'; import { test } from '@affine-test/kit/electron';
import { clickSideBarCurrentWorkspaceBanner } from '@affine-test/kit/utils/sidebar'; import { clickSideBarCurrentWorkspaceBanner } from '@affine-test/kit/utils/sidebar';
import { expect } from '@playwright/test'; import { expect } from '@playwright/test';
import fs from 'fs-extra'; import fs from 'fs-extra';
declare global {
interface Window {
apis: typeof apis;
}
}
test('check workspace has a DB file', async ({ appInfo, workspace }) => { test('check workspace has a DB file', async ({ appInfo, workspace }) => {
const w = await workspace.current(); const w = await workspace.current();
const dbPath = path.join( const dbPath = path.join(

View File

@@ -7,6 +7,7 @@
"devDependencies": { "devDependencies": {
"@affine-test/fixtures": "workspace:*", "@affine-test/fixtures": "workspace:*",
"@affine-test/kit": "workspace:*", "@affine-test/kit": "workspace:*",
"@affine/electron-api": "workspace:*",
"@playwright/test": "^1.39.0", "@playwright/test": "^1.39.0",
"@types/fs-extra": "^11.0.2", "@types/fs-extra": "^11.0.2",
"fs-extra": "^11.1.1", "fs-extra": "^11.1.1",

View File

@@ -11,6 +11,9 @@
}, },
{ {
"path": "../../tests/fixtures" "path": "../../tests/fixtures"
},
{
"path": "../../packages/frontend/electron-api"
} }
] ]
} }

View File

@@ -10,6 +10,7 @@
"./e2e-enhance/*": "./e2e-enhance/*.ts" "./e2e-enhance/*": "./e2e-enhance/*.ts"
}, },
"devDependencies": { "devDependencies": {
"@affine/electron-api": "workspace:*",
"@node-rs/argon2": "^1.5.2", "@node-rs/argon2": "^1.5.2",
"@playwright/test": "^1.39.0", "@playwright/test": "^1.39.0",
"express": "^4.18.2", "express": "^4.18.2",

View File

@@ -1,11 +1,18 @@
import type { affine } from '@affine/electron-api';
// Credit: https://github.com/spaceagetv/electron-playwright-helpers/blob/main/src/ipc_helpers.ts // Credit: https://github.com/spaceagetv/electron-playwright-helpers/blob/main/src/ipc_helpers.ts
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import type { ElectronApplication } from 'playwright'; import type { ElectronApplication } from 'playwright';
declare global {
interface Window {
affine: typeof affine;
}
}
export function ipcRendererInvoke(page: Page, channel: string, ...args: any[]) { export function ipcRendererInvoke(page: Page, channel: string, ...args: any[]) {
return page.evaluate( return page.evaluate(
({ channel, args }) => { ({ channel, args }) => {
return window.affine.ipcRenderer.invoke(channel, ...args); return window.affine?.ipcRenderer.invoke(channel, ...args);
}, },
{ channel, args } { channel, args }
); );
@@ -14,7 +21,7 @@ export function ipcRendererInvoke(page: Page, channel: string, ...args: any[]) {
export function ipcRendererSend(page: Page, channel: string, ...args: any[]) { export function ipcRendererSend(page: Page, channel: string, ...args: any[]) {
return page.evaluate( return page.evaluate(
({ channel, args }) => { ({ channel, args }) => {
window.affine.ipcRenderer.send(channel, ...args); window.affine?.ipcRenderer.send(channel, ...args);
}, },
{ channel, args } { channel, args }
); );

View File

@@ -1,53 +1,6 @@
import type { Environment, RuntimeConfig } from '@affine/env/global'; import type { Environment, RuntimeConfig } from '@affine/env/global';
import type {
ConfigStorageHandlerManager,
DBHandlerManager,
DebugHandlerManager,
DialogHandlerManager,
EventMap,
ExportHandlerManager,
UIHandlerManager,
UnwrapManagerHandlerToClientSide,
UpdaterHandlerManager,
WorkspaceHandlerManager,
} from '@toeverything/infra/index';
declare global { declare global {
interface Window {
appInfo: {
electron: boolean;
};
apis: {
db: UnwrapManagerHandlerToClientSide<DBHandlerManager>;
debug: UnwrapManagerHandlerToClientSide<DebugHandlerManager>;
dialog: UnwrapManagerHandlerToClientSide<DialogHandlerManager>;
export: UnwrapManagerHandlerToClientSide<ExportHandlerManager>;
ui: UnwrapManagerHandlerToClientSide<UIHandlerManager>;
updater: UnwrapManagerHandlerToClientSide<UpdaterHandlerManager>;
workspace: UnwrapManagerHandlerToClientSide<WorkspaceHandlerManager>;
configStorage: UnwrapManagerHandlerToClientSide<ConfigStorageHandlerManager>;
};
events: EventMap;
affine: {
ipcRenderer: {
send(channel: string, ...args: any[]): void;
invoke(channel: string, ...args: any[]): Promise<any>;
on(
channel: string,
listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void
): this;
once(
channel: string,
listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void
): this;
removeListener(
channel: string,
listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void
): this;
};
};
}
// eslint-disable-next-line no-var // eslint-disable-next-line no-var
var process: { var process: {
env: Record<string, string>; env: Record<string, string>;

View File

@@ -4,8 +4,7 @@
"types": "./__all.d.ts", "types": "./__all.d.ts",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@affine/env": "workspace:*", "@affine/env": "workspace:*"
"@toeverything/infra": "workspace:*"
}, },
"version": "0.11.0" "version": "0.11.0"
} }

View File

@@ -115,6 +115,7 @@ __metadata:
dependencies: dependencies:
"@affine-test/fixtures": "workspace:*" "@affine-test/fixtures": "workspace:*"
"@affine-test/kit": "workspace:*" "@affine-test/kit": "workspace:*"
"@affine/electron-api": "workspace:*"
"@playwright/test": "npm:^1.39.0" "@playwright/test": "npm:^1.39.0"
"@types/fs-extra": "npm:^11.0.2" "@types/fs-extra": "npm:^11.0.2"
fs-extra: "npm:^11.1.1" fs-extra: "npm:^11.1.1"
@@ -156,6 +157,7 @@ __metadata:
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "@affine-test/kit@workspace:tests/kit" resolution: "@affine-test/kit@workspace:tests/kit"
dependencies: dependencies:
"@affine/electron-api": "workspace:*"
"@node-rs/argon2": "npm:^1.5.2" "@node-rs/argon2": "npm:^1.5.2"
"@playwright/test": "npm:^1.39.0" "@playwright/test": "npm:^1.39.0"
express: "npm:^4.18.2" express: "npm:^4.18.2"
@@ -209,6 +211,7 @@ __metadata:
resolution: "@affine/component@workspace:packages/frontend/component" resolution: "@affine/component@workspace:packages/frontend/component"
dependencies: dependencies:
"@affine/debug": "workspace:*" "@affine/debug": "workspace:*"
"@affine/electron-api": "workspace:*"
"@affine/graphql": "workspace:*" "@affine/graphql": "workspace:*"
"@affine/i18n": "workspace:*" "@affine/i18n": "workspace:*"
"@affine/workspace": "workspace:*" "@affine/workspace": "workspace:*"
@@ -309,6 +312,7 @@ __metadata:
"@affine/cmdk": "workspace:*" "@affine/cmdk": "workspace:*"
"@affine/component": "workspace:*" "@affine/component": "workspace:*"
"@affine/debug": "workspace:*" "@affine/debug": "workspace:*"
"@affine/electron-api": "workspace:*"
"@affine/env": "workspace:*" "@affine/env": "workspace:*"
"@affine/graphql": "workspace:*" "@affine/graphql": "workspace:*"
"@affine/i18n": "workspace:*" "@affine/i18n": "workspace:*"
@@ -418,6 +422,15 @@ __metadata:
languageName: unknown languageName: unknown
linkType: soft linkType: soft
"@affine/electron-api@workspace:*, @affine/electron-api@workspace:packages/frontend/electron-api":
version: 0.0.0-use.local
resolution: "@affine/electron-api@workspace:packages/frontend/electron-api"
dependencies:
"@toeverything/infra": "workspace:*"
electron: "npm:^27.1.0"
languageName: unknown
linkType: soft
"@affine/electron@workspace:packages/frontend/electron": "@affine/electron@workspace:packages/frontend/electron":
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "@affine/electron@workspace:packages/frontend/electron" resolution: "@affine/electron@workspace:packages/frontend/electron"
@@ -788,6 +801,7 @@ __metadata:
dependencies: dependencies:
"@affine-test/fixtures": "workspace:*" "@affine-test/fixtures": "workspace:*"
"@affine/debug": "workspace:*" "@affine/debug": "workspace:*"
"@affine/electron-api": "workspace:*"
"@affine/env": "workspace:*" "@affine/env": "workspace:*"
"@affine/graphql": "workspace:*" "@affine/graphql": "workspace:*"
"@testing-library/react": "npm:^14.0.0" "@testing-library/react": "npm:^14.0.0"
@@ -13438,6 +13452,7 @@ __metadata:
resolution: "@toeverything/hooks@workspace:packages/frontend/hooks" resolution: "@toeverything/hooks@workspace:packages/frontend/hooks"
dependencies: dependencies:
"@affine/debug": "workspace:*" "@affine/debug": "workspace:*"
"@affine/electron-api": "workspace:*"
"@affine/env": "workspace:*" "@affine/env": "workspace:*"
"@affine/workspace": "workspace:*" "@affine/workspace": "workspace:*"
"@blocksuite/block-std": "npm:0.11.0-nightly-202312220916-e3abcbb" "@blocksuite/block-std": "npm:0.11.0-nightly-202312220916-e3abcbb"
@@ -13646,7 +13661,6 @@ __metadata:
resolution: "@types/affine__env@workspace:tools/@types/env" resolution: "@types/affine__env@workspace:tools/@types/env"
dependencies: dependencies:
"@affine/env": "workspace:*" "@affine/env": "workspace:*"
"@toeverything/infra": "workspace:*"
languageName: unknown languageName: unknown
linkType: soft linkType: soft