import { dirname } from "@std/path"; import { validateDocuments } from "./src/data.ts"; import { resolvePdfPath } from "./src/paths.ts"; import type { BuyerDocument } from "./src/types.ts"; import { type AppSettings, clampWindowSize, DEFAULTS, readLegacyConfig, resolveCacheDir, resolveSettingsPath, saveSettings as saveAppSettings, } from "./src/settings.ts"; import { cacheOrGetPdf } from "./src/pdf_cache.ts"; import INDEX_HTML from "./web/index.html" with { type: "text" }; import APP_JS from "./web/app.js" with { type: "text" }; import PDF_VIEWER_JS from "./web/pdf_viewer.js" with { type: "text" }; import STYLES_CSS from "./web/styles.css" with { type: "text" }; import SAMPLE_DOCUMENTS from "./sample-data/buyers_vision_anonymous.json" with { type: "json", }; import PDFJS_LIB_TEXT from "pdfjs-dist/legacy/build/pdf.min.mjs" with { type: "text", }; import PDFJS_WORKER_TEXT from "pdfjs-dist/legacy/build/pdf.worker.min.mjs" with { type: "text", }; // PDF.js >= 5 needs these auxiliary assets at runtime (fetched by the worker): // - wasm/: CCITT-G4 + JBIG2 (B/W scans), JPEG2000 and qcms (ICC) decoders // - standard_fonts/: base-14 font substitutes for PDFs without embedded fonts // - iccs/: default CMYK ICC profile // Embedded via import attributes so the compiled desktop binary stays self-contained. import WASM_JBIG2 from "pdfjs-dist/wasm/jbig2.wasm" with { type: "bytes" }; import WASM_OPENJPEG from "pdfjs-dist/wasm/openjpeg.wasm" with { type: "bytes", }; import WASM_QCMS from "pdfjs-dist/wasm/qcms_bg.wasm" with { type: "bytes" }; import JS_JBIG2_FALLBACK from "pdfjs-dist/wasm/jbig2_nowasm_fallback.js" with { type: "text", }; import JS_OPENJPEG_FALLBACK from "pdfjs-dist/wasm/openjpeg_nowasm_fallback.js" with { type: "text", }; import ICC_CGATS from "pdfjs-dist/iccs/CGATS001Compat-v2-micro.icc" with { type: "bytes", }; import FONT_DINGBATS from "pdfjs-dist/standard_fonts/FoxitDingbats.pfb" with { type: "bytes", }; import FONT_FIXED from "pdfjs-dist/standard_fonts/FoxitFixed.pfb" with { type: "bytes", }; import FONT_FIXED_B from "pdfjs-dist/standard_fonts/FoxitFixedBold.pfb" with { type: "bytes", }; import FONT_FIXED_BI from "pdfjs-dist/standard_fonts/FoxitFixedBoldItalic.pfb" with { type: "bytes", }; import FONT_FIXED_I from "pdfjs-dist/standard_fonts/FoxitFixedItalic.pfb" with { type: "bytes", }; import FONT_SERIF from "pdfjs-dist/standard_fonts/FoxitSerif.pfb" with { type: "bytes", }; import FONT_SERIF_B from "pdfjs-dist/standard_fonts/FoxitSerifBold.pfb" with { type: "bytes", }; import FONT_SERIF_BI from "pdfjs-dist/standard_fonts/FoxitSerifBoldItalic.pfb" with { type: "bytes", }; import FONT_SERIF_I from "pdfjs-dist/standard_fonts/FoxitSerifItalic.pfb" with { type: "bytes", }; import FONT_SYMBOL from "pdfjs-dist/standard_fonts/FoxitSymbol.pfb" with { type: "bytes", }; import FONT_LSANS from "pdfjs-dist/standard_fonts/LiberationSans-Regular.ttf" with { type: "bytes", }; import FONT_LSANS_B from "pdfjs-dist/standard_fonts/LiberationSans-Bold.ttf" with { type: "bytes", }; import FONT_LSANS_BI from "pdfjs-dist/standard_fonts/LiberationSans-BoldItalic.ttf" with { type: "bytes", }; import FONT_LSANS_I from "pdfjs-dist/standard_fonts/LiberationSans-Italic.ttf" with { type: "bytes", }; const PDFJS_ASSETS: Record< string, { body: Uint8Array | string; type: string } > = { "/pdfjs/wasm/jbig2.wasm": { body: WASM_JBIG2, type: "application/wasm" }, "/pdfjs/wasm/openjpeg.wasm": { body: WASM_OPENJPEG, type: "application/wasm", }, "/pdfjs/wasm/qcms_bg.wasm": { body: WASM_QCMS, type: "application/wasm" }, "/pdfjs/wasm/jbig2_nowasm_fallback.js": { body: JS_JBIG2_FALLBACK, type: "text/javascript; charset=utf-8", }, "/pdfjs/wasm/openjpeg_nowasm_fallback.js": { body: JS_OPENJPEG_FALLBACK, type: "text/javascript; charset=utf-8", }, "/pdfjs/iccs/CGATS001Compat-v2-micro.icc": { body: ICC_CGATS, type: "application/vnd.iccprofile", }, "/pdfjs/standard_fonts/FoxitDingbats.pfb": { body: FONT_DINGBATS, type: "application/octet-stream", }, "/pdfjs/standard_fonts/FoxitFixed.pfb": { body: FONT_FIXED, type: "application/octet-stream", }, "/pdfjs/standard_fonts/FoxitFixedBold.pfb": { body: FONT_FIXED_B, type: "application/octet-stream", }, "/pdfjs/standard_fonts/FoxitFixedBoldItalic.pfb": { body: FONT_FIXED_BI, type: "application/octet-stream", }, "/pdfjs/standard_fonts/FoxitFixedItalic.pfb": { body: FONT_FIXED_I, type: "application/octet-stream", }, "/pdfjs/standard_fonts/FoxitSerif.pfb": { body: FONT_SERIF, type: "application/octet-stream", }, "/pdfjs/standard_fonts/FoxitSerifBold.pfb": { body: FONT_SERIF_B, type: "application/octet-stream", }, "/pdfjs/standard_fonts/FoxitSerifBoldItalic.pfb": { body: FONT_SERIF_BI, type: "application/octet-stream", }, "/pdfjs/standard_fonts/FoxitSerifItalic.pfb": { body: FONT_SERIF_I, type: "application/octet-stream", }, "/pdfjs/standard_fonts/FoxitSymbol.pfb": { body: FONT_SYMBOL, type: "application/octet-stream", }, "/pdfjs/standard_fonts/LiberationSans-Regular.ttf": { body: FONT_LSANS, type: "font/ttf", }, "/pdfjs/standard_fonts/LiberationSans-Bold.ttf": { body: FONT_LSANS_B, type: "font/ttf", }, "/pdfjs/standard_fonts/LiberationSans-BoldItalic.ttf": { body: FONT_LSANS_BI, type: "font/ttf", }, "/pdfjs/standard_fonts/LiberationSans-Italic.ttf": { body: FONT_LSANS_I, type: "font/ttf", }, }; const PREFIX = "[BizMatch QC]"; let settings: AppSettings = await initializeSettings(); let documents: BuyerDocument[] = []; let loadError = ""; let dataSource: "sample" | "file" = "sample"; try { const loaded = await loadDocumentsForMode(); documents = loaded.documents; dataSource = loaded.source; } catch (error) { loadError = errorMessage(error); console.error(`${PREFIX} Initial data load failed: ${loadError}`); documents = validateDocuments(SAMPLE_DOCUMENTS); dataSource = "sample"; } async function initializeSettings(): Promise { const path = resolveSettingsPath(); console.log(`${PREFIX} Settings path: ${path}`); let result: AppSettings; let fileExisted = false; try { const content = await Deno.readTextFile(path); result = JSON.parse(content) as unknown as AppSettings; fileExisted = true; } catch { result = { ...DEFAULTS }; } const validated = { ...DEFAULTS }; if ( fileExisted && result && typeof result === "object" && !Array.isArray(result) ) { const obj = result as unknown as Record; if (typeof obj.jsonPath === "string") validated.jsonPath = obj.jsonPath; if (typeof obj.pdfBaseDirectory === "string") { validated.pdfBaseDirectory = obj.pdfBaseDirectory; } if (typeof obj.useAnonymousData === "boolean") { validated.useAnonymousData = obj.useAnonymousData; } if ( typeof obj.windowWidth === "number" && !isNaN(obj.windowWidth) && obj.windowWidth >= 1100 ) { validated.windowWidth = obj.windowWidth; } if ( typeof obj.windowHeight === "number" && !isNaN(obj.windowHeight) && obj.windowHeight >= 700 ) { validated.windowHeight = obj.windowHeight; } console.log(`${PREFIX} Settings loaded successfully.`); } else { console.log(`${PREFIX} No existing settings file found. Using defaults.`); } if (!fileExisted) { const legacy = await readLegacyConfig(); if (legacy) { validated.jsonPath = validated.jsonPath || legacy.jsonPath; validated.pdfBaseDirectory = validated.pdfBaseDirectory || legacy.pdfBaseDirectory; await saveAppSettings(validated); } } return validated; } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } async function loadDocumentsFromFile( jsonPath: string, ): Promise<{ documents: BuyerDocument[]; source: "file" }> { let raw: string; try { raw = await Deno.readTextFile(jsonPath); } catch (error) { throw new Error( `Cannot read JSON file "${jsonPath}": ${errorMessage(error)}`, ); } let parsed: unknown; try { parsed = JSON.parse(raw); } catch (error) { throw new Error(`Invalid JSON in "${jsonPath}": ${errorMessage(error)}`); } try { return { documents: validateDocuments(parsed), source: "file" }; } catch (error) { throw new Error( `JSON validation failed for "${jsonPath}": ${errorMessage(error)}`, ); } } async function loadDocumentsForMode(): Promise< { documents: BuyerDocument[]; source: "sample" | "file" } > { if (settings.useAnonymousData) { return { documents: validateDocuments(SAMPLE_DOCUMENTS), source: "sample" }; } if (!settings.jsonPath.trim()) { return { documents: validateDocuments(SAMPLE_DOCUMENTS), source: "sample" }; } return await loadDocumentsFromFile(settings.jsonPath); } async function applySettings( next: { jsonPath?: string; pdfBaseDirectory?: string; useAnonymousData?: boolean; }, ): Promise { const newSettings: AppSettings = { ...settings, jsonPath: next.jsonPath !== undefined ? next.jsonPath.trim() : settings.jsonPath, pdfBaseDirectory: next.pdfBaseDirectory !== undefined ? next.pdfBaseDirectory.trim() : settings.pdfBaseDirectory, useAnonymousData: next.useAnonymousData !== undefined ? next.useAnonymousData : settings.useAnonymousData, }; if (newSettings.useAnonymousData) { const docs = validateDocuments(SAMPLE_DOCUMENTS); settings = newSettings; documents = docs; dataSource = "sample"; loadError = ""; await saveAppSettings(settings); console.log( `${PREFIX} Switched to anonymized sample data (${docs.length} documents).`, ); return; } if (!newSettings.jsonPath.trim()) { settings = newSettings; documents = validateDocuments(SAMPLE_DOCUMENTS); dataSource = "sample"; loadError = ""; await saveAppSettings(settings); console.log(`${PREFIX} No JSON path configured; using sample data.`); return; } const loaded = await loadDocumentsFromFile(newSettings.jsonPath); if (newSettings.pdfBaseDirectory) { try { const stat = await Deno.stat(newSettings.pdfBaseDirectory); if (!stat.isDirectory) { throw new Error("Path exists but is not a directory."); } } catch (error) { throw new Error( `Cannot access PDF base directory "${newSettings.pdfBaseDirectory}": ${ errorMessage(error) }`, ); } } await saveAppSettings(newSettings); settings = newSettings; documents = loaded.documents; dataSource = loaded.source; loadError = ""; console.log( `${PREFIX} Loaded ${documents.length} documents from ${settings.jsonPath}.`, ); if (settings.pdfBaseDirectory) { console.log(`${PREFIX} PDF base directory: ${settings.pdfBaseDirectory}`); } } function json(data: unknown, status = 200) { return Response.json(data, { status, headers: { "cache-control": "no-store" }, }); } // ---- PDF token system ---- // A token stores a resolved cache path so that Range requests // (which PDF.js makes many of) hit the local disk cache directly // without re-stat'ing or re-validating against NAS for each one. interface PdfToken { cachePath: string; sourcePath: string; fileSize: number; stale: boolean; createdAt: number; } const pdfTokens = new Map(); const TOKEN_TTL_MS = 30 * 60 * 1000; // 30 minutes function cleanupTokens() { const now = Date.now(); for (const [token, entry] of pdfTokens) { if (now - entry.createdAt > TOKEN_TTL_MS) pdfTokens.delete(token); } } // Periodic cleanup every 10 minutes setInterval(cleanupTokens, 10 * 60 * 1000); async function servePdfBytes( cachePath: string, fileSize: number, rangeHeader: string | null, elapsedStart: number, stale: boolean, ): Promise { if (rangeHeader) { const match = rangeHeader.match(/^bytes=(\d+)-(\d*)$/); if (match) { const rangeStart = parseInt(match[1], 10); const rangeEnd = match[2] ? parseInt(match[2], 10) : fileSize - 1; const length = rangeEnd - rangeStart + 1; if (rangeStart < 0 || rangeEnd >= fileSize || rangeStart > rangeEnd) { return new Response(null, { status: 416, headers: { "content-range": `bytes */${fileSize}` }, }); } const file = await Deno.open(cachePath, { read: true }); await file.seek(rangeStart, Deno.SeekMode.Start); const buf = new Uint8Array(length); let bytesRead = 0; while (bytesRead < length) { const n = await file.read(buf.subarray(bytesRead)); if (n === null) break; bytesRead += n; } file.close(); const elapsed = Math.round(performance.now() - elapsedStart); console.log( `${PREFIX} PDF range served from local cache: bytes ${rangeStart}-${ rangeStart + bytesRead - 1 }/${fileSize} in ${elapsed}ms`, ); return new Response(buf.slice(0, bytesRead), { status: 206, headers: { "content-type": "application/pdf", "content-length": String(bytesRead), "content-range": `bytes ${rangeStart}-${ rangeStart + bytesRead - 1 }/${fileSize}`, "accept-ranges": "bytes", ...(stale ? { "x-bizmatch-cache-status": "stale" } : {}), }, }); } } const file = await Deno.open(cachePath, { read: true }); const elapsed = Math.round(performance.now() - elapsedStart); console.log( `${PREFIX} PDF full served from local cache (${fileSize}B) in ${elapsed}ms`, ); return new Response(file.readable, { headers: { "content-type": "application/pdf", "content-length": String(fileSize), "accept-ranges": "bytes", ...(stale ? { "x-bizmatch-cache-status": "stale" } : {}), }, }); } // ------- HTTP Server ------- Deno.serve(async (request) => { const url = new URL(request.url); if (url.pathname === "/api/state" && request.method === "GET") { const configForClient = { jsonPath: settings.jsonPath, pdfBaseDirectory: settings.pdfBaseDirectory, useAnonymousData: settings.useAnonymousData, }; return json({ config: configForClient, documents, loadError, dataSource }); } if (url.pathname === "/api/config" && request.method === "POST") { try { interface ConfigBody { jsonPath?: string; pdfBaseDirectory?: string; useAnonymousData?: boolean; } const body = await request.json() as ConfigBody; await applySettings({ jsonPath: body.jsonPath ?? settings.jsonPath, pdfBaseDirectory: body.pdfBaseDirectory ?? settings.pdfBaseDirectory, useAnonymousData: body.useAnonymousData ?? settings.useAnonymousData, }); return json({ ok: true, count: documents.length, dataSource }); } catch (error) { const message = errorMessage(error); loadError = message; console.error(`${PREFIX} Settings rejected: ${message}`); return json({ error: message }, 400); } } // ---- PDF prepare endpoint ---- if (url.pathname === "/api/pdf/prepare" && request.method === "POST") { try { const body = await request.json() as { index?: number; requestId?: string; }; const index = body.index; const doc = documents[index!]; if (!Number.isInteger(index) || !doc) { return json({ error: "Unknown document index." }, 404); } const sourcePath = resolvePdfPath(settings.pdfBaseDirectory, doc); const relative = doc._letter + "/" + doc.file_name; const reqTag = body.requestId ? `PDF ${body.requestId}` : "PDF"; console.log(`${PREFIX} ${reqTag} prepare requested: ${relative}`); const startTime = performance.now(); const cacheResult = await cacheOrGetPdf(sourcePath); let fileSize: number; try { fileSize = (await Deno.stat(cacheResult.path)).size; } catch { throw new Error("Cached file is not readable."); } const token = crypto.randomUUID(); pdfTokens.set(token, { cachePath: cacheResult.path, sourcePath, fileSize, stale: cacheResult.stale, createdAt: Date.now(), }); const elapsed = Math.round(performance.now() - startTime); const status = cacheResult.stale ? "refresh" : "hit"; console.log( `${PREFIX} ${reqTag} disk cache ${status} in ${elapsed}ms, token created`, ); return json({ url: `/api/pdf/content/${token}`, size: fileSize, cacheStatus: status, }); } catch (error) { const message = errorMessage(error); console.error(`${PREFIX} PDF prepare failed: ${message}`); return json({ error: message }, 404); } } // ---- PDF content endpoint (range-aware, cache-only) ---- if ( url.pathname.startsWith("/api/pdf/content/") && request.method === "GET" ) { const token = url.pathname.slice("/api/pdf/content/".length); const entry = pdfTokens.get(token); if (!entry) { return json({ error: "Invalid or expired PDF token." }, 404); } const startTime = performance.now(); try { return await servePdfBytes( entry.cachePath, entry.fileSize, request.headers.get("range"), startTime, entry.stale, ); } catch (error) { const message = errorMessage(error); console.error(`${PREFIX} PDF content serve failed: ${message}`); return json({ error: message }, 500); } } // ---- HEAD request for pdf content ---- if ( url.pathname.startsWith("/api/pdf/content/") && request.method === "HEAD" ) { const token = url.pathname.slice("/api/pdf/content/".length); const entry = pdfTokens.get(token); if (!entry) { return new Response(null, { status: 404 }); } return new Response(null, { status: 200, headers: { "content-type": "application/pdf", "content-length": String(entry.fileSize), "accept-ranges": "bytes", }, }); } // ---- PDF.js auxiliary assets (wasm decoders, standard fonts, ICC) ---- const pdfjsAsset = PDFJS_ASSETS[url.pathname]; if (pdfjsAsset) { return new Response(pdfjsAsset.body as BodyInit, { headers: { "content-type": pdfjsAsset.type, "cache-control": "public, max-age=86400", }, }); } // ---- PDF.js library assets ---- if (url.pathname === "/pdfjs/legacy/pdf.min.mjs") { return new Response(PDFJS_LIB_TEXT, { headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "public, max-age=86400", }, }); } if (url.pathname === "/pdfjs/legacy/pdf.worker.min.mjs") { return new Response(PDFJS_WORKER_TEXT, { headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "public, max-age=86400", }, }); } // ---- Static files ---- const staticFiles: Record = { "/": { body: INDEX_HTML, type: "text/html; charset=utf-8" }, "/index.html": { body: INDEX_HTML, type: "text/html; charset=utf-8" }, "/app.js": { body: APP_JS, type: "text/javascript; charset=utf-8" }, "/pdf_viewer.js": { body: PDF_VIEWER_JS, type: "text/javascript; charset=utf-8", }, "/styles.css": { body: STYLES_CSS, type: "text/css; charset=utf-8" }, }; const file = staticFiles[url.pathname]; if (file) { return new Response(file.body, { headers: { "content-type": file.type, "cache-control": "no-store" }, }); } return new Response("Not found", { status: 404 }); }); // ------- Startup logging ------- console.log(`${PREFIX} Settings path: ${resolveSettingsPath()}`); console.log(`${PREFIX} PDF cache directory: ${resolveCacheDir()}`); console.log( `${PREFIX} Data mode: ${ settings.useAnonymousData ? "anonymized sample" : (settings.jsonPath ? `file (${settings.jsonPath})` : "embedded sample") }`, ); console.log(`${PREFIX} Documents loaded: ${documents.length}`); console.log(`${PREFIX} PDF.js version: 6.1.200`); if (loadError) console.error(`${PREFIX} ${loadError}`); // ------- Window management ------- const clamped = clampWindowSize(settings); console.log( `${PREFIX} Settings window size loaded: ${clamped.windowWidth}x${clamped.windowHeight}`, ); console.log( `${PREFIX} Creating main window with: ${clamped.windowWidth}x${clamped.windowHeight}`, ); // Deno 2.9 Desktop API: use BrowserWindow constructor with width/height, // then verify with getSize() and apply setSize() if needed. type _Win = { getSize(): [number, number]; setSize(w: number, h: number): void; addEventListener( type: string, listener: (event: CustomEvent) => void, ): void; show(): void; }; const _WinCtor = (Deno as unknown as { BrowserWindow: new ( opts?: { title?: string; width?: number; height?: number }, ) => _Win; }).BrowserWindow; const win = new _WinCtor({ title: "BizMatch QC", width: clamped.windowWidth, height: clamped.windowHeight, }); const [actualW, actualH] = win.getSize(); console.log( `${PREFIX} Main window actual size after construction: ${actualW}x${actualH}`, ); if ( actualW !== clamped.windowWidth || actualH !== clamped.windowHeight ) { console.log( `${PREFIX} Window size differs from requested, applying setSize(${clamped.windowWidth}, ${clamped.windowHeight})`, ); win.setSize(clamped.windowWidth, clamped.windowHeight); const [w2, h2] = win.getSize(); console.log(`${PREFIX} After setSize: ${w2}x${h2}`); } // Native resize tracking using Deno 2.9 BrowserWindow events let resizeTimer: ReturnType | undefined; try { win.addEventListener( "resize", ((e: CustomEvent) => { if (resizeTimer !== undefined) clearTimeout(resizeTimer); // Store immediately but debounce writes const w = e.detail?.width ?? 0; const h = e.detail?.height ?? 0; if (w >= 1100 && h >= 700) { settings.windowWidth = w; settings.windowHeight = h; } resizeTimer = setTimeout(() => { if (settings.windowWidth >= 1100 && settings.windowHeight >= 700) { console.log( `${PREFIX} Window resized: ${settings.windowWidth}x${settings.windowHeight}`, ); saveAppSettings(settings).catch((err) => console.error( `${PREFIX} Failed to save window size: ${errorMessage(err)}`, ) ); } }, 500); }) as (e: Event) => void, ); win.addEventListener( "close", (() => { if (resizeTimer !== undefined) clearTimeout(resizeTimer); const [fw, fh] = win.getSize(); if (fw >= 1100 && fh >= 700) { settings.windowWidth = fw; settings.windowHeight = fh; } try { const path = resolveSettingsPath(); Deno.mkdirSync(dirname(path), { recursive: true }); Deno.writeTextFileSync(path, JSON.stringify(settings, null, 2)); console.log( `${PREFIX} Window size saved on close: ${settings.windowWidth}x${settings.windowHeight}`, ); } catch (err) { console.error( `${PREFIX} Failed to save settings on close: ${errorMessage(err)}`, ); } }) as (e: Event) => void, ); } catch (err) { console.warn( `${PREFIX} Native window-event binding unavailable: ${errorMessage(err)}`, ); } win.show(); // Workaround: with the experimental desktop backend the first BrowserWindow // "adopts" the implicit startup window, which is created before user code // runs. Sometimes the native window keeps its built-in default size even // though getSize() already reports the requested values, so the early // setSize-on-mismatch above is skipped. Enforce the persisted size // unconditionally after show(), and verify once more shortly after. try { win.setSize(clamped.windowWidth, clamped.windowHeight); setTimeout(() => { try { const [w, h] = win.getSize(); if (w !== clamped.windowWidth || h !== clamped.windowHeight) { console.log( `${PREFIX} Window size drifted to ${w}x${h}, enforcing ${clamped.windowWidth}x${clamped.windowHeight}`, ); win.setSize(clamped.windowWidth, clamped.windowHeight); } } catch { /* ok */ } }, 250); } catch (err) { console.warn( `${PREFIX} Post-show size enforcement failed: ${errorMessage(err)}`, ); }