feat: support sending and preview links
This commit is contained in:
+117
-1
@@ -1,8 +1,10 @@
|
||||
import { app, BrowserWindow, ipcMain, session } from 'electron';
|
||||
import { app, BrowserWindow, ipcMain, session, shell } from 'electron';
|
||||
import path from 'node:path';
|
||||
import started from 'electron-squirrel-startup';
|
||||
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
|
||||
|
||||
import type { LinkMetadata } from './lib/link-metadata';
|
||||
|
||||
updateElectronApp({
|
||||
updateSource: {
|
||||
type: UpdateSourceType.StaticStorage,
|
||||
@@ -59,6 +61,120 @@ ipcMain.on('window:close', (event) => {
|
||||
BrowserWindow.fromWebContents(event.sender)?.close();
|
||||
});
|
||||
|
||||
// --- Link metadata ---
|
||||
|
||||
const metadataCache = new Map<string, LinkMetadata>();
|
||||
|
||||
function getMetaContent(html: string, property: string): string | null {
|
||||
// Match both property="..." and name="..." attributes
|
||||
const regex = new RegExp(
|
||||
`<meta[^>]*(?:property|name)=["']${property}["'][^>]*content=["']([^"']*)["']|<meta[^>]*content=["']([^"']*)["'][^>]*(?:property|name)=["']${property}["']`,
|
||||
'i',
|
||||
);
|
||||
const match = html.match(regex);
|
||||
return match?.[1] ?? match?.[2] ?? null;
|
||||
}
|
||||
|
||||
function getTitle(html: string): string | null {
|
||||
const match = html.match(/<title[^>]*>([^<]*)<\/title>/i);
|
||||
return match?.[1]?.trim() ?? null;
|
||||
}
|
||||
|
||||
function getFavicon(html: string, baseUrl: string): string | null {
|
||||
const match = html.match(/<link[^>]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']*)["']/i)
|
||||
?? html.match(/<link[^>]*href=["']([^"']*)["'][^>]*rel=["'](?:shortcut )?icon["']/i);
|
||||
if (!match?.[1]) {
|
||||
// Fall back to /favicon.ico
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
return `${url.protocol}//${url.host}/favicon.ico`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return new URL(match[1], baseUrl).href;
|
||||
} catch {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
|
||||
function resolveUrl(src: string | null, baseUrl: string): string | null {
|
||||
if (!src) return null;
|
||||
try {
|
||||
return new URL(src, baseUrl).href;
|
||||
} catch {
|
||||
return src;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLinkMetadata(url: string): Promise<LinkMetadata | null> {
|
||||
const cached = metadataCache.get(url);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; llink/1.0)',
|
||||
'Accept': 'text/html',
|
||||
},
|
||||
redirect: 'follow',
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
// Only read the first ~50KB to get <head> content
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) return null;
|
||||
|
||||
let html = '';
|
||||
const decoder = new TextDecoder();
|
||||
while (html.length < 50_000) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
html += decoder.decode(value, { stream: true });
|
||||
}
|
||||
reader.cancel();
|
||||
|
||||
const domain = new URL(url).hostname.replace(/^www\./, '');
|
||||
const metadata: LinkMetadata = {
|
||||
url,
|
||||
title: getMetaContent(html, 'og:title') ?? getTitle(html),
|
||||
description: getMetaContent(html, 'og:description') ?? getMetaContent(html, 'description'),
|
||||
image: resolveUrl(getMetaContent(html, 'og:image'), url),
|
||||
favicon: getFavicon(html, url),
|
||||
domain,
|
||||
};
|
||||
|
||||
metadataCache.set(url, metadata);
|
||||
return metadata;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('link:fetch-metadata', async (_event, url: string) => {
|
||||
if (typeof url !== 'string') return null;
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return fetchLinkMetadata(url);
|
||||
});
|
||||
|
||||
ipcMain.handle('link:open-external', async (_event, url: string) => {
|
||||
if (typeof url !== 'string') return;
|
||||
// Only allow http(s) URLs for security
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) return;
|
||||
await shell.openExternal(url);
|
||||
});
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
|
||||
Reference in New Issue
Block a user