feat: improve electron sandbox (#14156)

This commit is contained in:
DarkSky
2025-12-27 03:23:28 +08:00
committed by GitHub
parent 3fe8923fc3
commit 4eed92cebf
32 changed files with 570 additions and 213 deletions

View File

@@ -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:

View File

@@ -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);

View File

@@ -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

View File

@@ -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'");
}
}
})()

View 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,
};
}

View File

@@ -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);

View File

@@ -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,
});

View File

@@ -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

View File

@@ -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

View File

@@ -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}');

View File

@@ -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);

View File

@@ -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);
}
};
},

View File

@@ -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,

View File

@@ -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';