feat: add helper process (#2753)

This commit is contained in:
Peng Xiao
2023-06-13 10:01:43 +08:00
committed by GitHub
parent dff8a0db7d
commit 5ba2dff008
74 changed files with 1002 additions and 1048 deletions

View File

@@ -0,0 +1 @@
tmp

View File

@@ -0,0 +1,173 @@
import assert from 'node:assert';
import path from 'node:path';
import { setTimeout } from 'node:timers/promises';
import fs from 'fs-extra';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import type { MainIPCHandlerMap } from '../exposed';
const registeredHandlers = new Map<
string,
((...args: any[]) => Promise<any>)[]
>();
type WithoutFirstParameter<T> = T extends (_: any, ...args: infer P) => infer R
? (...args: P) => R
: T;
// common mock dispatcher for ipcMain.handle AND app.on
// alternatively, we can use single parameter for T & F, eg, dispatch('workspace:list'),
// however this is too hard to be typed correctly
async function dispatch<
T extends keyof MainIPCHandlerMap,
F extends keyof MainIPCHandlerMap[T]
>(
namespace: T,
functionName: F,
...args: Parameters<WithoutFirstParameter<MainIPCHandlerMap[T][F]>>
): // @ts-expect-error
ReturnType<MainIPCHandlerMap[T][F]> {
// @ts-expect-error
const handlers = registeredHandlers.get(namespace + ':' + functionName);
assert(handlers);
// we only care about the first handler here
return await handlers[0](null, ...args);
}
const SESSION_DATA_PATH = path.join(__dirname, './tmp', 'affine-test');
const DOCUMENTS_PATH = path.join(__dirname, './tmp', 'affine-test-documents');
const browserWindow = {
isDestroyed: () => {
return false;
},
setWindowButtonVisibility: (_v: boolean) => {
// will be stubbed later
},
webContents: {
send: (_type: string, ..._args: any[]) => {
// will be stubbed later
},
},
};
const ipcMain = {
handle: (key: string, callback: (...args: any[]) => Promise<any>) => {
const handlers = registeredHandlers.get(key) || [];
handlers.push(callback);
registeredHandlers.set(key, handlers);
},
setMaxListeners: (_n: number) => {
// noop
},
};
const nativeTheme = {
themeSource: 'light',
};
const electronModule = {
app: {
getPath: (name: string) => {
if (name === 'sessionData') {
return SESSION_DATA_PATH;
} else if (name === 'documents') {
return DOCUMENTS_PATH;
}
throw new Error('not implemented');
},
name: 'affine-test',
on: (name: string, callback: (...args: any[]) => any) => {
const handlers = registeredHandlers.get(name) || [];
handlers.push(callback);
registeredHandlers.set(name, handlers);
},
addListener: (...args: any[]) => {
// @ts-expect-error
electronModule.app.on(...args);
},
removeListener: () => {},
},
BrowserWindow: {
getAllWindows: () => {
return [browserWindow];
},
},
nativeTheme: nativeTheme,
ipcMain,
shell: {} as Partial<Electron.Shell>,
dialog: {} as Partial<Electron.Dialog>,
};
// dynamically import handlers so that we can inject local variables to mocks
vi.doMock('electron', () => {
return electronModule;
});
beforeEach(async () => {
const { registerHandlers } = await import('../handlers');
registerHandlers();
// should also register events
const { registerEvents } = await import('../events');
registerEvents();
await fs.mkdirp(SESSION_DATA_PATH);
registeredHandlers.get('ready')?.forEach(fn => fn());
});
afterEach(async () => {
// reset registered handlers
registeredHandlers.get('before-quit')?.forEach(fn => fn());
// wait for the db to be closed on Windows
if (process.platform === 'win32') {
await setTimeout(200);
}
await fs.remove(SESSION_DATA_PATH);
});
describe('UI handlers', () => {
test('theme-change', async () => {
await dispatch('ui', 'handleThemeChange', 'dark');
expect(nativeTheme.themeSource).toBe('dark');
await dispatch('ui', 'handleThemeChange', 'light');
expect(nativeTheme.themeSource).toBe('light');
});
test('sidebar-visibility-change (macOS)', async () => {
vi.stubGlobal('process', { platform: 'darwin' });
const setWindowButtonVisibility = vi.fn();
browserWindow.setWindowButtonVisibility = setWindowButtonVisibility;
await dispatch('ui', 'handleSidebarVisibilityChange', true);
expect(setWindowButtonVisibility).toBeCalledWith(true);
await dispatch('ui', 'handleSidebarVisibilityChange', false);
expect(setWindowButtonVisibility).toBeCalledWith(false);
vi.unstubAllGlobals();
});
test('sidebar-visibility-change (non-macOS)', async () => {
vi.stubGlobal('process', { platform: 'linux' });
const setWindowButtonVisibility = vi.fn();
browserWindow.setWindowButtonVisibility = setWindowButtonVisibility;
await dispatch('ui', 'handleSidebarVisibilityChange', true);
expect(setWindowButtonVisibility).not.toBeCalled();
vi.unstubAllGlobals();
});
});
describe('applicationMenu', () => {
// test some basic IPC events
test('applicationMenu event', async () => {
const { applicationMenuSubjects } = await import('../application-menu');
const sendStub = vi.fn();
browserWindow.webContents.send = sendStub;
applicationMenuSubjects.newPageAction.next();
expect(sendStub).toHaveBeenCalledWith(
'applicationMenu:onNewPageAction',
undefined
);
browserWindow.webContents.send = () => {};
});
});

View File

@@ -0,0 +1,142 @@
import { app, Menu } from 'electron';
import { revealLogFile } from '../logger';
import { checkForUpdatesAndNotify } from '../updater';
import { isMacOS } from '../utils';
import { applicationMenuSubjects } from './subject';
// Unique id for menuitems
const MENUITEM_NEW_PAGE = 'affine:new-page';
export function createApplicationMenu() {
const isMac = isMacOS();
// Electron menu cannot be modified
// You have to copy the complete default menu template event if you want to add a single custom item
// See https://www.electronjs.org/docs/latest/api/menu#examples
const template = [
// { role: 'appMenu' }
...(isMac
? [
{
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
},
]
: []),
// { role: 'fileMenu' }
{
label: 'File',
submenu: [
{
id: MENUITEM_NEW_PAGE,
label: 'New Page',
accelerator: isMac ? 'Cmd+N' : 'Ctrl+N',
click: () => {
applicationMenuSubjects.newPageAction.next();
},
},
{ type: 'separator' },
isMac ? { role: 'close' } : { role: 'quit' },
],
},
// { role: 'editMenu' }
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
...(isMac
? [
{ role: 'pasteAndMatchStyle' },
{ role: 'delete' },
{ role: 'selectAll' },
{ type: 'separator' },
{
label: 'Speech',
submenu: [{ role: 'startSpeaking' }, { role: 'stopSpeaking' }],
},
]
: [{ role: 'delete' }, { type: 'separator' }, { role: 'selectAll' }]),
],
},
// { role: 'viewMenu' }
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
// { role: 'windowMenu' }
{
label: 'Window',
submenu: [
{ role: 'minimize' },
{ role: 'zoom' },
...(isMac
? [
{ type: 'separator' },
{ role: 'front' },
{ type: 'separator' },
{ role: 'window' },
]
: [{ role: 'close' }]),
],
},
{
role: 'help',
submenu: [
{
label: 'Learn More',
click: async () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { shell } = require('electron');
await shell.openExternal('https://affine.pro/');
},
},
{
label: 'Open log file',
click: async () => {
await revealLogFile();
},
},
{
label: 'Check for Updates',
click: async () => {
await checkForUpdatesAndNotify(true);
},
},
],
},
];
// @ts-expect-error: The snippet is copied from Electron official docs.
// It's working as expected. No idea why it contains type errors.
// Just ignore for now.
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
return menu;
}

View File

@@ -0,0 +1,20 @@
import type { MainEventRegister } from '../type';
import { applicationMenuSubjects } from './subject';
export * from './create';
export * from './subject';
/**
* Events triggered by application menu
*/
export const applicationMenuEvents = {
/**
* File -> New Page
*/
onNewPageAction: (fn: () => void) => {
const sub = applicationMenuSubjects.newPageAction.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
} satisfies Record<string, MainEventRegister>;

View File

@@ -0,0 +1,5 @@
import { Subject } from 'rxjs';
export const applicationMenuSubjects = {
newPageAction: new Subject<void>(),
};

View File

@@ -0,0 +1,39 @@
import { app, BrowserWindow } from 'electron';
import { applicationMenuEvents } from './application-menu';
import { logger } from './logger';
import { updaterEvents } from './updater/event';
export const allEvents = {
applicationMenu: applicationMenuEvents,
updater: updaterEvents,
};
function getActiveWindows() {
return BrowserWindow.getAllWindows().filter(win => !win.isDestroyed());
}
export function registerEvents() {
// register events
for (const [namespace, namespaceEvents] of Object.entries(allEvents)) {
for (const [key, eventRegister] of Object.entries(namespaceEvents)) {
const subscription = eventRegister((...args: any[]) => {
const chan = `${namespace}:${key}`;
logger.info(
'[ipc-event]',
chan,
args.filter(
a =>
a !== undefined &&
typeof a !== 'function' &&
typeof a !== 'object'
)
);
getActiveWindows().forEach(win => win.webContents.send(chan, ...args));
});
app.on('before-quit', () => {
subscription();
});
}
}
}

View File

@@ -0,0 +1,10 @@
import type { NamespaceHandlers } from '../type';
import { savePDFFileAs } from './pdf';
export const exportHandlers = {
savePDFFileAs: async (_, title: string) => {
return savePDFFileAs(title);
},
} satisfies NamespaceHandlers;
export * from './pdf';

View File

@@ -0,0 +1,61 @@
import { BrowserWindow, dialog, shell } from 'electron';
import fs from 'fs-extra';
import { logger } from '../logger';
import type { ErrorMessage } from './utils';
import { getFakedResult } from './utils';
export interface SavePDFFileResult {
filePath?: string;
canceled?: boolean;
error?: ErrorMessage;
}
/**
* This function is called when the user clicks the "Export to PDF" button in the electron.
*
* It will just copy the file to the given path
*/
export async function savePDFFileAs(
pageTitle: string
): Promise<SavePDFFileResult> {
try {
const ret =
getFakedResult() ??
(await dialog.showSaveDialog({
properties: ['showOverwriteConfirmation'],
title: 'Save PDF',
showsTagField: false,
buttonLabel: 'Save',
defaultPath: `${pageTitle}.pdf`,
message: 'Save Page as a PDF file',
}));
const filePath = ret.filePath;
if (ret.canceled || !filePath) {
return {
canceled: true,
};
}
await BrowserWindow.getFocusedWindow()
?.webContents.printToPDF({
pageSize: 'A4',
printBackground: true,
landscape: false,
})
.then(data => {
fs.writeFile(filePath, data, error => {
if (error) throw error;
logger.log(`Wrote PDF successfully to ${filePath}`);
});
});
await shell.openPath(filePath);
return { filePath };
} catch (err) {
logger.error('savePDFFileAs', err);
return {
error: 'UNKNOWN_ERROR',
};
}
}

View File

@@ -0,0 +1,24 @@
// provide a backdoor to set dialog path for testing in playwright
interface FakeDialogResult {
canceled?: boolean;
filePath?: string;
filePaths?: string[];
}
// result will be used in the next call to showOpenDialog
// if it is being read once, it will be reset to undefined
let fakeDialogResult: FakeDialogResult | undefined = undefined;
export function getFakedResult() {
const result = fakeDialogResult;
fakeDialogResult = undefined;
return result;
}
export function setFakeDialogResult(result: FakeDialogResult | undefined) {
fakeDialogResult = result;
// for convenience, we will fill filePaths with filePath if it is not set
if (result?.filePaths === undefined && result?.filePath !== undefined) {
result.filePaths = [result.filePath];
}
}
const ErrorMessages = ['FILE_ALREADY_EXISTS', 'UNKNOWN_ERROR'] as const;
export type ErrorMessage = (typeof ErrorMessages)[number];

View File

@@ -0,0 +1,29 @@
import { allEvents as events } from './events';
import { allHandlers as handlers } from './handlers';
// this will be used by preload script to expose all handlers and events to the renderer process
// - register in exposeInMainWorld in preload
// - provide type hints
export { events, handlers };
export const getExposedMeta = () => {
const handlersMeta = Object.entries(handlers).map(
([namespace, namespaceHandlers]) => {
return [namespace, Object.keys(namespaceHandlers)];
}
);
const eventsMeta = Object.entries(events).map(
([namespace, namespaceHandlers]) => {
return [namespace, Object.keys(namespaceHandlers)];
}
);
return {
handlers: handlersMeta,
events: eventsMeta,
};
};
export type MainIPCHandlerMap = typeof handlers;
export type MainIPCEventMap = typeof events;

View File

@@ -0,0 +1,79 @@
import type {
DebugHandlerManager,
ExportHandlerManager,
UIHandlerManager,
UnwrapManagerHandlerToServerSide,
UpdaterHandlerManager,
} from '@toeverything/infra';
import { ipcMain } from 'electron';
import { exportHandlers } from './export';
import { getLogFilePath, logger, revealLogFile } from './logger';
import { uiHandlers } from './ui';
import { updaterHandlers } from './updater';
export const debugHandlers = {
revealLogFile: async () => {
return revealLogFile();
},
logFilePath: async () => {
return getLogFilePath();
},
};
type AllHandlers = {
debug: UnwrapManagerHandlerToServerSide<
Electron.IpcMainInvokeEvent,
DebugHandlerManager
>;
export: UnwrapManagerHandlerToServerSide<
Electron.IpcMainInvokeEvent,
ExportHandlerManager
>;
ui: UnwrapManagerHandlerToServerSide<
Electron.IpcMainInvokeEvent,
UIHandlerManager
>;
updater: UnwrapManagerHandlerToServerSide<
Electron.IpcMainInvokeEvent,
UpdaterHandlerManager
>;
};
// 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,
ui: uiHandlers,
export: exportHandlers,
updater: updaterHandlers,
} satisfies AllHandlers;
export const registerHandlers = () => {
// TODO: listen to namespace instead of individual event types
ipcMain.setMaxListeners(100);
for (const [namespace, namespaceHandlers] of Object.entries(allHandlers)) {
for (const [key, handler] of Object.entries(namespaceHandlers)) {
const chan = `${namespace}:${key}`;
ipcMain.handle(chan, async (e, ...args) => {
const start = performance.now();
try {
// @ts-expect-error - TODO: fix this
const result = await handler(e, ...args);
logger.info(
'[ipc-api]',
chan,
args.filter(
arg => typeof arg !== 'function' && typeof arg !== 'object'
),
'-',
(performance.now() - start).toFixed(2),
'ms'
);
return result;
} catch (error) {
logger.error('[ipc]', chan, error);
}
});
}
}
};

View File

@@ -0,0 +1,111 @@
import path from 'node:path';
import { type _AsyncVersionOf, AsyncCall } from 'async-call-rpc';
import {
app,
dialog,
MessageChannelMain,
shell,
type UtilityProcess,
utilityProcess,
type WebContents,
} from 'electron';
import { logger } from './logger';
import { MessageEventChannel } from './utils';
const HELPER_PROCESS_PATH = path.join(__dirname, './helper.js');
function pickAndBind<T extends object, U extends keyof T>(
obj: T,
keys: U[]
): { [K in U]: T[K] } {
return keys.reduce((acc, key) => {
const prop = obj[key];
acc[key] =
typeof prop === 'function'
? // @ts-expect-error - a hack to bind the function
prop.bind(obj)
: prop;
return acc;
}, {} as any);
}
class HelperProcessManager {
ready: Promise<void>;
#process: UtilityProcess;
// a rpc server for the main process -> helper process
rpc?: _AsyncVersionOf<PeersAPIs.HelperToMain>;
static instance = new HelperProcessManager();
private constructor() {
const helperProcess = utilityProcess.fork(HELPER_PROCESS_PATH);
this.#process = helperProcess;
this.ready = new Promise((resolve, reject) => {
helperProcess.once('spawn', () => {
try {
this.#connectMain();
resolve();
} catch (err) {
logger.error('[helper] connectMain error', err);
reject(err);
}
});
});
app.on('before-quit', () => {
this.#process.kill();
});
}
// bridge renderer <-> helper process
connectRenderer(renderer: WebContents) {
// connect to the helper process
const { port1: helperPort, port2: rendererPort } = new MessageChannelMain();
this.#process.postMessage({ channel: 'renderer-connect' }, [helperPort]);
renderer.postMessage('helper-connection', null, [rendererPort]);
return () => {
helperPort.close();
rendererPort.close();
};
}
// bridge main <-> helper process
// also set up the RPC to the helper process
#connectMain() {
const dialogMethods = pickAndBind(dialog, [
'showOpenDialog',
'showSaveDialog',
]);
const shellMethods = pickAndBind(shell, [
'openExternal',
'showItemInFolder',
]);
const appMethods = pickAndBind(app, ['getPath']);
const mainToHelperServer: PeersAPIs.MainToHelper = {
...dialogMethods,
...shellMethods,
...appMethods,
};
const server = AsyncCall<PeersAPIs.HelperToMain>(mainToHelperServer, {
strict: {
// the channel is shared for other purposes as well so that we do not want to
// restrict to only JSONRPC messages
unknownMessage: false,
},
channel: new MessageEventChannel(this.#process),
});
this.rpc = server;
}
}
export async function ensureHelperProcess() {
const helperProcessManager = HelperProcessManager.instance;
await helperProcessManager.ready;
return helperProcessManager;
}

View File

@@ -0,0 +1,71 @@
import './security-restrictions';
import { app } from 'electron';
import { createApplicationMenu } from './application-menu/create';
import { registerEvents } from './events';
import { registerHandlers } from './handlers';
import { ensureHelperProcess } from './helper-process';
import { logger } from './logger';
import { restoreOrCreateWindow } from './main-window';
import { registerPlugin } from './plugin';
import { registerProtocol } from './protocol';
import { registerUpdater } from './updater';
if (require('electron-squirrel-startup')) app.quit();
// allow tests to overwrite app name through passing args
if (process.argv.includes('--app-name')) {
const appNameIndex = process.argv.indexOf('--app-name');
const appName = process.argv[appNameIndex + 1];
app.setName(appName);
}
/**
* Prevent multiple instances
*/
const isSingleInstance = app.requestSingleInstanceLock();
if (!isSingleInstance) {
logger.info('Another instance is running, exiting...');
app.quit();
process.exit(0);
}
app.on('second-instance', () => {
restoreOrCreateWindow().catch(e =>
console.error('Failed to restore or create window:', e)
);
});
app.on('open-url', async (_, _url) => {
// todo: handle `affine://...` urls
});
/**
* Shout down background process if all windows was closed
*/
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
/**
* @see https://www.electronjs.org/docs/v14-x-y/api/app#event-activate-macos Event: 'activate'
*/
app.on('activate', restoreOrCreateWindow);
/**
* Create app window when background process will be ready
*/
app
.whenReady()
.then(registerProtocol)
.then(registerHandlers)
.then(registerEvents)
.then(registerPlugin)
.then(ensureHelperProcess)
.then(restoreOrCreateWindow)
.then(createApplicationMenu)
.then()
.then(registerUpdater)
.catch(e => console.error('Failed create window:', e));

View File

@@ -0,0 +1,14 @@
import { shell } from 'electron';
import log from 'electron-log';
export const logger = log.scope('main');
log.initialize();
export function getLogFilePath() {
return log.transports.file.getFile().path;
}
export async function revealLogFile() {
const filePath = getLogFilePath();
return await shell.openPath(filePath);
}

View File

@@ -0,0 +1,133 @@
import assert from 'node:assert';
import { BrowserWindow, nativeTheme } from 'electron';
import electronWindowState from 'electron-window-state';
import { join } from 'path';
import { getExposedMeta } from './exposed';
import { ensureHelperProcess } from './helper-process';
import { logger } from './logger';
import { isMacOS, isWindows } from './utils';
const IS_DEV: boolean =
process.env.NODE_ENV === 'development' && !process.env.CI;
const DEV_TOOL = process.env.DEV_TOOL === 'true';
async function createWindow() {
logger.info('create window');
const mainWindowState = electronWindowState({
defaultWidth: 1000,
defaultHeight: 800,
});
const helperProcessManager = await ensureHelperProcess();
const helperExposedMeta = await helperProcessManager.rpc?.getMeta();
assert(helperExposedMeta, 'helperExposedMeta should be defined');
const mainExposedMeta = getExposedMeta();
const browserWindow = new BrowserWindow({
titleBarStyle: isMacOS()
? 'hiddenInset'
: isWindows()
? 'hidden'
: 'default',
trafficLightPosition: { x: 24, y: 18 },
x: mainWindowState.x,
y: mainWindowState.y,
width: mainWindowState.width,
minWidth: 640,
minHeight: 480,
visualEffectState: 'active',
vibrancy: 'under-window',
height: mainWindowState.height,
show: false, // Use 'ready-to-show' event to show window
webPreferences: {
webgl: true,
contextIsolation: true,
sandbox: false,
webviewTag: false, // The webview tag is not recommended. Consider alternatives like iframe or Electron's BrowserView. https://www.electronjs.org/docs/latest/api/webview-tag#warning
spellcheck: false, // FIXME: enable?
preload: join(__dirname, './preload.js'),
// serialize exposed meta that to be used in preload
additionalArguments: [
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
`--helper-exposed-meta=` + JSON.stringify(helperExposedMeta),
],
},
});
nativeTheme.themeSource = 'light';
mainWindowState.manage(browserWindow);
let helperConnectionUnsub: (() => void) | undefined;
/**
* If you install `show: true` then it can cause issues when trying to close the window.
* Use `show: false` and listener events `ready-to-show` to fix these issues.
*
* @see https://github.com/electron/electron/issues/25012
*/
browserWindow.on('ready-to-show', () => {
if (IS_DEV) {
// do not gain focus in dev mode
browserWindow.showInactive();
} else {
browserWindow.show();
}
helperConnectionUnsub = helperProcessManager.connectRenderer(
browserWindow.webContents
);
logger.info('main window is ready to show');
if (DEV_TOOL) {
browserWindow.webContents.openDevTools({
mode: 'detach',
});
}
});
browserWindow.on('close', e => {
e.preventDefault();
browserWindow.destroy();
helperConnectionUnsub?.();
// TODO: gracefully close the app, for example, ask user to save unsaved changes
});
/**
* URL for main window.
*/
const pageUrl = process.env.DEV_SERVER_URL || 'file://./index.html'; // see protocol.ts
logger.info('loading page at', pageUrl);
await browserWindow.loadURL(pageUrl);
logger.info('main window is loaded at', pageUrl);
return browserWindow;
}
// singleton
let browserWindow: Electron.BrowserWindow | undefined;
/**
* Restore existing BrowserWindow or Create new BrowserWindow
*/
export async function restoreOrCreateWindow() {
browserWindow = BrowserWindow.getAllWindows().find(w => !w.isDestroyed());
if (browserWindow === undefined) {
browserWindow = await createWindow();
}
if (browserWindow.isMinimized()) {
browserWindow.restore();
logger.info('restore main window');
}
return browserWindow;
}

View File

@@ -0,0 +1,57 @@
import { join, resolve } from 'node:path';
import { Worker } from 'node:worker_threads';
import { AsyncCall } from 'async-call-rpc';
import { ipcMain } from 'electron';
import { MessageEventChannel } from './utils';
declare global {
// fixme(himself65):
// remove this when bookmark block plugin is migrated to plugin-infra
// eslint-disable-next-line no-var
var asyncCall: Record<string, (...args: any) => PromiseLike<any>>;
}
export async function registerPlugin() {
const pluginWorkerPath = join(__dirname, './workers/plugin.worker.js');
const asyncCall = AsyncCall<
Record<string, (...args: any) => PromiseLike<any>>
>(
{},
{
channel: new MessageEventChannel(new Worker(pluginWorkerPath)),
}
);
globalThis.asyncCall = asyncCall;
await import('@toeverything/plugin-infra/manager').then(
({ rootStore, affinePluginsAtom }) => {
const bookmarkPluginPath = join(
process.env.PLUGIN_DIR ?? resolve(__dirname, './plugins'),
'./bookmark-block/index.mjs'
);
import('file://' + bookmarkPluginPath);
let dispose: () => void = () => {
// noop
};
rootStore.sub(affinePluginsAtom, () => {
dispose();
const plugins = rootStore.get(affinePluginsAtom);
Object.values(plugins).forEach(plugin => {
plugin.definition.commands.forEach(command => {
ipcMain.handle(command, (event, ...args) =>
asyncCall[command](...args)
);
});
});
dispose = () => {
Object.values(plugins).forEach(plugin => {
plugin.definition.commands.forEach(command => {
ipcMain.removeHandler(command);
});
});
};
});
}
);
}

View File

@@ -0,0 +1,67 @@
import { protocol, session } from 'electron';
import { join } from 'path';
protocol.registerSchemesAsPrivileged([
{
scheme: 'assets',
privileges: {
secure: false,
corsEnabled: true,
supportFetchAPI: true,
standard: true,
bypassCSP: true,
},
},
]);
function toAbsolutePath(url: string) {
let realpath = decodeURIComponent(url);
const webStaticDir = join(__dirname, '../resources/web-static');
if (url.startsWith('./')) {
// if is a file type, load the file in resources
if (url.split('/').at(-1)?.includes('.')) {
realpath = join(webStaticDir, decodeURIComponent(url));
} else {
// else, fallback to load the index.html instead
realpath = join(webStaticDir, 'index.html');
}
}
return realpath;
}
export function registerProtocol() {
protocol.interceptFileProtocol('file', (request, callback) => {
const url = request.url.replace(/^file:\/\//, '');
const realpath = toAbsolutePath(url);
callback(realpath);
console.log('interceptFileProtocol realpath', request.url, realpath);
return true;
});
protocol.registerFileProtocol('assets', (request, callback) => {
const url = request.url.replace(/^assets:\/\//, '');
const realpath = toAbsolutePath(url);
callback(realpath);
return true;
});
session.defaultSession.webRequest.onHeadersReceived(
(responseDetails, callback) => {
const { responseHeaders } = responseDetails;
if (responseHeaders) {
delete responseHeaders['access-control-allow-origin'];
delete responseHeaders['access-control-allow-methods'];
responseHeaders['Access-Control-Allow-Origin'] = ['*'];
responseHeaders['Access-Control-Allow-Methods'] = [
'GET',
'POST',
'PUT',
'DELETE',
'OPTIONS',
];
}
callback({ responseHeaders });
}
);
}

View File

@@ -0,0 +1,43 @@
import { app, shell } from 'electron';
app.on('web-contents-created', (_, contents) => {
/**
* Block navigation to origins not on the allowlist.
*
* Navigation is a common attack vector. If an attacker can convince the app to navigate away
* from its current page, they can possibly force the app to open web sites on the Internet.
*
* @see https://www.electronjs.org/docs/latest/tutorial/security#13-disable-or-limit-navigation
*/
contents.on('will-navigate', (event, url) => {
if (
(process.env.DEV_SERVER_URL &&
url.startsWith(process.env.DEV_SERVER_URL)) ||
url.startsWith('affine://') ||
url.startsWith('file://.')
) {
return;
}
// Prevent navigation
event.preventDefault();
shell.openExternal(url).catch(console.error);
});
/**
* Hyperlinks to allowed sites open in the default browser.
*
* The creation of new `webContents` is a common attack vector. Attackers attempt to convince the app to create new windows,
* frames, or other renderer processes with more privileges than they had before; or with pages opened that they couldn't open before.
* You should deny any unexpected window creation.
*
* @see https://www.electronjs.org/docs/latest/tutorial/security#14-disable-or-limit-creation-of-new-windows
* @see https://www.electronjs.org/docs/latest/tutorial/security#15-do-not-use-openexternal-with-untrusted-content
*/
contents.setWindowOpenHandler(({ url }) => {
// Open default browser
shell.openExternal(url).catch(console.error);
// Prevent creating new window in application
return { action: 'deny' };
});
});

View File

@@ -0,0 +1,10 @@
export type MainEventRegister = (...args: any[]) => () => void;
export type IsomorphicHandler = (
e: Electron.IpcMainInvokeEvent,
...args: any[]
) => Promise<any>;
export type NamespaceHandlers = {
[key: string]: IsomorphicHandler;
};

View File

@@ -0,0 +1,60 @@
import { app, BrowserWindow, shell } from 'electron';
import { parse } from 'url';
import { logger } from '../logger';
const redirectUri = 'https://affine.pro/client/auth-callback';
export const oauthEndpoint = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${process.env.AFFINE_GOOGLE_CLIENT_ID}&redirect_uri=${redirectUri}&response_type=code&scope=openid https://www.googleapis.com/auth/userinfo.email profile&access_type=offline&customParameters={"prompt":"select_account"}`;
const tokenEndpoint = 'https://oauth2.googleapis.com/token';
export const getExchangeTokenParams = (code: string) => {
const postData = {
code,
client_id: process.env.AFFINE_GOOGLE_CLIENT_ID || '',
client_secret: process.env.AFFINE_GOOGLE_CLIENT_SECRET || '',
redirect_uri: redirectUri,
grant_type: 'authorization_code',
};
const requestInit: RequestInit = {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(postData).toString(),
};
return { requestInit, url: tokenEndpoint };
};
export function getGoogleOauthCode() {
return new Promise<ReturnType<typeof getExchangeTokenParams>>(
(resolve, reject) => {
shell.openExternal(oauthEndpoint).catch(e => {
logger.error('Failed to open external url', e);
reject(e);
});
const handleOpenUrl = async (_: any, url: string) => {
const mainWindow = BrowserWindow.getAllWindows().find(
w => !w.isDestroyed()
);
const urlObj = parse(url.replace('??', '?'), true);
if (!mainWindow || !url.startsWith('affine://auth-callback')) return;
const code = urlObj.query['code'] as string;
if (!code) return;
logger.info('google sign in code received from callback', code);
app.removeListener('open-url', handleOpenUrl);
resolve(getExchangeTokenParams(code));
};
app.on('open-url', handleOpenUrl);
setTimeout(() => {
reject(new Error('Timed out'));
app.removeListener('open-url', handleOpenUrl);
}, 30000);
}
);
}

View File

@@ -0,0 +1,50 @@
import { app, BrowserWindow, nativeTheme } from 'electron';
import type { NamespaceHandlers } from '../type';
import { isMacOS } from '../utils';
import { getGoogleOauthCode } from './google-auth';
export const uiHandlers = {
handleThemeChange: async (_, theme: (typeof nativeTheme)['themeSource']) => {
nativeTheme.themeSource = theme;
},
handleSidebarVisibilityChange: async (_, visible: boolean) => {
if (isMacOS()) {
const windows = BrowserWindow.getAllWindows();
windows.forEach(w => {
// hide window buttons when sidebar is not visible
w.setWindowButtonVisibility(visible);
});
}
},
handleMinimizeApp: async () => {
const windows = BrowserWindow.getAllWindows();
windows.forEach(w => {
w.minimize();
});
},
handleMaximizeApp: async () => {
const windows = BrowserWindow.getAllWindows();
windows.forEach(w => {
if (w.isMaximized()) {
w.unmaximize();
} else {
w.maximize();
}
});
},
handleCloseApp: async () => {
app.quit();
},
getGoogleOauthCode: async () => {
return getGoogleOauthCode();
},
/**
* @deprecated Remove this when bookmark block plugin is migrated to plugin-infra
*/
getBookmarkDataByLink: async (_, link: string) => {
return globalThis.asyncCall[
'com.blocksuite.bookmark-block.get-bookmark-data-by-link'
](link);
},
} satisfies NamespaceHandlers;

View File

@@ -0,0 +1,103 @@
import { app } from 'electron';
import type { AppUpdater } from 'electron-updater';
import { z } from 'zod';
import { logger } from '../logger';
import { isMacOS } from '../utils';
import { updaterSubjects } from './event';
export const ReleaseTypeSchema = z.enum([
'stable',
'beta',
'canary',
'internal',
]);
export const envBuildType = (process.env.BUILD_TYPE || 'canary')
.trim()
.toLowerCase();
export const buildType = ReleaseTypeSchema.parse(envBuildType);
const mode = process.env.NODE_ENV;
const isDev = mode === 'development';
let _autoUpdater: AppUpdater | null = null;
export const quitAndInstall = async () => {
_autoUpdater?.quitAndInstall();
};
let lastCheckTime = 0;
export const checkForUpdatesAndNotify = async (force = true) => {
if (!_autoUpdater) {
return void 0;
}
// check every 30 minutes (1800 seconds) at most
if (force || lastCheckTime + 1000 * 1800 < Date.now()) {
lastCheckTime = Date.now();
return await _autoUpdater.checkForUpdatesAndNotify();
}
return void 0;
};
export const registerUpdater = async () => {
// so we wrap it in a function
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { autoUpdater } = require('electron-updater');
_autoUpdater = autoUpdater;
// skip auto update in dev mode
if (!_autoUpdater || isDev) {
return;
}
// TODO: support auto update on windows and linux
const allowAutoUpdate = isMacOS();
_autoUpdater.autoDownload = false;
_autoUpdater.allowPrerelease = buildType !== 'stable';
_autoUpdater.autoInstallOnAppQuit = false;
_autoUpdater.autoRunAppAfterInstall = true;
_autoUpdater.setFeedURL({
channel: buildType,
provider: 'github',
repo: buildType !== 'internal' ? 'AFFiNE' : 'AFFiNE-Releases',
owner: 'toeverything',
releaseType: buildType === 'stable' ? 'release' : 'prerelease',
});
// register events for checkForUpdatesAndNotify
_autoUpdater.on('update-available', info => {
if (allowAutoUpdate) {
_autoUpdater?.downloadUpdate().catch(e => {
logger.error('Failed to download update', e);
});
logger.info('Update available, downloading...', info);
}
updaterSubjects.updateAvailable.next({
version: info.version,
allowAutoUpdate,
});
});
_autoUpdater.on('download-progress', e => {
logger.info(`Download progress: ${e.percent}`);
updaterSubjects.downloadProgress.next(e.percent);
});
_autoUpdater.on('update-downloaded', e => {
updaterSubjects.updateReady.next({
version: e.version,
allowAutoUpdate,
});
// I guess we can skip it?
// updaterSubjects.clientDownloadProgress.next(100);
logger.info('Update downloaded, ready to install');
});
_autoUpdater.on('error', e => {
logger.error('Error while updating client', e);
});
_autoUpdater.forceDevUpdateConfig = isDev;
app.on('activate', async () => {
await checkForUpdatesAndNotify(false);
});
};

View File

@@ -0,0 +1,36 @@
import { BehaviorSubject, Subject } from 'rxjs';
import type { MainEventRegister } from '../type';
export interface UpdateMeta {
version: string;
allowAutoUpdate: boolean;
}
export const updaterSubjects = {
// means it is ready for restart and install the new version
updateAvailable: new Subject<UpdateMeta>(),
updateReady: new Subject<UpdateMeta>(),
downloadProgress: new BehaviorSubject<number>(0),
};
export const updaterEvents = {
onUpdateAvailable: (fn: (versionMeta: UpdateMeta) => void) => {
const sub = updaterSubjects.updateAvailable.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
onUpdateReady: (fn: (versionMeta: UpdateMeta) => void) => {
const sub = updaterSubjects.updateReady.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
onDownloadProgress: (fn: (progress: number) => void) => {
const sub = updaterSubjects.downloadProgress.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
} satisfies Record<string, MainEventRegister>;

View File

@@ -0,0 +1,18 @@
import { app } from 'electron';
import type { NamespaceHandlers } from '../type';
import { checkForUpdatesAndNotify, quitAndInstall } from './electron-updater';
export const updaterHandlers = {
currentVersion: async () => {
return app.getVersion();
},
quitAndInstall: async () => {
return quitAndInstall();
},
checkForUpdatesAndNotify: async () => {
return checkForUpdatesAndNotify(true);
},
} satisfies NamespaceHandlers;
export * from './electron-updater';

View File

@@ -0,0 +1,40 @@
import type { EventBasedChannel } from 'async-call-rpc';
export function getTime() {
return new Date().getTime();
}
export const isMacOS = () => {
return process.platform === 'darwin';
};
export const isWindows = () => {
return process.platform === 'win32';
};
interface MessagePortLike {
postMessage: (data: unknown) => void;
addListener: (event: 'message', listener: (...args: any[]) => void) => void;
removeListener: (
event: 'message',
listener: (...args: any[]) => void
) => void;
}
export class MessageEventChannel implements EventBasedChannel {
constructor(private worker: MessagePortLike) {}
on(listener: (data: unknown) => void) {
const f = (data: unknown) => {
listener(data);
};
this.worker.addListener('message', f);
return () => {
this.worker.removeListener('message', f);
};
}
send(data: unknown) {
this.worker.postMessage(data);
}
}

View File

@@ -0,0 +1,43 @@
import { join, resolve } from 'node:path';
import { parentPort } from 'node:worker_threads';
import { AsyncCall } from 'async-call-rpc';
import { MessageEventChannel } from '../utils';
const commandProxy: Record<string, (...args: any[]) => Promise<any>> = {};
if (!parentPort) {
throw new Error('parentPort is undefined');
}
AsyncCall(commandProxy, {
channel: new MessageEventChannel(parentPort),
});
import('@toeverything/plugin-infra/manager').then(
({ rootStore, affinePluginsAtom }) => {
const bookmarkPluginPath = join(
process.env.PLUGIN_DIR ?? resolve(__dirname, '../plugins'),
'./bookmark-block/index.mjs'
);
import('file://' + bookmarkPluginPath);
rootStore.sub(affinePluginsAtom, () => {
const plugins = rootStore.get(affinePluginsAtom);
Object.values(plugins).forEach(plugin => {
if (plugin.serverAdapter) {
plugin.serverAdapter({
registerCommand: (command, fn) => {
console.log('register command', command);
commandProxy[command] = fn;
},
unregisterCommand: command => {
delete commandProxy[command];
},
});
}
});
});
}
);