feat: ship a web app (#218)
* feat(orion): add endpoint for link metadata * refactor: cleanup comments * wip: plumbing for building a web app * setup deployment materials for web app * fix(web): favicon
This commit was merged in pull request #218.
This commit is contained in:
@@ -3,7 +3,6 @@ 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';
|
||||
import { appConfig } from './config/env';
|
||||
import { logError } from './lib/errors';
|
||||
import { safeHandle } from './main/ipc-utils';
|
||||
@@ -370,106 +369,6 @@ ipcMain.on('autoplay:navigate', (_event, data) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- 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 (err) {
|
||||
// Metadata is a progressive enhancement — keep the null contract, but log
|
||||
// so upstream failures (DNS, TLS, aborted fetches) aren't invisible.
|
||||
logError(err, { scope: 'link.fetchMetadata', url });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dock badge ---
|
||||
|
||||
ipcMain.on('app:set-dock-badge', (_event, count: number) => {
|
||||
@@ -480,16 +379,6 @@ ipcMain.on('app:set-dock-badge', (_event, count: number) => {
|
||||
|
||||
safeHandle('app:get-version', () => app.getVersion());
|
||||
|
||||
safeHandle('link:fetch-metadata', async (_event, url) => {
|
||||
if (typeof url !== 'string') return null;
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return fetchLinkMetadata(url);
|
||||
});
|
||||
|
||||
safeHandle('link:open-external', async (_event, url) => {
|
||||
if (typeof url !== 'string') return;
|
||||
// Only allow http(s) URLs for security
|
||||
|
||||
Reference in New Issue
Block a user