feat(desktop): implement workspace foundation + desktop-client skeleton
Plan 0 (workspaces): add workspaces + workspace_memberships schema, extend JWT/Actor/claims with primary_workspace_id, seed default workspace per tenant, thread workspace_id through tenant monitoring quota. Plan A (desktop skeleton): new Electron app (apps/desktop-client) with main/ preload/renderer, shared Vue component package (packages/ui-shared), and server surface — desktop client registration + token rotation + heartbeat, SSE task event stream, desktop accounts/tasks/content handlers, publish job endpoint, and supporting repositories, services, sqlc queries, and migrations. Hard cutover per plan: remove browser-extension monitoring callback endpoints, stub legacy media API in admin-web, and delete monitoring_callback_handler.go.
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
import type { DesktopArticleContent } from "@geo/shared-types";
|
||||
import type { Session, WebContents, WebContentsView } from "electron/main";
|
||||
|
||||
import { STANDARD_ACCEPT_LANGUAGES, STANDARD_USER_AGENT } from "../user-agent";
|
||||
|
||||
export function normalizeText(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
export function normalizeRemoteUrl(value?: string | null): string | null {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (/^(data|blob):/i.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
if (/^\/\//.test(trimmed)) {
|
||||
return `https:${trimmed}`;
|
||||
}
|
||||
if (/^[a-z][a-z\d+\-.]*:/i.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
return `https://${trimmed.replace(/^\/+/, "")}`;
|
||||
}
|
||||
|
||||
export function normalizeArticleHtml(article: DesktopArticleContent): string {
|
||||
let next = article.html_content?.trim() ?? "";
|
||||
next = next.replace(/<section\b/gi, "<div").replace(/<\/section>/gi, "</div>");
|
||||
next = next.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, "");
|
||||
next = next.replace(/\sstyle="[^"]*"/gi, "");
|
||||
next = next.replace(/\sdata-[a-z-]+="[^"]*"/gi, "");
|
||||
next = next.replace(/<figure[^>]*>\s*(<img[\s\S]*?>)\s*<\/figure>/gi, "$1");
|
||||
return next.trim();
|
||||
}
|
||||
|
||||
export function extractImageSources(html: string): string[] {
|
||||
const sources = new Set<string>();
|
||||
for (const match of html.matchAll(/<img\b[^>]*src=(['"])(.*?)\1[^>]*>/gi)) {
|
||||
const src = match[2]?.trim();
|
||||
if (src) {
|
||||
sources.add(src);
|
||||
}
|
||||
}
|
||||
return [...sources];
|
||||
}
|
||||
|
||||
export async function fetchImageBlob(sourceUrl: string): Promise<Blob | null> {
|
||||
const normalizedUrl = normalizeRemoteUrl(sourceUrl);
|
||||
if (!normalizedUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await fetch(normalizedUrl).catch(() => null);
|
||||
if (!response?.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const blob = await response.blob().catch(() => null);
|
||||
if (!blob || !blob.type.startsWith("image/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return blob;
|
||||
}
|
||||
|
||||
export async function uploadHtmlImages(
|
||||
html: string,
|
||||
uploader: (sourceUrl: string) => Promise<string | null>,
|
||||
): Promise<{ html: string; uploaded: Map<string, string> }> {
|
||||
const sources = extractImageSources(html);
|
||||
if (!sources.length) {
|
||||
return {
|
||||
html,
|
||||
uploaded: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
const uploaded = new Map<string, string>();
|
||||
for (const source of sources) {
|
||||
const target = await uploader(source);
|
||||
if (target) {
|
||||
uploaded.set(source, target);
|
||||
}
|
||||
}
|
||||
|
||||
let next = html;
|
||||
for (const [from, to] of uploaded.entries()) {
|
||||
next = next.split(from).join(to);
|
||||
}
|
||||
|
||||
return { html: next, uploaded };
|
||||
}
|
||||
|
||||
export async function sessionFetchText(
|
||||
session: Session,
|
||||
input: string,
|
||||
init?: RequestInit,
|
||||
): Promise<string> {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (!headers.has("user-agent")) {
|
||||
headers.set("user-agent", STANDARD_USER_AGENT);
|
||||
}
|
||||
if (!headers.has("accept-language")) {
|
||||
headers.set("accept-language", STANDARD_ACCEPT_LANGUAGES);
|
||||
}
|
||||
|
||||
const response = await session.fetch(input, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(text || `request_failed_${response.status}`);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export async function sessionFetchJson<T>(
|
||||
session: Session,
|
||||
input: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const text = await sessionFetchText(session, input, init);
|
||||
if (!text) {
|
||||
return {} as T;
|
||||
}
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
export interface PageFetchInit {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
credentials?: "include" | "same-origin" | "omit";
|
||||
}
|
||||
|
||||
export async function sessionCookieFetchJson<T>(
|
||||
session: Session,
|
||||
url: string,
|
||||
init?: { method?: string; headers?: Record<string, string>; body?: string },
|
||||
): Promise<T | null> {
|
||||
let parsedURL: URL;
|
||||
try {
|
||||
parsedURL = new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let cookieHeader = "";
|
||||
try {
|
||||
const cookies = await session.cookies.get({ url });
|
||||
cookieHeader = cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ");
|
||||
} catch {
|
||||
cookieHeader = "";
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"user-agent": STANDARD_USER_AGENT,
|
||||
"accept-language": STANDARD_ACCEPT_LANGUAGES,
|
||||
accept: "application/json, text/plain, */*",
|
||||
referer: `${parsedURL.protocol}//${parsedURL.host}/`,
|
||||
origin: `${parsedURL.protocol}//${parsedURL.host}`,
|
||||
};
|
||||
if (cookieHeader) {
|
||||
headers.cookie = cookieHeader;
|
||||
}
|
||||
if (init?.headers) {
|
||||
for (const [key, value] of Object.entries(init.headers)) {
|
||||
headers[key.toLowerCase()] = value;
|
||||
}
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: init?.method ?? "GET",
|
||||
headers,
|
||||
body: init?.body,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let text: string;
|
||||
try {
|
||||
text = await response.text();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
return {} as T;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function pageFetchJson<T>(
|
||||
webContents: WebContents,
|
||||
url: string,
|
||||
init?: PageFetchInit,
|
||||
): Promise<T | null> {
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const effectiveInit: PageFetchInit = {
|
||||
credentials: "include",
|
||||
...init,
|
||||
};
|
||||
|
||||
const script = `(async () => {
|
||||
try {
|
||||
const response = await fetch(${JSON.stringify(url)}, ${JSON.stringify(effectiveInit)});
|
||||
if (!response.ok) {
|
||||
return JSON.stringify({ __ok: false, status: response.status });
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return JSON.stringify({ __ok: true, empty: true });
|
||||
}
|
||||
return JSON.stringify({ __ok: true, body: text });
|
||||
} catch (error) {
|
||||
return JSON.stringify({ __ok: false, error: String(error && error.message || error) });
|
||||
}
|
||||
})()`;
|
||||
|
||||
let serialized: string;
|
||||
try {
|
||||
serialized = (await webContents.executeJavaScript(script, true)) as string;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof serialized !== "string" || !serialized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let envelope: { __ok: boolean; body?: string; empty?: boolean; status?: number; error?: string };
|
||||
try {
|
||||
envelope = JSON.parse(serialized);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!envelope.__ok) {
|
||||
return null;
|
||||
}
|
||||
if (envelope.empty) {
|
||||
return {} as T;
|
||||
}
|
||||
if (!envelope.body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(envelope.body) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sessionCookieValue(
|
||||
session: Session,
|
||||
domain: string,
|
||||
name: string,
|
||||
): Promise<string> {
|
||||
const cookies = await session.cookies.get({ domain });
|
||||
return cookies.find((item) => item.name === name)?.value ?? "";
|
||||
}
|
||||
|
||||
export async function sessionCookieHeader(session: Session, domain: string): Promise<string> {
|
||||
const cookies = await session.cookies.get({ domain });
|
||||
return cookies.map((item) => `${item.name}=${item.value}`).join("; ");
|
||||
}
|
||||
|
||||
export async function ensureViewLoaded(
|
||||
view: WebContentsView,
|
||||
url: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
if (signal?.aborted) {
|
||||
throw new Error("adapter_aborted");
|
||||
}
|
||||
|
||||
if (view.webContents.isLoading()) {
|
||||
await waitForLoadStop(view, signal);
|
||||
}
|
||||
|
||||
const currentURL = view.webContents.getURL();
|
||||
if (currentURL === url) {
|
||||
return;
|
||||
}
|
||||
|
||||
await view.webContents.loadURL(url);
|
||||
await waitForLoadStop(view, signal);
|
||||
}
|
||||
|
||||
async function waitForLoadStop(view: WebContentsView, signal?: AbortSignal): Promise<void> {
|
||||
if (!view.webContents.isLoading()) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (view.webContents.isLoading()) {
|
||||
if (signal?.aborted) {
|
||||
throw new Error("adapter_aborted");
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 120);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user