Files
makelore/electron/main/ipc/conversation-link-context-menu.ts

135 lines
4.1 KiB
TypeScript

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<void>;
openPath: (path: string) => Promise<string>;
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<unknown>): 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 });
});
}