135 lines
4.1 KiB
TypeScript
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 });
|
|
});
|
|
}
|