feat: support sending and preview links

This commit is contained in:
talksik
2026-03-21 12:46:23 -07:00
parent 6bf2f2217a
commit d6b7ecc8fd
8 changed files with 431 additions and 60 deletions
+117 -1
View File
@@ -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.