import { app, BrowserWindow, clipboard, ipcMain, Menu, shell, type MenuItemConstructorOptions, } from 'electron'; const MAX_LINK_LENGTH = 4_096; const LOCAL_WEB_FILE_PATTERN = /\.(?:html?|xhtml)$/i; export type ConversationLinkContextMenuRequest = | { kind: 'external'; target: string } | { kind: 'local-web'; target: string }; export interface ConversationLinkMenuActions { platform: NodeJS.Platform; homePath: string; openExternal: (url: string) => Promise; openPath: (path: string) => Promise; showItemInFolder: (path: string) => void; writeText: (value: string) => void; } function isBoundedSingleLine(value: string): boolean { return value.length > 0 && value.length <= MAX_LINK_LENGTH && !/[\0\r\n]/.test(value); } function safeExternalTarget(value: unknown): string | null { const candidate = typeof value === 'string' ? value.trim() : ''; if (!isBoundedSingleLine(candidate)) return null; try { const url = new URL(candidate); if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.username || url.password) { return null; } return url.toString(); } catch { return null; } } function resolveHomeRelativePath(value: string, homePath: string): string { if (value === '~') return homePath; const separator = homePath.includes('\\') ? '\\' : '/'; const suffix = value.slice(2).replace(/[\\/]/g, separator); return `${homePath.replace(/[\\/]$/, '')}${separator}${suffix}`; } function safeLocalWebTarget(value: unknown, homePath: string): string | null { const candidate = typeof value === 'string' ? value.trim() : ''; if (!isBoundedSingleLine(candidate) || !LOCAL_WEB_FILE_PATTERN.test(candidate)) return null; if (candidate === '~' || /^~[\\/]/.test(candidate)) { return resolveHomeRelativePath(candidate, homePath); } const absolute = candidate.startsWith('/') || /^[A-Za-z]:[\\/]/.test(candidate) || /^\\\\[^\\]+\\[^\\]+/.test(candidate); return absolute ? candidate : null; } function ignoreRejectedAction(action: Promise): void { void action.catch(() => undefined); } export function createConversationLinkMenuTemplate( request: ConversationLinkContextMenuRequest, actions: ConversationLinkMenuActions, ): MenuItemConstructorOptions[] { if (request?.kind === 'external') { const target = safeExternalTarget(request.target); if (!target) throw new Error('Unsupported external URL'); return [ { label: '用默认浏览器打开', click: () => ignoreRejectedAction(actions.openExternal(target)), }, { type: 'separator' }, { label: '复制链接地址', click: () => actions.writeText(target), }, ]; } if (request?.kind === 'local-web') { const target = safeLocalWebTarget(request.target, actions.homePath); if (!target) throw new Error('Unsupported local web file'); return [ { label: '用默认浏览器打开', click: () => ignoreRejectedAction(actions.openPath(target)), }, { label: actions.platform === 'darwin' ? '在访达中显示' : '在文件夹中显示', click: () => actions.showItemInFolder(target), }, { type: 'separator' }, { label: '复制地址', click: () => actions.writeText(target), }, ]; } throw new Error('Unsupported link context menu request'); } export function registerConversationLinkContextMenuHandler(mainWindow: BrowserWindow): void { ipcMain.handle('shell:showLinkContextMenu', (event, request: ConversationLinkContextMenuRequest) => { const template = createConversationLinkMenuTemplate(request, { platform: process.platform, homePath: app.getPath('home'), openExternal: (url) => shell.openExternal(url), openPath: (path) => shell.openPath(path), showItemInFolder: (path) => shell.showItemInFolder(path), writeText: (value) => clipboard.writeText(value), }); const menu = Menu.buildFromTemplate(template); const ownerWindow = BrowserWindow.fromWebContents(event.sender) ?? mainWindow; menu.popup({ window: ownerWindow }); }); }