fix(electron): create doc shortcut should follow default type in settings (#14678)

This commit is contained in:
DarkSky
2026-03-18 14:58:22 +08:00
committed by GitHub
parent c1a09b951f
commit d6d5ae6182
18 changed files with 156 additions and 81 deletions

View File

@@ -46,7 +46,10 @@ export function setupEvents(frameworkProvider: FrameworkProvider) {
const { workspace } = currentWorkspace;
const docsService = workspace.scope.get(DocsService);
const page = docsService.createDoc({ primaryMode: type });
const page =
type === 'default'
? docsService.createDoc()
: docsService.createDoc({ primaryMode: type });
workspace.scope.get(WorkbenchService).workbench.openDoc(page.id);
})
.catch(err => {

View File

@@ -67,7 +67,7 @@ export function createApplicationMenu() {
click: async () => {
await initAndShowMainWindow();
// fixme: if the window is just created, the new page action will not be triggered
applicationMenuSubjects.newPageAction$.next('page');
applicationMenuSubjects.newPageAction$.next('default');
},
},
],

View File

@@ -1,5 +1,5 @@
import type { MainEventRegister } from '../type';
import { applicationMenuSubjects } from './subject';
import { applicationMenuSubjects, type NewPageAction } from './subject';
export * from './create';
export * from './subject';
@@ -11,7 +11,7 @@ export const applicationMenuEvents = {
/**
* File -> New Doc
*/
onNewPageAction: (fn: (type: 'page' | 'edgeless') => void) => {
onNewPageAction: (fn: (type: NewPageAction) => void) => {
const sub = applicationMenuSubjects.newPageAction$.subscribe(fn);
return () => {
sub.unsubscribe();

View File

@@ -1,7 +1,9 @@
import { Subject } from 'rxjs';
export type NewPageAction = 'page' | 'edgeless' | 'default';
export const applicationMenuSubjects = {
newPageAction$: new Subject<'page' | 'edgeless'>(),
newPageAction$: new Subject<NewPageAction>(),
openJournal$: new Subject<void>(),
openInSettingModal$: new Subject<{
activeTab: string;

View File

@@ -9,6 +9,7 @@ import { beforeAppQuit } from './cleanup';
import { logger } from './logger';
import { powerEvents } from './power';
import { recordingEvents } from './recording';
import { checkSource } from './security-restrictions';
import { sharedStorageEvents } from './shared-storage';
import { uiEvents } from './ui/events';
import { updaterEvents } from './updater/event';
@@ -70,7 +71,7 @@ export function registerEvents() {
action: 'subscribe' | 'unsubscribe',
channel: string
) => {
if (typeof channel !== 'string') return;
if (!checkSource(event) || typeof channel !== 'string') return;
if (action === 'subscribe') {
addSubscription(event.sender, channel);
if (channel === 'power:power-source') {

View File

@@ -7,6 +7,7 @@ import { configStorageHandlers } from './config-storage';
import { findInPageHandlers } from './find-in-page';
import { getLogFilePath, logger, revealLogFile } from './logger';
import { recordingHandlers } from './recording';
import { checkSource } from './security-restrictions';
import { sharedStorageHandlers } from './shared-storage';
import { uiHandlers } from './ui/handlers';
import { updaterHandlers } from './updater';
@@ -49,7 +50,7 @@ export const registerHandlers = () => {
...args: any[]
) => {
// args[0] is the `{namespace:key}`
if (typeof args[0] !== 'string') {
if (!checkSource(e) || typeof args[0] !== 'string') {
logger.error('invalid ipc message', args);
return;
}
@@ -97,6 +98,8 @@ export const registerHandlers = () => {
});
ipcMain.on(AFFINE_API_CHANNEL_NAME, (e, ...args: any[]) => {
if (!checkSource(e)) return;
handleIpcMessage(e, ...args)
.then(ret => {
e.returnValue = ret;

View File

@@ -1,5 +1,3 @@
import './security-restrictions';
import path from 'node:path';
import * as Sentry from '@sentry/electron/main';
@@ -15,6 +13,7 @@ import { registerHandlers } from './handlers';
import { logger } from './logger';
import { registerProtocol } from './protocol';
import { setupRecordingFeature } from './recording/feature';
import { registerSecurityRestrictions } from './security-restrictions';
import { setupTrayState } from './tray';
import { registerUpdater } from './updater';
import { launch } from './windows-manager/launcher';
@@ -105,6 +104,7 @@ app.on('activate', () => {
});
setupDeepLink(app);
registerSecurityRestrictions();
/**
* Create app window when background process will be ready

View File

@@ -4,9 +4,9 @@ import { pathToFileURL } from 'node:url';
import { app, net, protocol, session } from 'electron';
import cookieParser from 'set-cookie-parser';
import { anotherHost, mainHost } from '../shared/internal-origin';
import { isWindows, resourcesPath } from '../shared/utils';
import { buildType, isDev } from './config';
import { anotherHost, mainHost } from './constants';
import { logger } from './logger';
const webStaticDir = join(resourcesPath, 'web-static');

View File

@@ -1,71 +1,71 @@
import { app } from 'electron';
import { anotherHost, mainHost } from './constants';
import { isInternalUrl } from '../shared/internal-origin';
import { logger } from './logger';
import { openExternalSafely } from './security/open-external';
import { validateRedirectProxyUrl } from './security/redirect-proxy';
app.on('web-contents-created', (_, contents) => {
const isInternalUrl = (url: string) => {
try {
const parsed = new URL(url);
if (
parsed.protocol === 'assets:' &&
(parsed.hostname === mainHost || parsed.hostname === anotherHost)
) {
return true;
}
} catch {}
return false;
};
/**
* Block navigation to origins not on the allowlist.
*
* Navigation is a common attack vector. If an attacker can convince the app to navigate away
* from its current page, they can possibly force the app to open web sites on the Internet.
*
* @see https://www.electronjs.org/docs/latest/tutorial/security#13-disable-or-limit-navigation
*/
contents.on('will-navigate', (event, url) => {
if (isInternalUrl(url)) {
return;
}
// Prevent navigation
event.preventDefault();
openExternalSafely(url).catch(error => {
console.error('[security] Failed to open external URL:', error);
});
});
export const checkSource = (
e: Electron.IpcMainInvokeEvent | Electron.IpcMainEvent
) => {
const url = e.senderFrame?.url || e.sender.getURL();
const result = isInternalUrl(url);
if (!result) logger.error('invalid source', url);
return result;
};
/**
* Hyperlinks to allowed sites open in the default browser.
*
* The creation of new `webContents` is a common attack vector. Attackers attempt to convince the app to create new windows,
* frames, or other renderer processes with more privileges than they had before; or with pages opened that they couldn't open before.
* You should deny any unexpected window creation.
*
* @see https://www.electronjs.org/docs/latest/tutorial/security#14-disable-or-limit-creation-of-new-windows
* @see https://www.electronjs.org/docs/latest/tutorial/security#15-do-not-use-openexternal-with-untrusted-content
*/
contents.setWindowOpenHandler(({ url }) => {
if (!isInternalUrl(url)) {
export const registerSecurityRestrictions = () => {
app.on('web-contents-created', (_, contents) => {
/**
* Block navigation to origins not on the allowlist.
*
* Navigation is a common attack vector. If an attacker can convince the app to navigate away
* from its current page, they can possibly force the app to open web sites on the Internet.
*
* @see https://www.electronjs.org/docs/latest/tutorial/security#13-disable-or-limit-navigation
*/
contents.on('will-navigate', (event, url) => {
if (isInternalUrl(url)) {
return;
}
// Prevent navigation
event.preventDefault();
openExternalSafely(url).catch(error => {
console.error('[security] Failed to open external URL:', error);
});
} else if (url.includes('/redirect-proxy')) {
const result = validateRedirectProxyUrl(url);
if (!result.allow) {
console.warn(
`[security] Blocked redirect proxy: ${result.reason}`,
result.redirectTarget ?? url
);
return { action: 'deny' };
}
});
openExternalSafely(result.redirectTarget).catch(error => {
console.error('[security] Failed to open external URL:', error);
});
}
// Prevent creating new window in application
return { action: 'deny' };
/**
* Hyperlinks to allowed sites open in the default browser.
*
* The creation of new `webContents` is a common attack vector. Attackers attempt to convince the app to create new windows,
* frames, or other renderer processes with more privileges than they had before; or with pages opened that they couldn't open before.
* You should deny any unexpected window creation.
*
* @see https://www.electronjs.org/docs/latest/tutorial/security#14-disable-or-limit-creation-of-new-windows
* @see https://www.electronjs.org/docs/latest/tutorial/security#15-do-not-use-openexternal-with-untrusted-content
*/
contents.setWindowOpenHandler(({ url }) => {
if (!isInternalUrl(url)) {
openExternalSafely(url).catch(error => {
console.error('[security] Failed to open external URL:', error);
});
} else if (url.includes('/redirect-proxy')) {
const result = validateRedirectProxyUrl(url);
if (!result.allow) {
console.warn(
`[security] Blocked redirect proxy: ${result.reason}`,
result.redirectTarget ?? url
);
return { action: 'deny' };
}
openExternalSafely(result.redirectTarget).catch(error => {
console.error('[security] Failed to open external URL:', error);
});
}
// Prevent creating new window in application
return { action: 'deny' };
});
});
});
};

View File

@@ -2,8 +2,8 @@ import { join } from 'node:path';
import { BrowserWindow, type Display, screen } from 'electron';
import { customThemeViewUrl } from '../../shared/internal-origin';
import { isMacOS } from '../../shared/utils';
import { customThemeViewUrl } from '../constants';
import { logger } from '../logger';
import { buildWebPreferences } from '../web-preferences';

View File

@@ -4,10 +4,10 @@ import { BrowserWindow, nativeTheme } from 'electron';
import electronWindowState from 'electron-window-state';
import { BehaviorSubject, map, shareReplay } from 'rxjs';
import { mainWindowOrigin } from '../../shared/internal-origin';
import { isLinux, isMacOS, isWindows, resourcesPath } from '../../shared/utils';
import { beforeAppQuit } from '../cleanup';
import { buildType } from '../config';
import { mainWindowOrigin } from '../constants';
import { ensureHelperProcess } from '../helper-process';
import { logger } from '../logger';
import { MenubarStateKey, MenubarStateSchema } from '../shared-state-schema';

View File

@@ -2,8 +2,8 @@ import { join } from 'node:path';
import { BrowserWindow, screen } from 'electron';
import { onboardingViewUrl } from '../../shared/internal-origin';
import { isDev } from '../config';
import { onboardingViewUrl } from '../constants';
// import { getExposedMeta } from './exposed';
import { logger } from '../logger';
import { buildWebPreferences } from '../web-preferences';

View File

@@ -8,7 +8,7 @@ import {
} from 'electron';
import { BehaviorSubject } from 'rxjs';
import { popupViewUrl } from '../constants';
import { popupViewUrl } from '../../shared/internal-origin';
import { logger } from '../logger';
import type { MainEventRegister, NamespaceHandlers } from '../type';
import { buildWebPreferences } from '../web-preferences';

View File

@@ -24,9 +24,9 @@ import {
type Unsubscribable,
} from 'rxjs';
import { mainWindowOrigin, shellViewUrl } from '../../shared/internal-origin';
import { isMacOS } from '../../shared/utils';
import { beforeAppQuit, onTabClose } from '../cleanup';
import { mainWindowOrigin, shellViewUrl } from '../constants';
import { ensureHelperProcess } from '../helper-process';
import { logger } from '../logger';
import {

View File

@@ -2,7 +2,7 @@ import { join } from 'node:path';
import { BrowserWindow, MessageChannelMain, type WebContents } from 'electron';
import { backgroundWorkerViewUrl } from '../constants';
import { backgroundWorkerViewUrl } from '../../shared/internal-origin';
import { ensureHelperProcess } from '../helper-process';
import { logger } from '../logger';
import { buildWebPreferences } from '../web-preferences';

View File

@@ -2,13 +2,21 @@ import '@sentry/electron/preload';
import { contextBridge } from 'electron';
import { isInternalUrl } from '../shared/internal-origin';
import { apis, appInfo, events } from './electron-api';
import { sharedStorage } from './shared-storage';
import { listenWorkerApis } from './worker';
contextBridge.exposeInMainWorld('__appInfo', appInfo);
contextBridge.exposeInMainWorld('__apis', apis);
contextBridge.exposeInMainWorld('__events', events);
contextBridge.exposeInMainWorld('__sharedStorage', sharedStorage);
const locationLike = (globalThis as { location?: { href?: unknown } }).location;
listenWorkerApis();
const currentUrl =
typeof locationLike?.href === 'string' ? locationLike.href : null;
if (currentUrl && isInternalUrl(currentUrl)) {
contextBridge.exposeInMainWorld('__appInfo', appInfo);
contextBridge.exposeInMainWorld('__apis', apis);
contextBridge.exposeInMainWorld('__events', events);
contextBridge.exposeInMainWorld('__sharedStorage', sharedStorage);
listenWorkerApis();
}

View File

@@ -1,5 +1,6 @@
export const mainHost = '.';
export const anotherHost = 'another-host';
export const internalHosts = new Set([mainHost, anotherHost]);
export const mainWindowOrigin = `assets://${mainHost}`;
export const anotherOrigin = `assets://${anotherHost}`;
@@ -13,3 +14,12 @@ export const customThemeViewUrl = `${mainWindowOrigin}/theme-editor`;
// Notes from electron official docs:
// "The zoom policy at the Chromium level is same-origin, meaning that the zoom level for a specific domain propagates across all instances of windows with the same domain. Differentiating the window URLs will make zoom work per-window."
export const popupViewUrl = `${anotherOrigin}/popup.html`;
export const isInternalUrl = (url: string) => {
try {
const parsed = new URL(url);
return parsed.protocol === 'assets:' && internalHosts.has(parsed.hostname);
} catch {
return false;
}
};

View File

@@ -1,7 +1,12 @@
import { test } from '@affine-test/kit/electron';
import {
ensureInEdgelessMode,
ensureInPageMode,
} from '@affine-test/kit/utils/editor';
import {
clickNewPageButton,
getBlockSuiteEditorTitle,
waitForEditorLoad,
} from '@affine-test/kit/utils/page-logic';
import { clickSideBarSettingButton } from '@affine-test/kit/utils/sidebar';
import { createLocalWorkspace } from '@affine-test/kit/utils/workspace';
@@ -14,12 +19,55 @@ const historyShortcut = async (page: Page, command: 'goBack' | 'goForward') => {
);
};
const setNewDocDefaultMode = async (
page: Page,
mode: 'page' | 'edgeless' | 'ask'
) => {
const modeTriggerByValue = {
page: 'page-mode-trigger',
edgeless: 'edgeless-mode-trigger',
ask: 'ask-every-time-trigger',
} as const;
await clickSideBarSettingButton(page);
await page.getByTestId('editor-panel-trigger').click();
await page.getByTestId('new-doc-default-mode-trigger').click();
await page.getByTestId(modeTriggerByValue[mode]).click();
await page.getByTestId('modal-close-button').click();
};
test('new page', async ({ page, workspace }) => {
await clickNewPageButton(page);
const flavour = (await workspace.current()).meta.flavour;
expect(flavour).toBe('local');
});
test('application menu respects default new doc mode', async ({
electronApp,
page,
}) => {
await waitForEditorLoad(page);
await ensureInPageMode(page);
await setNewDocDefaultMode(page, 'edgeless');
await electronApp.evaluate(({ BrowserWindow, Menu }) => {
const menuItem =
Menu.getApplicationMenu()?.getMenuItemById('affine:new-page');
const focusedWindow = BrowserWindow.getFocusedWindow();
if (!menuItem) {
throw new Error('Missing application menu item: affine:new-page');
}
if (!focusedWindow) {
throw new Error('Missing focused window for application menu dispatch');
}
menuItem.click(undefined, focusedWindow, focusedWindow.webContents);
});
await ensureInEdgelessMode(page);
});
test('app sidebar router forward/back', async ({ page }) => {
// create pages
await page.waitForTimeout(500);