mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 13:25:12 +00:00
feat: improve electron sandbox (#14156)
This commit is contained in:
@@ -7,7 +7,7 @@ export const anotherOrigin = `assets://${anotherHost}`;
|
||||
export const onboardingViewUrl = `${mainWindowOrigin}/onboarding`;
|
||||
export const shellViewUrl = `${mainWindowOrigin}/shell.html`;
|
||||
export const backgroundWorkerViewUrl = `${mainWindowOrigin}/background-worker.html`;
|
||||
export const customThemeViewUrl = `${mainWindowOrigin}/theme-editor.html`;
|
||||
export const customThemeViewUrl = `${mainWindowOrigin}/theme-editor`;
|
||||
|
||||
// mitigate the issue that popup window share the same zoom level of the main window
|
||||
// Notes from electron official docs:
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { BrowserWindow, WebContentsView } from 'electron';
|
||||
import { ipcMain, webContents } from 'electron';
|
||||
|
||||
import { AFFINE_EVENT_CHANNEL_NAME } from '../shared/type';
|
||||
import {
|
||||
AFFINE_EVENT_CHANNEL_NAME,
|
||||
AFFINE_EVENT_SUBSCRIBE_CHANNEL_NAME,
|
||||
} from '../shared/type';
|
||||
import { applicationMenuEvents } from './application-menu';
|
||||
import { beforeAppQuit } from './cleanup';
|
||||
import { logger } from './logger';
|
||||
@@ -19,12 +22,64 @@ export const allEvents = {
|
||||
popup: popupEvents,
|
||||
};
|
||||
|
||||
function getActiveWindows() {
|
||||
return BrowserWindow.getAllWindows().filter(win => !win.isDestroyed());
|
||||
const subscriptions = new Map<number, Set<string>>();
|
||||
|
||||
function getTargetContents(channel: string) {
|
||||
const targets: Electron.WebContents[] = [];
|
||||
subscriptions.forEach((channels, id) => {
|
||||
if (!channels.has(channel)) return;
|
||||
const wc = webContents.fromId(id);
|
||||
if (wc && !wc.isDestroyed()) {
|
||||
targets.push(wc);
|
||||
}
|
||||
});
|
||||
return targets;
|
||||
}
|
||||
|
||||
function addSubscription(sender: Electron.WebContents, channel: string) {
|
||||
const id = sender.id;
|
||||
const set = subscriptions.get(id) ?? new Set<string>();
|
||||
set.add(channel);
|
||||
if (!subscriptions.has(id)) {
|
||||
sender.once('destroyed', () => {
|
||||
subscriptions.delete(id);
|
||||
});
|
||||
}
|
||||
subscriptions.set(id, set);
|
||||
}
|
||||
|
||||
function removeSubscription(sender: Electron.WebContents, channel: string) {
|
||||
const id = sender.id;
|
||||
const set = subscriptions.get(id);
|
||||
if (!set) return;
|
||||
set.delete(channel);
|
||||
if (set.size === 0) {
|
||||
subscriptions.delete(id);
|
||||
} else {
|
||||
subscriptions.set(id, set);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerEvents() {
|
||||
const unsubs: (() => void)[] = [];
|
||||
|
||||
const onSubscribe = (
|
||||
event: Electron.IpcMainEvent,
|
||||
action: 'subscribe' | 'unsubscribe',
|
||||
channel: string
|
||||
) => {
|
||||
if (typeof channel !== 'string') return;
|
||||
if (action === 'subscribe') {
|
||||
addSubscription(event.sender, channel);
|
||||
} else {
|
||||
removeSubscription(event.sender, channel);
|
||||
}
|
||||
};
|
||||
|
||||
ipcMain.on(AFFINE_EVENT_SUBSCRIBE_CHANNEL_NAME, onSubscribe);
|
||||
unsubs.push(() =>
|
||||
ipcMain.removeListener(AFFINE_EVENT_SUBSCRIBE_CHANNEL_NAME, onSubscribe)
|
||||
);
|
||||
// register events
|
||||
for (const [namespace, namespaceEvents] of Object.entries(allEvents)) {
|
||||
for (const [key, eventRegister] of Object.entries(namespaceEvents)) {
|
||||
@@ -40,22 +95,10 @@ export function registerEvents() {
|
||||
typeof a !== 'object'
|
||||
)
|
||||
);
|
||||
// is this efficient?
|
||||
getActiveWindows().forEach(win => {
|
||||
if (win.isDestroyed()) {
|
||||
return;
|
||||
getTargetContents(chan).forEach(wc => {
|
||||
if (!wc.isDestroyed()) {
|
||||
wc.send(AFFINE_EVENT_CHANNEL_NAME, chan, ...args);
|
||||
}
|
||||
// .webContents could be undefined if the window is destroyed
|
||||
win.webContents?.send(AFFINE_EVENT_CHANNEL_NAME, chan, ...args);
|
||||
win.contentView.children.forEach(child => {
|
||||
if (
|
||||
child instanceof WebContentsView &&
|
||||
child.webContents &&
|
||||
!child.webContents.isDestroyed()
|
||||
) {
|
||||
child.webContents?.send(AFFINE_EVENT_CHANNEL_NAME, chan, ...args);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
unsubs.push(unsubscribe);
|
||||
|
||||
@@ -76,6 +76,16 @@ class HelperProcessManager {
|
||||
beforeAppQuit(() => {
|
||||
this.#process.kill();
|
||||
});
|
||||
|
||||
this.#process.on('exit', code => {
|
||||
logger.error('[helper] process exited', { code });
|
||||
HelperProcessManager._instance = null;
|
||||
});
|
||||
|
||||
this.#process.on('error', err => {
|
||||
logger.error('[helper] process error', err);
|
||||
HelperProcessManager._instance = null;
|
||||
});
|
||||
}
|
||||
|
||||
// bridge renderer <-> helper process
|
||||
|
||||
@@ -5,6 +5,7 @@ import { app, net, protocol, session } from 'electron';
|
||||
import cookieParser from 'set-cookie-parser';
|
||||
|
||||
import { isWindows, resourcesPath } from '../shared/utils';
|
||||
import { isDev } from './config';
|
||||
import { anotherHost, mainHost } from './constants';
|
||||
import { logger } from './logger';
|
||||
|
||||
@@ -13,11 +14,9 @@ protocol.registerSchemesAsPrivileged([
|
||||
scheme: 'assets',
|
||||
privileges: {
|
||||
secure: true,
|
||||
allowServiceWorkers: true,
|
||||
corsEnabled: true,
|
||||
supportFetchAPI: true,
|
||||
standard: true,
|
||||
bypassCSP: true,
|
||||
stream: true,
|
||||
},
|
||||
},
|
||||
@@ -28,7 +27,6 @@ protocol.registerSchemesAsPrivileged([
|
||||
corsEnabled: true,
|
||||
supportFetchAPI: true,
|
||||
standard: true,
|
||||
bypassCSP: true,
|
||||
stream: true,
|
||||
},
|
||||
},
|
||||
@@ -59,8 +57,13 @@ async function handleFileRequest(request: Request) {
|
||||
urlObject.pathname &&
|
||||
/\.(woff2?|ttf|otf)$/i.test(urlObject.pathname.split('?')[0] ?? '');
|
||||
|
||||
// Redirect to webpack dev server if defined
|
||||
if (process.env.DEV_SERVER_URL && !isAbsolutePath && !isFontRequest) {
|
||||
// Redirect to webpack dev server if available
|
||||
if (
|
||||
isDev &&
|
||||
process.env.DEV_SERVER_URL &&
|
||||
!isAbsolutePath &&
|
||||
!isFontRequest
|
||||
) {
|
||||
const devServerUrl = new URL(
|
||||
`${urlObject.pathname}${urlObject.search}`,
|
||||
process.env.DEV_SERVER_URL
|
||||
@@ -103,19 +106,6 @@ async function handleFileRequest(request: Request) {
|
||||
return net.fetch(pathToFileURL(filepath).toString(), clonedRequest);
|
||||
}
|
||||
|
||||
// whitelist for cors
|
||||
// url patterns that are allowed to have cors headers
|
||||
const corsWhitelist = [
|
||||
/^(?:[a-zA-Z0-9-]+\.)*googlevideo\.com$/,
|
||||
/^(?:[a-zA-Z0-9-]+\.)*youtube\.com$/,
|
||||
/^(?:[a-zA-Z0-9-]+\.)*youtube-nocookie\.com$/,
|
||||
/^(?:[a-zA-Z0-9-]+\.)*gstatic\.com$/,
|
||||
/^(?:[a-zA-Z0-9-]+\.)*googleapis\.com$/,
|
||||
/^localhost(?::\d+)?$/,
|
||||
/^127\.0\.0\.1(?::\d+)?$/,
|
||||
/^insider\.affine\.pro$/,
|
||||
/^app\.affine\.pro$/,
|
||||
];
|
||||
const needRefererDomains = [
|
||||
/^(?:[a-zA-Z0-9-]+\.)*youtube\.com$/,
|
||||
/^(?:[a-zA-Z0-9-]+\.)*youtube-nocookie\.com$/,
|
||||
@@ -123,6 +113,44 @@ const needRefererDomains = [
|
||||
];
|
||||
const defaultReferer = 'https://client.affine.local/';
|
||||
|
||||
function setHeader(
|
||||
headers: Record<string, string[]>,
|
||||
name: string,
|
||||
value: string
|
||||
) {
|
||||
Object.keys(headers).forEach(key => {
|
||||
if (key.toLowerCase() === name.toLowerCase()) {
|
||||
delete headers[key];
|
||||
}
|
||||
});
|
||||
headers[name] = [value];
|
||||
}
|
||||
|
||||
function ensureFrameAncestors(
|
||||
headers: Record<string, string[]>,
|
||||
directive: string
|
||||
) {
|
||||
const cspHeaderKey = Object.keys(headers).find(
|
||||
key => key.toLowerCase() === 'content-security-policy'
|
||||
);
|
||||
if (!cspHeaderKey) {
|
||||
headers['Content-Security-Policy'] = [`frame-ancestors ${directive}`];
|
||||
return;
|
||||
}
|
||||
|
||||
const values = headers[cspHeaderKey];
|
||||
headers[cspHeaderKey] = values.map(val => {
|
||||
if (typeof val !== 'string') return val as any;
|
||||
const directives = val
|
||||
.split(';')
|
||||
.map(v => v.trim())
|
||||
.filter(Boolean)
|
||||
.filter(d => !d.toLowerCase().startsWith('frame-ancestors'));
|
||||
directives.push(`frame-ancestors ${directive}`);
|
||||
return directives.join('; ');
|
||||
});
|
||||
}
|
||||
|
||||
export function registerProtocol() {
|
||||
protocol.handle('file', request => {
|
||||
return handleFileRequest(request);
|
||||
@@ -172,79 +200,19 @@ export function registerProtocol() {
|
||||
}
|
||||
}
|
||||
|
||||
const hostname = new URL(url).hostname;
|
||||
if (!corsWhitelist.some(domainRegex => domainRegex.test(hostname))) {
|
||||
const { protocol } = new URL(url);
|
||||
|
||||
// Only adjust CORS for assets/file responses; leave remote http(s) headers intact
|
||||
if (protocol === 'assets:' || protocol === 'file:') {
|
||||
delete responseHeaders['access-control-allow-origin'];
|
||||
delete responseHeaders['access-control-allow-headers'];
|
||||
delete responseHeaders['Access-Control-Allow-Origin'];
|
||||
delete responseHeaders['Access-Control-Allow-Headers'];
|
||||
} else if (
|
||||
!needRefererDomains.some(domainRegex => domainRegex.test(hostname))
|
||||
) {
|
||||
if (
|
||||
!responseHeaders['access-control-allow-origin'] &&
|
||||
!responseHeaders['Access-Control-Allow-Origin']
|
||||
) {
|
||||
responseHeaders['Access-Control-Allow-Origin'] = ['*'];
|
||||
}
|
||||
if (
|
||||
!responseHeaders['access-control-allow-headers'] &&
|
||||
!responseHeaders['Access-Control-Allow-Headers']
|
||||
) {
|
||||
responseHeaders['Access-Control-Allow-Headers'] = [
|
||||
'Origin, X-Requested-With, Content-Type, Accept, Authorization',
|
||||
];
|
||||
}
|
||||
if (
|
||||
!responseHeaders['access-control-allow-methods'] &&
|
||||
!responseHeaders['Access-Control-Allow-Methods']
|
||||
) {
|
||||
responseHeaders['Access-Control-Allow-Methods'] = [
|
||||
'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// to allow url embedding, remove "x-frame-options",
|
||||
// if response header contains "content-security-policy", remove "frame-ancestors/frame-src"
|
||||
delete responseHeaders['x-frame-options'];
|
||||
delete responseHeaders['X-Frame-Options'];
|
||||
|
||||
// Handle Content Security Policy headers
|
||||
const cspHeaders = [
|
||||
'content-security-policy',
|
||||
'Content-Security-Policy',
|
||||
];
|
||||
for (const cspHeader of cspHeaders) {
|
||||
const cspValues = responseHeaders[cspHeader];
|
||||
if (cspValues) {
|
||||
// Remove frame-ancestors and frame-src directives from CSP
|
||||
const modifiedCspValues = cspValues
|
||||
.map(cspValue => {
|
||||
if (typeof cspValue === 'string') {
|
||||
return cspValue
|
||||
.split(';')
|
||||
.filter(directive => {
|
||||
const trimmed = directive.trim().toLowerCase();
|
||||
return (
|
||||
!trimmed.startsWith('frame-ancestors') &&
|
||||
!trimmed.startsWith('frame-src')
|
||||
);
|
||||
})
|
||||
.join(';');
|
||||
}
|
||||
return cspValue;
|
||||
})
|
||||
.filter(
|
||||
value => value && typeof value === 'string' && value.trim()
|
||||
);
|
||||
|
||||
if (modifiedCspValues.length > 0) {
|
||||
responseHeaders[cspHeader] = modifiedCspValues;
|
||||
} else {
|
||||
delete responseHeaders[cspHeader];
|
||||
}
|
||||
}
|
||||
if (protocol === 'assets:' || protocol === 'file:') {
|
||||
setHeader(responseHeaders, 'X-Frame-Options', 'SAMEORIGIN');
|
||||
ensureFrameAncestors(responseHeaders, "'self'");
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
19
packages/frontend/apps/electron/src/main/web-preferences.ts
Normal file
19
packages/frontend/apps/electron/src/main/web-preferences.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { WebPreferences } from 'electron';
|
||||
|
||||
const DEFAULT_WEB_PREFERENCES: Pick<
|
||||
WebPreferences,
|
||||
'contextIsolation' | 'nodeIntegration' | 'sandbox'
|
||||
> = {
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
};
|
||||
|
||||
export function buildWebPreferences(
|
||||
overrides: Partial<WebPreferences> = {}
|
||||
): WebPreferences {
|
||||
return {
|
||||
...DEFAULT_WEB_PREFERENCES,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { BrowserWindow, type Display, screen } from 'electron';
|
||||
import { isMacOS } from '../../shared/utils';
|
||||
import { customThemeViewUrl } from '../constants';
|
||||
import { logger } from '../logger';
|
||||
import { buildWebPreferences } from '../web-preferences';
|
||||
|
||||
let customThemeWindow: Promise<BrowserWindow> | undefined;
|
||||
|
||||
@@ -26,11 +27,11 @@ async function createCustomThemeWindow(additionalArguments: string[]) {
|
||||
resizable: true,
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
webPreferences: {
|
||||
webPreferences: buildWebPreferences({
|
||||
webgl: true,
|
||||
preload: join(__dirname, './preload.js'),
|
||||
additionalArguments: additionalArguments,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await browserWindow.loadURL(customThemeViewUrl);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { logger } from '../logger';
|
||||
import { MenubarStateKey, MenubarStateSchema } from '../shared-state-schema';
|
||||
import { globalStateStorage } from '../shared-storage/storage';
|
||||
import { uiSubjects } from '../ui/subject';
|
||||
import { buildWebPreferences } from '../web-preferences';
|
||||
|
||||
const IS_DEV: boolean =
|
||||
process.env.NODE_ENV === 'development' && !process.env.CI;
|
||||
@@ -56,6 +57,7 @@ export class MainWindowManager {
|
||||
show: false,
|
||||
width: 100,
|
||||
height: 100,
|
||||
webPreferences: buildWebPreferences(),
|
||||
});
|
||||
this.hiddenMacWindow.on('close', () => {
|
||||
this.cleanupWindows();
|
||||
@@ -95,11 +97,9 @@ export class MainWindowManager {
|
||||
// backgroundMaterial: 'mica',
|
||||
height: mainWindowState.height,
|
||||
show: false, // Use 'ready-to-show' event to show window
|
||||
webPreferences: {
|
||||
webPreferences: buildWebPreferences({
|
||||
webgl: true,
|
||||
contextIsolation: true,
|
||||
sandbox: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const helper = await ensureHelperProcess();
|
||||
helper.connectMain(browserWindow);
|
||||
@@ -283,10 +283,10 @@ export async function openUrlInHiddenWindow(urlObj: URL) {
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 600,
|
||||
webPreferences: {
|
||||
webPreferences: buildWebPreferences({
|
||||
preload: join(__dirname, './preload.js'),
|
||||
additionalArguments: await getWindowAdditionalArguments(),
|
||||
},
|
||||
}),
|
||||
show: BUILD_CONFIG.debug,
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { isDev } from '../config';
|
||||
import { onboardingViewUrl } from '../constants';
|
||||
// import { getExposedMeta } from './exposed';
|
||||
import { logger } from '../logger';
|
||||
import { buildWebPreferences } from '../web-preferences';
|
||||
import { fullscreenAndCenter, getScreenSize } from './utils';
|
||||
|
||||
// todo: not all window need all of the exposed meta
|
||||
@@ -40,11 +41,11 @@ async function createOnboardingWindow(additionalArguments: string[]) {
|
||||
transparent: true,
|
||||
hasShadow: false,
|
||||
roundedCorners: false,
|
||||
webPreferences: {
|
||||
webPreferences: buildWebPreferences({
|
||||
webgl: true,
|
||||
preload: join(__dirname, './preload.js'),
|
||||
additionalArguments: additionalArguments,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// workaround for the phantom title bar on windows when losing focus
|
||||
|
||||
@@ -11,6 +11,7 @@ import { BehaviorSubject } from 'rxjs';
|
||||
import { popupViewUrl } from '../constants';
|
||||
import { logger } from '../logger';
|
||||
import type { MainEventRegister, NamespaceHandlers } from '../type';
|
||||
import { buildWebPreferences } from '../web-preferences';
|
||||
import { getCurrentDisplay } from './utils';
|
||||
|
||||
type PopupWindowType = 'notification' | 'recording';
|
||||
@@ -85,17 +86,15 @@ abstract class PopupWindow {
|
||||
visualEffectState: 'active',
|
||||
vibrancy: 'under-window',
|
||||
...this.windowOptions,
|
||||
webPreferences: {
|
||||
...this.windowOptions.webPreferences,
|
||||
webPreferences: buildWebPreferences({
|
||||
webgl: true,
|
||||
contextIsolation: true,
|
||||
sandbox: false,
|
||||
transparent: true,
|
||||
spellcheck: false,
|
||||
preload: join(__dirname, './preload.js'), // this points to the bundled preload module
|
||||
...this.windowOptions.webPreferences,
|
||||
// serialize exposed meta that to be used in preload
|
||||
additionalArguments: await getAdditionalArguments(this.name),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// it seems that the dock will disappear when popup windows are shown
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
type WorkbenchViewMeta,
|
||||
} from '../shared-state-schema';
|
||||
import { globalStateStorage } from '../shared-storage/storage';
|
||||
import { buildWebPreferences } from '../web-preferences';
|
||||
import { getMainWindow, MainWindowManager } from './main-window';
|
||||
|
||||
async function getAdditionalArguments() {
|
||||
@@ -511,6 +512,7 @@ export class WebContentViewsManager {
|
||||
if (view) {
|
||||
this.resizeView(view);
|
||||
}
|
||||
this.updateBackgroundThrottling();
|
||||
return view;
|
||||
};
|
||||
|
||||
@@ -759,6 +761,10 @@ export class WebContentViewsManager {
|
||||
|
||||
this.mainWindow?.on('focus', () => {
|
||||
focusActiveView();
|
||||
this.updateBackgroundThrottling();
|
||||
});
|
||||
this.mainWindow?.on('blur', () => {
|
||||
this.updateBackgroundThrottling();
|
||||
});
|
||||
|
||||
combineLatest([
|
||||
@@ -768,6 +774,7 @@ export class WebContentViewsManager {
|
||||
// makes sure the active view is always focused
|
||||
if (window?.isFocused()) {
|
||||
focusActiveView();
|
||||
this.updateBackgroundThrottling();
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -810,17 +817,15 @@ export class WebContentViewsManager {
|
||||
additionalArguments.push(`--view-id=${viewId}`);
|
||||
|
||||
const view = new WebContentsView({
|
||||
webPreferences: {
|
||||
webPreferences: buildWebPreferences({
|
||||
webgl: true,
|
||||
transparent: true,
|
||||
contextIsolation: true,
|
||||
sandbox: false,
|
||||
spellcheck: spellCheckSettings.enabled,
|
||||
preload: join(__dirname, './preload.js'), // this points to the bundled preload module
|
||||
// serialize exposed meta that to be used in preload
|
||||
additionalArguments: additionalArguments,
|
||||
backgroundThrottling: false,
|
||||
},
|
||||
backgroundThrottling: true,
|
||||
}),
|
||||
});
|
||||
|
||||
view.webContents.on('context-menu', (_event, params) => {
|
||||
@@ -872,13 +877,16 @@ export class WebContentViewsManager {
|
||||
});
|
||||
|
||||
this.webViewsMap$.next(this.tabViewsMap.set(viewId, view));
|
||||
let unsub = () => {};
|
||||
let disconnectHelperProcess: (() => void) | null = null;
|
||||
|
||||
// shell process do not need to connect to helper process
|
||||
if (type !== 'shell') {
|
||||
view.webContents.on('did-finish-load', () => {
|
||||
unsub();
|
||||
unsub = helperProcessManager.connectRenderer(view.webContents);
|
||||
disconnectHelperProcess?.();
|
||||
disconnectHelperProcess = helperProcessManager.connectRenderer(
|
||||
view.webContents
|
||||
);
|
||||
this.updateBackgroundThrottling();
|
||||
});
|
||||
} else {
|
||||
view.webContents.on('focus', () => {
|
||||
@@ -892,6 +900,8 @@ export class WebContentViewsManager {
|
||||
}
|
||||
|
||||
view.webContents.on('destroyed', () => {
|
||||
disconnectHelperProcess?.();
|
||||
disconnectHelperProcess = null;
|
||||
this.webViewsMap$.next(
|
||||
new Map(
|
||||
[...this.tabViewsMap.entries()].filter(([key]) => key !== viewId)
|
||||
@@ -902,6 +912,7 @@ export class WebContentViewsManager {
|
||||
if (this.tabViewsMap.size === 0) {
|
||||
app.quit();
|
||||
}
|
||||
this.updateBackgroundThrottling();
|
||||
});
|
||||
|
||||
this.resizeView(view);
|
||||
@@ -920,6 +931,30 @@ export class WebContentViewsManager {
|
||||
return view;
|
||||
};
|
||||
|
||||
private readonly updateBackgroundThrottling = () => {
|
||||
const mainFocused = this.mainWindow?.isFocused() ?? false;
|
||||
const activeId = this.activeWorkbenchId;
|
||||
this.webViewsMap$.value.forEach((view, id) => {
|
||||
if (id === 'shell') {
|
||||
return;
|
||||
}
|
||||
const shouldThrottle = !mainFocused || id !== activeId;
|
||||
try {
|
||||
view.webContents.setBackgroundThrottling(shouldThrottle);
|
||||
} catch (err) {
|
||||
logger.warn('failed to set backgroundThrottling', err);
|
||||
}
|
||||
});
|
||||
if (this.shellView) {
|
||||
const shellThrottle = !mainFocused;
|
||||
try {
|
||||
this.shellView.webContents.setBackgroundThrottling(shellThrottle);
|
||||
} catch (err) {
|
||||
logger.warn('failed to set shell backgroundThrottling', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private async skipOnboarding(view: WebContentsView) {
|
||||
await view.webContents.executeJavaScript(`
|
||||
window.localStorage.setItem('app_config', '{"onBoarding":false}');
|
||||
|
||||
@@ -5,6 +5,7 @@ import { BrowserWindow, MessageChannelMain, type WebContents } from 'electron';
|
||||
import { backgroundWorkerViewUrl } from '../constants';
|
||||
import { ensureHelperProcess } from '../helper-process';
|
||||
import { logger } from '../logger';
|
||||
import { buildWebPreferences } from '../web-preferences';
|
||||
|
||||
async function getAdditionalArguments() {
|
||||
const { getExposedMeta } = await import('../exposed');
|
||||
@@ -41,10 +42,10 @@ export class WorkerManager {
|
||||
const worker = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 600,
|
||||
webPreferences: {
|
||||
webPreferences: buildWebPreferences({
|
||||
preload: join(__dirname, './preload.js'),
|
||||
additionalArguments: additionalArguments,
|
||||
},
|
||||
}),
|
||||
show: false,
|
||||
});
|
||||
|
||||
@@ -56,9 +57,32 @@ export class WorkerManager {
|
||||
};
|
||||
|
||||
let disconnectHelperProcess: (() => void) | null = null;
|
||||
worker.on('closed', () => {
|
||||
let cleanedUp = false;
|
||||
const cleanup = () => {
|
||||
if (cleanedUp) {
|
||||
return;
|
||||
}
|
||||
cleanedUp = true;
|
||||
this.workers.delete(key);
|
||||
disconnectHelperProcess?.();
|
||||
disconnectHelperProcess = null;
|
||||
};
|
||||
const handleWorkerFailure = (reason: string) => {
|
||||
logger.error('[worker] renderer process gone', { key, reason });
|
||||
record.loaded.reject(new Error(`worker ${key} failed: ${reason}`));
|
||||
cleanup();
|
||||
try {
|
||||
worker.destroy();
|
||||
} catch (err) {
|
||||
logger.warn('failed to destroy worker window', err);
|
||||
}
|
||||
};
|
||||
worker.on('closed', cleanup);
|
||||
worker.webContents.on('render-process-gone', (_event, details) => {
|
||||
handleWorkerFailure(details.reason ?? 'unknown');
|
||||
});
|
||||
worker.webContents.on('unresponsive', () => {
|
||||
handleWorkerFailure('unresponsive');
|
||||
});
|
||||
worker.loadURL(backgroundWorkerViewUrl).catch(e => {
|
||||
logger.error('failed to load url', e);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { z } from 'zod';
|
||||
import {
|
||||
AFFINE_API_CHANNEL_NAME,
|
||||
AFFINE_EVENT_CHANNEL_NAME,
|
||||
AFFINE_EVENT_SUBSCRIBE_CHANNEL_NAME,
|
||||
type ExposedMeta,
|
||||
type HelperToRenderer,
|
||||
type RendererToHelper,
|
||||
@@ -83,6 +84,33 @@ function getMainAPIs() {
|
||||
|
||||
// channel -> callback[]
|
||||
const listenersMap = new Map<string, ((...args: any[]) => void)[]>();
|
||||
const subscribeCounts = new Map<string, number>();
|
||||
|
||||
const subscribe = (channel: string) => {
|
||||
const count = (subscribeCounts.get(channel) ?? 0) + 1;
|
||||
subscribeCounts.set(channel, count);
|
||||
if (count === 1) {
|
||||
ipcRenderer.send(
|
||||
AFFINE_EVENT_SUBSCRIBE_CHANNEL_NAME,
|
||||
'subscribe',
|
||||
channel
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = (channel: string) => {
|
||||
const count = (subscribeCounts.get(channel) ?? 0) - 1;
|
||||
if (count <= 0) {
|
||||
subscribeCounts.delete(channel);
|
||||
ipcRenderer.send(
|
||||
AFFINE_EVENT_SUBSCRIBE_CHANNEL_NAME,
|
||||
'unsubscribe',
|
||||
channel
|
||||
);
|
||||
} else {
|
||||
subscribeCounts.set(channel, count);
|
||||
}
|
||||
};
|
||||
|
||||
ipcRenderer.on(AFFINE_EVENT_CHANNEL_NAME, (_event, channel, ...args) => {
|
||||
if (typeof channel !== 'string') {
|
||||
@@ -108,12 +136,20 @@ function getMainAPIs() {
|
||||
...(listenersMap.get(channel) ?? []),
|
||||
callback,
|
||||
]);
|
||||
subscribe(channel);
|
||||
|
||||
return () => {
|
||||
const listeners = listenersMap.get(channel) ?? [];
|
||||
const index = listeners.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
listeners.splice(index, 1);
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
listeners.splice(index, 1);
|
||||
unsubscribe(channel);
|
||||
if (listeners.length === 0) {
|
||||
listenersMap.delete(channel);
|
||||
} else {
|
||||
listenersMap.set(channel, listeners);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
@@ -6,16 +6,6 @@ import {
|
||||
AFFINE_EVENT_CHANNEL_NAME,
|
||||
} from '../shared/type';
|
||||
|
||||
// Load persisted data from main process synchronously at preload time
|
||||
const initialGlobalState = ipcRenderer.sendSync(
|
||||
AFFINE_API_CHANNEL_NAME,
|
||||
'sharedStorage:getAllGlobalState'
|
||||
);
|
||||
const initialGlobalCache = ipcRenderer.sendSync(
|
||||
AFFINE_API_CHANNEL_NAME,
|
||||
'sharedStorage:getAllGlobalCache'
|
||||
);
|
||||
|
||||
// Unique id for this renderer instance, used to ignore self-originated broadcasts
|
||||
const CLIENT_ID: string = Math.random().toString(36).slice(2);
|
||||
|
||||
@@ -35,42 +25,97 @@ function createSharedStorageApi(
|
||||
}
|
||||
) {
|
||||
const memory = new MemoryMemento();
|
||||
memory.setAll(init);
|
||||
ipcRenderer.on(AFFINE_EVENT_CHANNEL_NAME, (_event, channel, updates) => {
|
||||
if (channel === `sharedStorage:${event}`) {
|
||||
for (const [key, raw] of Object.entries(updates)) {
|
||||
// support both legacy plain value and new { v, r, s } structure
|
||||
let value: any;
|
||||
let source: string | undefined;
|
||||
const revisions = new Map<string, number>();
|
||||
const updateQueue: Record<string, any>[] = [];
|
||||
let loaded = false;
|
||||
|
||||
if (raw && typeof raw === 'object' && 'v' in raw) {
|
||||
value = (raw as any).v;
|
||||
source = (raw as any).s;
|
||||
} else {
|
||||
value = raw;
|
||||
}
|
||||
const applyUpdates = (updates: Record<string, any>) => {
|
||||
for (const [key, raw] of Object.entries(updates)) {
|
||||
// '*' means "reset everything" coming from a clear operation
|
||||
if (key === '*') {
|
||||
memory.clear();
|
||||
revisions.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ignore our own broadcasts
|
||||
if (source && source === CLIENT_ID) {
|
||||
// support both legacy plain value and new { v, r, s } structure
|
||||
let value: any;
|
||||
let source: string | undefined;
|
||||
let rev: number | undefined;
|
||||
|
||||
if (raw && typeof raw === 'object' && 'v' in raw) {
|
||||
value = raw.v;
|
||||
source = raw.s;
|
||||
rev = typeof raw.r === 'number' ? raw.r : undefined;
|
||||
} else {
|
||||
value = raw;
|
||||
}
|
||||
|
||||
// Ignore our own broadcasts
|
||||
if (source && source === CLIENT_ID) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rev !== undefined) {
|
||||
const current = revisions.get(key) ?? -1;
|
||||
if (rev <= current) {
|
||||
continue;
|
||||
}
|
||||
revisions.set(key, rev);
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
memory.del(key);
|
||||
} else {
|
||||
memory.set(key, value);
|
||||
}
|
||||
if (value === undefined) {
|
||||
memory.del(key);
|
||||
} else {
|
||||
memory.set(key, value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ipcRenderer.on(AFFINE_EVENT_CHANNEL_NAME, (_event, channel, updates) => {
|
||||
if (channel === `sharedStorage:${event}`) {
|
||||
if (loaded) {
|
||||
applyUpdates(updates);
|
||||
} else {
|
||||
updateQueue.push(updates);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const initPromise = (async () => {
|
||||
try {
|
||||
memory.setAll(init);
|
||||
const latest = await ipcRenderer.invoke(
|
||||
AFFINE_API_CHANNEL_NAME,
|
||||
event === 'onGlobalStateChanged'
|
||||
? 'sharedStorage:getAllGlobalState'
|
||||
: 'sharedStorage:getAllGlobalCache'
|
||||
);
|
||||
if (latest && typeof latest === 'object') {
|
||||
memory.setAll(latest);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load initial shared storage', err);
|
||||
} finally {
|
||||
loaded = true;
|
||||
while (updateQueue.length) {
|
||||
const updates = updateQueue.shift();
|
||||
if (updates) {
|
||||
applyUpdates(updates);
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
ready: initPromise,
|
||||
del(key: string) {
|
||||
memory.del(key);
|
||||
invokeWithCatch(`sharedStorage:${api.del}`, key, CLIENT_ID);
|
||||
},
|
||||
clear() {
|
||||
memory.clear();
|
||||
revisions.clear();
|
||||
invokeWithCatch(`sharedStorage:${api.clear}`, CLIENT_ID);
|
||||
},
|
||||
get<T>(key: string): T | undefined {
|
||||
@@ -90,25 +135,17 @@ function createSharedStorageApi(
|
||||
};
|
||||
}
|
||||
|
||||
export const globalState = createSharedStorageApi(
|
||||
initialGlobalState,
|
||||
'onGlobalStateChanged',
|
||||
{
|
||||
clear: 'clearGlobalState',
|
||||
del: 'delGlobalState',
|
||||
set: 'setGlobalState',
|
||||
}
|
||||
);
|
||||
export const globalState = createSharedStorageApi({}, 'onGlobalStateChanged', {
|
||||
clear: 'clearGlobalState',
|
||||
del: 'delGlobalState',
|
||||
set: 'setGlobalState',
|
||||
});
|
||||
|
||||
export const globalCache = createSharedStorageApi(
|
||||
initialGlobalCache,
|
||||
'onGlobalCacheChanged',
|
||||
{
|
||||
clear: 'clearGlobalCache',
|
||||
del: 'delGlobalCache',
|
||||
set: 'setGlobalCache',
|
||||
}
|
||||
);
|
||||
export const globalCache = createSharedStorageApi({}, 'onGlobalCacheChanged', {
|
||||
clear: 'clearGlobalCache',
|
||||
del: 'delGlobalCache',
|
||||
set: 'setGlobalCache',
|
||||
});
|
||||
|
||||
export const sharedStorage = {
|
||||
globalState,
|
||||
|
||||
@@ -30,3 +30,4 @@ export type MainToHelper = Pick<
|
||||
|
||||
export const AFFINE_API_CHANNEL_NAME = 'affine-ipc-api';
|
||||
export const AFFINE_EVENT_CHANNEL_NAME = 'affine-ipc-event';
|
||||
export const AFFINE_EVENT_SUBSCRIBE_CHANNEL_NAME = 'affine-ipc-event-subscribe';
|
||||
|
||||
Reference in New Issue
Block a user