From 4eed92cebfeb39215adbf42cf050a9c5db30ba50 Mon Sep 17 00:00:00 2001
From: DarkSky <25152247+darkskygit@users.noreply.github.com>
Date: Sat, 27 Dec 2025 03:23:28 +0800
Subject: [PATCH] feat: improve electron sandbox (#14156)
---
.../src/__tests__/adapters/html.unit.spec.ts | 2 +-
.../embed-figma-block/embed-figma-block.ts | 3 +-
.../configs/providers/bilibili.ts | 79 ++++++++++
.../configs/providers/generic.ts | 3 +-
.../configs/providers/index.ts | 2 +
.../embed-iframe-block/embed-iframe-block.ts | 37 ++++-
.../embed/src/embed-iframe-block/style.ts | 13 ++
.../src/embed-loom-block/embed-loom-block.ts | 3 +-
.../embed-youtube-block.ts | 4 +-
.../embed-iframe/embed-iframe-config.ts | 1 +
.../embed-iframe/embed-iframe-service.ts | 1 +
packages/backend/server/package.json | 2 +-
packages/common/debug/src/index.ts | 20 ++-
packages/common/error/src/index.ts | 3 +-
.../apps/electron/src/main/constants.ts | 2 +-
.../frontend/apps/electron/src/main/events.ts | 81 +++++++---
.../apps/electron/src/main/helper-process.ts | 10 ++
.../apps/electron/src/main/protocol.ts | 138 +++++++-----------
.../apps/electron/src/main/web-preferences.ts | 19 +++
.../windows-manager/custom-theme-window.ts | 5 +-
.../src/main/windows-manager/main-window.ts | 12 +-
.../src/main/windows-manager/onboarding.ts | 5 +-
.../src/main/windows-manager/popup.ts | 9 +-
.../src/main/windows-manager/tab-views.ts | 51 ++++++-
.../apps/electron/src/main/worker/pool.ts | 30 +++-
.../apps/electron/src/preload/electron-api.ts | 40 ++++-
.../electron/src/preload/shared-storage.ts | 133 +++++++++++------
.../frontend/apps/electron/src/shared/type.ts | 1 +
.../src/blocksuite/ai/messages/wrapper.ts | 1 -
.../blocksuite/ai/provider/copilot-client.ts | 42 +++++-
.../core/src/modules/cloud/services/fetch.ts | 21 ++-
yarn.lock | 10 +-
32 files changed, 570 insertions(+), 213 deletions(-)
create mode 100644 blocksuite/affine/blocks/embed/src/embed-iframe-block/configs/providers/bilibili.ts
create mode 100644 packages/frontend/apps/electron/src/main/web-preferences.ts
diff --git a/blocksuite/affine/all/src/__tests__/adapters/html.unit.spec.ts b/blocksuite/affine/all/src/__tests__/adapters/html.unit.spec.ts
index 1fb0338f9..382ff1957 100644
--- a/blocksuite/affine/all/src/__tests__/adapters/html.unit.spec.ts
+++ b/blocksuite/affine/all/src/__tests__/adapters/html.unit.spec.ts
@@ -2214,7 +2214,7 @@ describe('html to snapshot', () => {
test('iframe', async () => {
const html = template(
- ``
+ ``
);
const blockSnapshot: BlockSnapshot = {
diff --git a/blocksuite/affine/blocks/embed/src/embed-figma-block/embed-figma-block.ts b/blocksuite/affine/blocks/embed/src/embed-figma-block/embed-figma-block.ts
index 75df5e5ac..590733cbf 100644
--- a/blocksuite/affine/blocks/embed/src/embed-figma-block/embed-figma-block.ts
+++ b/blocksuite/affine/blocks/embed/src/embed-figma-block/embed-figma-block.ts
@@ -82,7 +82,8 @@ export class EmbedFigmaBlockComponent extends EmbedBlockComponent
diff --git a/blocksuite/affine/blocks/embed/src/embed-iframe-block/configs/providers/bilibili.ts b/blocksuite/affine/blocks/embed/src/embed-iframe-block/configs/providers/bilibili.ts
new file mode 100644
index 000000000..aeee9e80c
--- /dev/null
+++ b/blocksuite/affine/blocks/embed/src/embed-iframe-block/configs/providers/bilibili.ts
@@ -0,0 +1,79 @@
+import { EmbedIframeConfigExtension } from '@blocksuite/affine-shared/services';
+
+import {
+ type EmbedIframeUrlValidationOptions,
+ validateEmbedIframeUrl,
+} from '../../utils';
+
+const BILIBILI_DEFAULT_WIDTH_IN_SURFACE = 800;
+const BILIBILI_DEFAULT_HEIGHT_IN_SURFACE = 450;
+const BILIBILI_DEFAULT_HEIGHT_IN_NOTE = 450;
+const BILIBILI_DEFAULT_WIDTH_PERCENT = 100;
+
+const bilibiliValidationOptions: EmbedIframeUrlValidationOptions = {
+ protocols: ['https:'],
+ hostnames: ['player.bilibili.com', 'www.bilibili.com', 'bilibili.com'],
+};
+
+const biliPlayerValidationOptions: EmbedIframeUrlValidationOptions = {
+ protocols: ['https:'],
+ hostnames: ['player.bilibili.com'],
+};
+
+const AV_REGEX = /av([0-9]+)/i;
+const BV_REGEX = /(BV[0-9A-Za-z]{10})/;
+
+const extractAvid = (url: string) => {
+ const match = url.match(AV_REGEX);
+ return match ? match[1] : undefined;
+};
+
+const extractBvid = (url: string) => {
+ const match = url.match(BV_REGEX);
+ return match ? match[1] : undefined;
+};
+
+const buildBiliPlayerEmbedUrl = (url: string) => {
+ // If the user pasted the embed URL directly, keep it
+ if (validateEmbedIframeUrl(url, biliPlayerValidationOptions)) {
+ return url;
+ }
+ const avid = extractAvid(url);
+ if (avid) {
+ const params = new URLSearchParams({
+ aid: avid,
+ autoplay: '0',
+ });
+ return `https://player.bilibili.com/player.html?${params.toString()}`;
+ }
+ const bvid = extractBvid(url);
+ if (bvid) {
+ const params = new URLSearchParams({
+ bvid,
+ autoplay: '0',
+ });
+ return `https://player.bilibili.com/player.html?${params.toString()}`;
+ }
+ return undefined;
+};
+
+const bilibiliConfig = {
+ name: 'bilibili',
+ match: (url: string) =>
+ validateEmbedIframeUrl(url, bilibiliValidationOptions) &&
+ (!!extractAvid(url) || !!extractBvid(url)),
+ buildOEmbedUrl: buildBiliPlayerEmbedUrl,
+ useOEmbedUrlDirectly: true,
+ options: {
+ widthInSurface: BILIBILI_DEFAULT_WIDTH_IN_SURFACE,
+ heightInSurface: BILIBILI_DEFAULT_HEIGHT_IN_SURFACE,
+ heightInNote: BILIBILI_DEFAULT_HEIGHT_IN_NOTE,
+ widthPercent: BILIBILI_DEFAULT_WIDTH_PERCENT,
+ allow: 'clipboard-write; encrypted-media; picture-in-picture',
+ sandbox: 'allow-same-origin allow-scripts',
+ style: 'border: none; border-radius: 8px;',
+ allowFullscreen: true,
+ },
+};
+
+export const BilibiliEmbedConfig = EmbedIframeConfigExtension(bilibiliConfig);
diff --git a/blocksuite/affine/blocks/embed/src/embed-iframe-block/configs/providers/generic.ts b/blocksuite/affine/blocks/embed/src/embed-iframe-block/configs/providers/generic.ts
index 6d96eff46..ae1fe021a 100644
--- a/blocksuite/affine/blocks/embed/src/embed-iframe-block/configs/providers/generic.ts
+++ b/blocksuite/affine/blocks/embed/src/embed-iframe-block/configs/providers/generic.ts
@@ -67,8 +67,9 @@ const genericConfig = {
heightInNote: GENERIC_DEFAULT_HEIGHT_IN_NOTE,
allowFullscreen: true,
style: 'border: none; border-radius: 8px;',
- allow: 'clipboard-read; clipboard-write; picture-in-picture;',
+ allow: '',
referrerpolicy: 'no-referrer-when-downgrade',
+ sandbox: 'allow-scripts',
},
};
diff --git a/blocksuite/affine/blocks/embed/src/embed-iframe-block/configs/providers/index.ts b/blocksuite/affine/blocks/embed/src/embed-iframe-block/configs/providers/index.ts
index 343e90c32..94afa63ce 100644
--- a/blocksuite/affine/blocks/embed/src/embed-iframe-block/configs/providers/index.ts
+++ b/blocksuite/affine/blocks/embed/src/embed-iframe-block/configs/providers/index.ts
@@ -1,3 +1,4 @@
+import { BilibiliEmbedConfig } from './bilibili';
import { ExcalidrawEmbedConfig } from './excalidraw';
import { GenericEmbedConfig } from './generic';
import { GoogleDocsEmbedConfig } from './google-docs';
@@ -11,5 +12,6 @@ export const EmbedIframeConfigExtensions = [
MiroEmbedConfig,
ExcalidrawEmbedConfig,
GoogleDocsEmbedConfig,
+ BilibiliEmbedConfig,
GenericEmbedConfig,
];
diff --git a/blocksuite/affine/blocks/embed/src/embed-iframe-block/embed-iframe-block.ts b/blocksuite/affine/blocks/embed/src/embed-iframe-block/embed-iframe-block.ts
index 326e5f811..826733ca9 100644
--- a/blocksuite/affine/blocks/embed/src/embed-iframe-block/embed-iframe-block.ts
+++ b/blocksuite/affine/blocks/embed/src/embed-iframe-block/embed-iframe-block.ts
@@ -23,7 +23,7 @@ import {
type ReadonlySignal,
signal,
} from '@preact/signals-core';
-import { html } from 'lit';
+import { html, nothing } from 'lit';
import { query } from 'lit/decorators.js';
import { type ClassInfo, classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
@@ -45,6 +45,10 @@ import { safeGetIframeSrc } from './utils.js';
export type EmbedIframeStatus = 'idle' | 'loading' | 'success' | 'error';
+const TRUSTED_SANDBOX =
+ 'allow-same-origin allow-scripts allow-forms allow-presentation';
+const UNTRUSTED_SANDBOX = 'allow-scripts';
+
export class EmbedIframeBlockComponent extends CaptionedBlockComponent {
selectedStyle$: ReadonlySignal | null = computed(
() => ({
@@ -89,6 +93,7 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent
- `;
+ ${sourceHost
+ ? html`${sourceHost}
`
+ : nothing}`;
+ };
+
+ private readonly _getSourceHost = () => {
+ const url = this.model.props.url ?? this.model.props.iframeUrl;
+ if (!url) return null;
+ try {
+ const parsed = new URL(url);
+ return parsed.hostname;
+ } catch {
+ return null;
+ }
};
private readonly _renderContent = () => {
diff --git a/blocksuite/affine/blocks/embed/src/embed-iframe-block/style.ts b/blocksuite/affine/blocks/embed/src/embed-iframe-block/style.ts
index 0d2236f39..8065d9fad 100644
--- a/blocksuite/affine/blocks/embed/src/embed-iframe-block/style.ts
+++ b/blocksuite/affine/blocks/embed/src/embed-iframe-block/style.ts
@@ -23,6 +23,19 @@ export const embedIframeBlockStyles = css`
height: 100%;
display: none;
}
+
+ .affine-embed-iframe-source {
+ position: absolute;
+ left: 8px;
+ bottom: 8px;
+ padding: 2px 6px;
+ background: rgba(0, 0, 0, 0.7);
+ color: #fff;
+ border-radius: 4px;
+ font-size: 12px;
+ line-height: 16px;
+ pointer-events: none;
+ }
.affine-embed-iframe-block-overlay.show {
display: block;
}
diff --git a/blocksuite/affine/blocks/embed/src/embed-loom-block/embed-loom-block.ts b/blocksuite/affine/blocks/embed/src/embed-loom-block/embed-loom-block.ts
index 3fe0b454d..5cd1cf85f 100644
--- a/blocksuite/affine/blocks/embed/src/embed-loom-block/embed-loom-block.ts
+++ b/blocksuite/affine/blocks/embed/src/embed-loom-block/embed-loom-block.ts
@@ -124,7 +124,8 @@ export class EmbedLoomBlockComponent extends EmbedBlockComponent<
diff --git a/blocksuite/affine/blocks/embed/src/embed-youtube-block/embed-youtube-block.ts b/blocksuite/affine/blocks/embed/src/embed-youtube-block/embed-youtube-block.ts
index 2a3d70eb1..f3bf9bfec 100644
--- a/blocksuite/affine/blocks/embed/src/embed-youtube-block/embed-youtube-block.ts
+++ b/blocksuite/affine/blocks/embed/src/embed-youtube-block/embed-youtube-block.ts
@@ -148,8 +148,8 @@ export class EmbedYoutubeBlockComponent extends EmbedBlockComponent<
type="text/html"
src=${`https://www.youtube.com/embed/${videoId}`}
frameborder="0"
- allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
- allowfullscreen
+ allow="fullscreen; autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share"
+ sandbox="allow-scripts allow-same-origin allow-presentation"
loading="lazy"
credentialless
>
diff --git a/blocksuite/affine/shared/src/services/embed-iframe/embed-iframe-config.ts b/blocksuite/affine/shared/src/services/embed-iframe/embed-iframe-config.ts
index 00b599c84..626b6c6bd 100644
--- a/blocksuite/affine/shared/src/services/embed-iframe/embed-iframe-config.ts
+++ b/blocksuite/affine/shared/src/services/embed-iframe/embed-iframe-config.ts
@@ -27,6 +27,7 @@ export type IframeOptions = {
allow?: string;
allowFullscreen?: boolean;
containerBorderRadius?: number;
+ sandbox?: string;
};
/**
diff --git a/blocksuite/affine/shared/src/services/embed-iframe/embed-iframe-service.ts b/blocksuite/affine/shared/src/services/embed-iframe/embed-iframe-service.ts
index edcca85bc..0b3899974 100644
--- a/blocksuite/affine/shared/src/services/embed-iframe/embed-iframe-service.ts
+++ b/blocksuite/affine/shared/src/services/embed-iframe/embed-iframe-service.ts
@@ -106,6 +106,7 @@ export class EmbedIframeService
}
const oEmbedUrl = config.buildOEmbedUrl(url);
+
if (!oEmbedUrl) {
return null;
}
diff --git a/packages/backend/server/package.json b/packages/backend/server/package.json
index 93c1c076b..1cf17a61a 100644
--- a/packages/backend/server/package.json
+++ b/packages/backend/server/package.json
@@ -58,7 +58,7 @@
"@opentelemetry/core": "^2.2.0",
"@opentelemetry/exporter-prometheus": "^0.208.0",
"@opentelemetry/exporter-zipkin": "^2.2.0",
- "@opentelemetry/host-metrics": "^0.37.0",
+ "@opentelemetry/host-metrics": "^0.38.0",
"@opentelemetry/instrumentation": "^0.208.0",
"@opentelemetry/instrumentation-graphql": "^0.56.0",
"@opentelemetry/instrumentation-http": "^0.208.0",
diff --git a/packages/common/debug/src/index.ts b/packages/common/debug/src/index.ts
index ad564f9a7..eabe33b12 100644
--- a/packages/common/debug/src/index.ts
+++ b/packages/common/debug/src/index.ts
@@ -4,15 +4,31 @@ type LogLevel = 'debug' | 'info' | 'warn' | 'error';
const SESSION_KEY = 'affine:debug';
if (typeof window !== 'undefined') {
+ const getSessionValue = (key: string) => {
+ try {
+ return sessionStorage.getItem(key);
+ } catch {
+ return null;
+ }
+ };
+
+ const setSessionValue = (key: string, value: string) => {
+ try {
+ sessionStorage.setItem(key, value);
+ } catch {
+ // ignore if storage is not accessible (e.g., sandboxed renderer)
+ }
+ };
+
// enable debug logs if the URL search string contains `debug`
// e.g. http://localhost:3000/?debug
if (window.location.search.includes('debug')) {
// enable debug logs for the current session
// since the query string may be removed by the browser after navigations,
// we need to store the debug flag in sessionStorage
- sessionStorage.setItem(SESSION_KEY, 'true');
+ setSessionValue(SESSION_KEY, 'true');
}
- if (sessionStorage.getItem(SESSION_KEY) === 'true') {
+ if (getSessionValue(SESSION_KEY) === 'true') {
// enable all debug logs by default
debug.enable('*');
console.warn('Debug logs enabled');
diff --git a/packages/common/error/src/index.ts b/packages/common/error/src/index.ts
index 7a2366a16..d24af347f 100644
--- a/packages/common/error/src/index.ts
+++ b/packages/common/error/src/index.ts
@@ -4,7 +4,8 @@ import { GraphQLError as BaseGraphQLError } from 'graphql';
export type ErrorName =
| keyof typeof ErrorNames
| 'NETWORK_ERROR'
- | 'CONTENT_TOO_LARGE';
+ | 'CONTENT_TOO_LARGE'
+ | 'REQUEST_ABORTED';
export interface UserFriendlyErrorResponse {
status: number;
diff --git a/packages/frontend/apps/electron/src/main/constants.ts b/packages/frontend/apps/electron/src/main/constants.ts
index 6d228df99..e4ed7c007 100644
--- a/packages/frontend/apps/electron/src/main/constants.ts
+++ b/packages/frontend/apps/electron/src/main/constants.ts
@@ -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:
diff --git a/packages/frontend/apps/electron/src/main/events.ts b/packages/frontend/apps/electron/src/main/events.ts
index f2f35a705..f75fcbe3a 100644
--- a/packages/frontend/apps/electron/src/main/events.ts
+++ b/packages/frontend/apps/electron/src/main/events.ts
@@ -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>();
+
+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();
+ 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);
diff --git a/packages/frontend/apps/electron/src/main/helper-process.ts b/packages/frontend/apps/electron/src/main/helper-process.ts
index d395d05fa..c6d791929 100644
--- a/packages/frontend/apps/electron/src/main/helper-process.ts
+++ b/packages/frontend/apps/electron/src/main/helper-process.ts
@@ -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
diff --git a/packages/frontend/apps/electron/src/main/protocol.ts b/packages/frontend/apps/electron/src/main/protocol.ts
index 305eeb28f..ba986160e 100644
--- a/packages/frontend/apps/electron/src/main/protocol.ts
+++ b/packages/frontend/apps/electron/src/main/protocol.ts
@@ -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,
+ name: string,
+ value: string
+) {
+ Object.keys(headers).forEach(key => {
+ if (key.toLowerCase() === name.toLowerCase()) {
+ delete headers[key];
+ }
+ });
+ headers[name] = [value];
+}
+
+function ensureFrameAncestors(
+ headers: Record,
+ 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'");
}
}
})()
diff --git a/packages/frontend/apps/electron/src/main/web-preferences.ts b/packages/frontend/apps/electron/src/main/web-preferences.ts
new file mode 100644
index 000000000..497edd965
--- /dev/null
+++ b/packages/frontend/apps/electron/src/main/web-preferences.ts
@@ -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 {
+ return {
+ ...DEFAULT_WEB_PREFERENCES,
+ ...overrides,
+ };
+}
diff --git a/packages/frontend/apps/electron/src/main/windows-manager/custom-theme-window.ts b/packages/frontend/apps/electron/src/main/windows-manager/custom-theme-window.ts
index ebd957deb..01bb5e29c 100644
--- a/packages/frontend/apps/electron/src/main/windows-manager/custom-theme-window.ts
+++ b/packages/frontend/apps/electron/src/main/windows-manager/custom-theme-window.ts
@@ -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 | 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);
diff --git a/packages/frontend/apps/electron/src/main/windows-manager/main-window.ts b/packages/frontend/apps/electron/src/main/windows-manager/main-window.ts
index 8fc5dfa72..066010e73 100644
--- a/packages/frontend/apps/electron/src/main/windows-manager/main-window.ts
+++ b/packages/frontend/apps/electron/src/main/windows-manager/main-window.ts
@@ -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,
});
diff --git a/packages/frontend/apps/electron/src/main/windows-manager/onboarding.ts b/packages/frontend/apps/electron/src/main/windows-manager/onboarding.ts
index ff79c2f3c..1c02e58de 100644
--- a/packages/frontend/apps/electron/src/main/windows-manager/onboarding.ts
+++ b/packages/frontend/apps/electron/src/main/windows-manager/onboarding.ts
@@ -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
diff --git a/packages/frontend/apps/electron/src/main/windows-manager/popup.ts b/packages/frontend/apps/electron/src/main/windows-manager/popup.ts
index 7e79f94ae..d856aa608 100644
--- a/packages/frontend/apps/electron/src/main/windows-manager/popup.ts
+++ b/packages/frontend/apps/electron/src/main/windows-manager/popup.ts
@@ -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
diff --git a/packages/frontend/apps/electron/src/main/windows-manager/tab-views.ts b/packages/frontend/apps/electron/src/main/windows-manager/tab-views.ts
index e9ac9a36c..cba954ec8 100644
--- a/packages/frontend/apps/electron/src/main/windows-manager/tab-views.ts
+++ b/packages/frontend/apps/electron/src/main/windows-manager/tab-views.ts
@@ -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}');
diff --git a/packages/frontend/apps/electron/src/main/worker/pool.ts b/packages/frontend/apps/electron/src/main/worker/pool.ts
index 1b8f07637..4a8ec3c7c 100644
--- a/packages/frontend/apps/electron/src/main/worker/pool.ts
+++ b/packages/frontend/apps/electron/src/main/worker/pool.ts
@@ -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);
diff --git a/packages/frontend/apps/electron/src/preload/electron-api.ts b/packages/frontend/apps/electron/src/preload/electron-api.ts
index 420d025e2..4429699b4 100644
--- a/packages/frontend/apps/electron/src/preload/electron-api.ts
+++ b/packages/frontend/apps/electron/src/preload/electron-api.ts
@@ -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 void)[]>();
+ const subscribeCounts = new Map();
+
+ 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);
}
};
},
diff --git a/packages/frontend/apps/electron/src/preload/shared-storage.ts b/packages/frontend/apps/electron/src/preload/shared-storage.ts
index 0e96daa05..7a0236605 100644
--- a/packages/frontend/apps/electron/src/preload/shared-storage.ts
+++ b/packages/frontend/apps/electron/src/preload/shared-storage.ts
@@ -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();
+ const updateQueue: Record[] = [];
+ 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) => {
+ 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(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,
diff --git a/packages/frontend/apps/electron/src/shared/type.ts b/packages/frontend/apps/electron/src/shared/type.ts
index 231ee3baf..088b96a43 100644
--- a/packages/frontend/apps/electron/src/shared/type.ts
+++ b/packages/frontend/apps/electron/src/shared/type.ts
@@ -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';
diff --git a/packages/frontend/core/src/blocksuite/ai/messages/wrapper.ts b/packages/frontend/core/src/blocksuite/ai/messages/wrapper.ts
index c06374877..6f06b193e 100644
--- a/packages/frontend/core/src/blocksuite/ai/messages/wrapper.ts
+++ b/packages/frontend/core/src/blocksuite/ai/messages/wrapper.ts
@@ -75,7 +75,6 @@ export const createIframeRenderer: (
class="ai-answer-iframe"
sandbox="allow-scripts"
scrolling="no"
- allowfullscreen
.srcdoc=${preprocessHtml(answer)}
>
`;
diff --git a/packages/frontend/core/src/blocksuite/ai/provider/copilot-client.ts b/packages/frontend/core/src/blocksuite/ai/provider/copilot-client.ts
index 1fdff183a..63ac1bd49 100644
--- a/packages/frontend/core/src/blocksuite/ai/provider/copilot-client.ts
+++ b/packages/frontend/core/src/blocksuite/ai/provider/copilot-client.ts
@@ -1,6 +1,6 @@
import { showAILoginRequiredAtom } from '@affine/core/components/affine/auth/ai-login-required';
import type { AIToolsConfig } from '@affine/core/modules/ai-button';
-import type { UserFriendlyError } from '@affine/error';
+import { UserFriendlyError } from '@affine/error';
import {
addContextBlobMutation,
addContextCategoryMutation,
@@ -50,6 +50,20 @@ export enum Endpoint {
type OptionsField =
RequestOptions['variables'] extends { options: infer U } ? U : never;
+function toUserFriendlyError(err: any): UserFriendlyError {
+ return err instanceof UserFriendlyError
+ ? err
+ : UserFriendlyError.fromAny(err);
+}
+
+function isAbortError(error: UserFriendlyError) {
+ return (
+ error.name === 'REQUEST_ABORTED' ||
+ error.code === 'REQUEST_ABORTED' ||
+ error.message?.toLowerCase().includes('aborted') === true
+ );
+}
+
function codeToError(error: UserFriendlyError) {
switch (error.status) {
case 401:
@@ -66,7 +80,7 @@ function codeToError(error: UserFriendlyError) {
}
export function resolveError(err: any) {
- return codeToError(err);
+ return codeToError(toUserFriendlyError(err));
}
export function handleError(src: any) {
@@ -185,7 +199,11 @@ export class CopilotClient {
});
return res.currentUser?.copilot?.chats.edges.map(e => e.node);
} catch (err) {
- throw resolveError(err);
+ const parsed = toUserFriendlyError(err);
+ if (isAbortError(parsed)) {
+ return [];
+ }
+ throw resolveError(parsed);
}
}
@@ -205,7 +223,11 @@ export class CopilotClient {
});
return res.currentUser?.copilot?.chats.edges.map(e => e.node);
} catch (err) {
- throw resolveError(err);
+ const parsed = toUserFriendlyError(err);
+ if (isAbortError(parsed)) {
+ return [];
+ }
+ throw resolveError(parsed);
}
}
@@ -230,7 +252,11 @@ export class CopilotClient {
return res.currentUser?.copilot?.chats.edges.map(e => e.node);
} catch (err) {
- throw resolveError(err);
+ const parsed = toUserFriendlyError(err);
+ if (isAbortError(parsed)) {
+ return [];
+ }
+ throw resolveError(parsed);
}
}
@@ -255,7 +281,11 @@ export class CopilotClient {
return res.currentUser?.copilot?.chats.edges.map(e => e.node);
} catch (err) {
- throw resolveError(err);
+ const parsed = toUserFriendlyError(err);
+ if (isAbortError(parsed)) {
+ return [];
+ }
+ throw resolveError(parsed);
}
}
diff --git a/packages/frontend/core/src/modules/cloud/services/fetch.ts b/packages/frontend/core/src/modules/cloud/services/fetch.ts
index d5c7cf8a9..18319dbc0 100644
--- a/packages/frontend/core/src/modules/cloud/services/fetch.ts
+++ b/packages/frontend/core/src/modules/cloud/services/fetch.ts
@@ -63,13 +63,22 @@ export class FetchService extends Service {
}
);
} catch (err: any) {
+ const isAbort =
+ err?.name === 'AbortError' ||
+ err?.code === 'ABORT_ERR' ||
+ err?.type === 'aborted' ||
+ abortController.signal.aborted;
+
+ const message =
+ err?.message || (isAbort ? 'Request aborted' : 'Unknown network error');
+
throw new UserFriendlyError({
- status: 504,
- code: 'NETWORK_ERROR',
- type: 'NETWORK_ERROR',
- name: 'NETWORK_ERROR',
- message: `Network error: ${err.message}`,
- stacktrace: err.stack,
+ status: isAbort ? 499 : 504,
+ code: isAbort ? 'REQUEST_ABORTED' : 'NETWORK_ERROR',
+ type: isAbort ? 'REQUEST_ABORTED' : 'NETWORK_ERROR',
+ name: isAbort ? 'REQUEST_ABORTED' : 'NETWORK_ERROR',
+ message: `Network error: ${message}`,
+ stacktrace: err?.stack,
});
} finally {
clearTimeout(timeoutId);
diff --git a/yarn.lock b/yarn.lock
index 621539ec1..ad328b9dc 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -948,7 +948,7 @@ __metadata:
"@opentelemetry/core": "npm:^2.2.0"
"@opentelemetry/exporter-prometheus": "npm:^0.208.0"
"@opentelemetry/exporter-zipkin": "npm:^2.2.0"
- "@opentelemetry/host-metrics": "npm:^0.37.0"
+ "@opentelemetry/host-metrics": "npm:^0.38.0"
"@opentelemetry/instrumentation": "npm:^0.208.0"
"@opentelemetry/instrumentation-graphql": "npm:^0.56.0"
"@opentelemetry/instrumentation-http": "npm:^0.208.0"
@@ -11440,14 +11440,14 @@ __metadata:
languageName: node
linkType: hard
-"@opentelemetry/host-metrics@npm:^0.37.0":
- version: 0.37.0
- resolution: "@opentelemetry/host-metrics@npm:0.37.0"
+"@opentelemetry/host-metrics@npm:^0.38.0":
+ version: 0.38.0
+ resolution: "@opentelemetry/host-metrics@npm:0.38.0"
dependencies:
systeminformation: "npm:5.23.8"
peerDependencies:
"@opentelemetry/api": ^1.3.0
- checksum: 10/c2929a28f046ff99633ae4dafb758967be5623fa9c456f01a82d7f58823bf328a388f928e36a0591d963adff532fe9461abbebc532a7604ec48f3a194cb7f129
+ checksum: 10/22f088b4c1541e86b4447d6386b771bed9c332bf0449b5c6fdc1ebbc1ccd5dc5f37223ad10e4591040cd99123596a92f05aa77587cdd2702c53c6a1b77169b79
languageName: node
linkType: hard