import { spawn } from "node:child_process"; import { BrowserWindow, ipcMain } from "electron"; import { buildInstalledSourceMap, mergeUpdateSources, parseApmUpgradableOutput, parseAptssUpgradableOutput, parsePrintUrisOutput, } from "./query"; import { resolveUpdateItemIcons } from "./icons"; import { createUpdateCenterService, type StoreFilter, type UpdateCenterIgnorePayload, type UpdateCenterService, type UpdateCenterStartTask, } from "./service"; import type { UpdateCenterItem } from "./types"; export interface UpdateCenterCommandResult { code: number; stdout: string; stderr: string; } export type UpdateCenterCommandRunner = ( command: string, args: string[], ) => Promise; export interface UpdateCenterLoadItemsResult { items: UpdateCenterItem[]; warnings: string[]; } interface RemoteAppMetadata { category: string; name?: string; } type StoreAppMetadataMap = Map; interface RemoteCategoryAppEntry { Name?: string; Pkgname?: string; } const REMOTE_STORE_BASE_URL = "https://erotica.spark-app.store"; const categoryCache = new Map>(); const APTSS_LIST_UPGRADABLE_COMMAND = { command: "bash", args: [ "-lc", "env LANGUAGE=en_US /usr/bin/apt -c /opt/durapps/spark-store/bin/apt-fast-conf/aptss-apt.conf list --upgradable -o Dir::Etc::sourcelist=/opt/durapps/spark-store/bin/apt-fast-conf/sources.list.d/aptss.list -o Dir::Etc::sourceparts=/dev/null -o APT::Get::List-Cleanup=0 | awk 'NR>1'", ], }; const DPKG_QUERY_INSTALLED_COMMAND = { command: "dpkg-query", args: [ "-W", "-f=${Package}\t${db:Status-Want} ${db:Status-Status} ${db:Status-Eflag}\n", ], }; const getApmPrintUrisCommand = (pkgname: string) => ({ command: "bash", args: [ "-lc", `amber-pm-debug /usr/bin/apt -c /opt/durapps/spark-store/bin/apt-fast-conf/aptss-apt.conf download ${pkgname} --print-uris`, ], }); const getAptssPrintUrisCommand = (pkgname: string) => ({ command: "bash", args: [ "-lc", `/usr/bin/apt download ${pkgname} --print-uris -c /opt/durapps/spark-store/bin/apt-fast-conf/aptss-apt.conf -o Dir::Etc::sourcelist=/opt/durapps/spark-store/bin/apt-fast-conf/sources.list.d/aptss.list -o Dir::Etc::sourceparts=/dev/null`, ], }); const runCommandCapture: UpdateCenterCommandRunner = async ( command, args, ): Promise => await new Promise((resolve) => { const child = spawn(command, args, { shell: false, env: process.env, }); let stdout = ""; let stderr = ""; child.stdout?.on("data", (data) => { stdout += data.toString(); }); child.stderr?.on("data", (data) => { stderr += data.toString(); }); child.on("error", (error) => { resolve({ code: -1, stdout, stderr: error.message }); }); child.on("close", (code) => { resolve({ code: code ?? -1, stdout, stderr }); }); }); const getCommandError = ( label: string, result: UpdateCenterCommandResult, ): string | null => { if (result.code === 0) { return null; } return `${label} failed: ${result.stderr || result.stdout || `exit code ${result.code}`}`; }; const loadApmItemMetadata = async ( item: UpdateCenterItem, runCommand: UpdateCenterCommandRunner, ): Promise< | { item: UpdateCenterItem; warning?: undefined } | { item: null; warning: string } > => { const printUrisCommand = getApmPrintUrisCommand(item.pkgname); const metadataResult = await runCommand( printUrisCommand.command, printUrisCommand.args, ); const commandError = getCommandError( `apm metadata query for ${item.pkgname}`, metadataResult, ); if (commandError) { return { item: null, warning: commandError }; } const metadata = parsePrintUrisOutput(metadataResult.stdout); if (!metadata) { return { item: null, warning: `apm metadata query for ${item.pkgname} returned no package metadata`, }; } return { item: { ...item, ...metadata, }, }; }; const loadAptssItemMetadata = async ( item: UpdateCenterItem, runCommand: UpdateCenterCommandRunner, ): Promise< | { item: UpdateCenterItem; warning?: undefined } | { item: null; warning: string } > => { console.log(`[DEBUG] Loading APTSS metadata for ${item.pkgname}`); const printUrisCommand = getAptssPrintUrisCommand(item.pkgname); console.log( `[DEBUG] APTSS command: ${printUrisCommand.command} ${printUrisCommand.args.join(" ")}`, ); const metadataResult = await runCommand( printUrisCommand.command, printUrisCommand.args, ); console.log(`[DEBUG] APTSS metadata result code: ${metadataResult.code}`); console.log( `[DEBUG] APTSS metadata stdout: ${metadataResult.stdout.substring(0, 500)}`, ); console.log( `[DEBUG] APTSS metadata stderr: ${metadataResult.stderr.substring(0, 500)}`, ); const commandError = getCommandError( `aptss metadata query for ${item.pkgname}`, metadataResult, ); if (commandError) { console.log(`[DEBUG] APTSS metadata error: ${commandError}`); return { item: null, warning: commandError }; } const metadata = parsePrintUrisOutput(metadataResult.stdout); if (metadata) { console.log(`[DEBUG] APTSS parsed metadata:`, { ...metadata, downloadUrl: `${metadata.downloadUrl}.metalink`, }); } else { console.log(`[DEBUG] APTSS parsed metadata:`, metadata); } if (!metadata) { return { item: null, warning: `aptss metadata query for ${item.pkgname} returned no package metadata`, }; } return { item: { ...item, ...metadata, }, }; }; const enrichApmItems = async ( items: UpdateCenterItem[], runCommand: UpdateCenterCommandRunner, ): Promise => { const results = await Promise.all( items.map((item) => loadApmItemMetadata(item, runCommand)), ); return { items: results.flatMap((result) => (result.item ? [result.item] : [])), warnings: results.flatMap((result) => result.warning ? [result.warning] : [], ), }; }; const enrichAptssItems = async ( items: UpdateCenterItem[], runCommand: UpdateCenterCommandRunner, ): Promise => { const results = await Promise.all( items.map((item) => loadAptssItemMetadata(item, runCommand)), ); return { items: results.flatMap((result) => (result.item ? [result.item] : [])), warnings: results.flatMap((result) => result.warning ? [result.warning] : [], ), }; }; const getStoreArch = ( item: Pick, ): string => { const arch = item.arch; if (!arch) { return ""; } if (arch.includes("-")) { return arch; } return `${arch}-${item.source === "aptss" ? "store" : "apm"}`; }; const loadJson = async (url: string): Promise => { const response = await fetch(url); if (!response.ok) { throw new Error(`Request failed for ${url}`); } return (await response.json()) as T; }; const loadStoreCategoryMap = async ( storeArch: string, ): Promise => { const categories = await loadJson>( `${REMOTE_STORE_BASE_URL}/${storeArch}/categories.json`, ); const categoryEntries = await Promise.allSettled( Object.keys(categories).map(async (category) => { const apps = await loadJson( `${REMOTE_STORE_BASE_URL}/${storeArch}/${category}/applist.json`, ); return { apps, category }; }), ); const categoryMap: StoreAppMetadataMap = new Map(); for (const entry of categoryEntries) { if (entry.status !== "fulfilled") { continue; } for (const app of entry.value.apps) { if (app.Pkgname && !categoryMap.has(app.Pkgname)) { categoryMap.set(app.Pkgname, { category: entry.value.category, name: app.Name, }); } } } return categoryMap; }; const getStoreCategoryMap = ( storeArch: string, ): Promise => { const cached = categoryCache.get(storeArch); if (cached) { return cached; } const pending = loadStoreCategoryMap(storeArch).catch(() => { categoryCache.delete(storeArch); return new Map(); }); categoryCache.set(storeArch, pending); return pending; }; const enrichItemCategories = async ( items: UpdateCenterItem[], ): Promise => { return await Promise.all( items.map(async (item) => { if (item.category) { return item; } const storeArch = getStoreArch(item); if (!storeArch) { return item; } const categoryMap = await getStoreCategoryMap(storeArch); const metadata = categoryMap.get(item.pkgname); return metadata ? { ...item, category: metadata.category, ...(metadata.name ? { name: metadata.name } : {}), } : item; }), ); }; const enrichItemIcons = (items: UpdateCenterItem[]): UpdateCenterItem[] => { return items.map((item) => { const icons = resolveUpdateItemIcons(item); return Object.keys(icons).length > 0 ? { ...item, ...icons } : item; }); }; const isSourceEnabled = ( storeFilter: StoreFilter, source: "spark" | "apm", ): boolean => { return storeFilter === "both" || storeFilter === source; }; const isCommandAvailable = async ( runCommand: UpdateCenterCommandRunner, command: "aptss" | "apm", ): Promise => { const result = await runCommand("which", [command]); return result.code === 0 && result.stdout.trim().length > 0; }; export const loadUpdateCenterItems = async ( storeFilter: StoreFilter = "both", runCommand: UpdateCenterCommandRunner = runCommandCapture, ): Promise => { console.log(`[UpdateCenter] loadUpdateCenterItems called with storeFilter=${storeFilter}`); const [sparkEnabled, apmEnabled] = await Promise.all([ isSourceEnabled(storeFilter, "spark") ? isCommandAvailable(runCommand, "aptss") : Promise.resolve(false), isSourceEnabled(storeFilter, "apm") ? isCommandAvailable(runCommand, "apm") : Promise.resolve(false), ]); console.log(`[UpdateCenter] sparkEnabled=${sparkEnabled}, apmEnabled=${apmEnabled}`); const [aptssResult, apmResult, aptssInstalledResult, apmInstalledResult] = await Promise.all([ sparkEnabled ? runCommand( APTSS_LIST_UPGRADABLE_COMMAND.command, APTSS_LIST_UPGRADABLE_COMMAND.args, ) : Promise.resolve({ code: 0, stdout: "", stderr: "" }), apmEnabled ? runCommand("apm", ["list", "--upgradable"]) : Promise.resolve({ code: 0, stdout: "", stderr: "" }), sparkEnabled ? runCommand( DPKG_QUERY_INSTALLED_COMMAND.command, DPKG_QUERY_INSTALLED_COMMAND.args, ) : Promise.resolve({ code: 0, stdout: "", stderr: "" }), apmEnabled ? runCommand("apm", ["list", "--installed"]) : Promise.resolve({ code: 0, stdout: "", stderr: "" }), ]); console.log(`[UpdateCenter] aptssResult: code=${aptssResult.code}, stdout=${aptssResult.stdout.substring(0, 500)}, stderr=${aptssResult.stderr.substring(0, 500)}`); console.log(`[UpdateCenter] apmResult: code=${apmResult.code}, stdout=${apmResult.stdout.substring(0, 500)}, stderr=${apmResult.stderr.substring(0, 500)}`); console.log(`[UpdateCenter] aptssInstalledResult: code=${aptssInstalledResult.code}, stdout=${aptssInstalledResult.stdout.substring(0, 500)}`); console.log(`[UpdateCenter] apmInstalledResult: code=${apmInstalledResult.code}, stdout=${apmInstalledResult.stdout.substring(0, 500)}`); const aptssAvailable = sparkEnabled && (aptssResult.code === 0 || aptssInstalledResult.code === 0); const warnings = [ aptssAvailable ? getCommandError("aptss upgradable query", aptssResult) : null, apmEnabled ? getCommandError("apm upgradable query", apmResult) : null, aptssAvailable ? getCommandError("dpkg installed query", aptssInstalledResult) : null, apmEnabled ? getCommandError("apm installed query", apmInstalledResult) : null, ].filter((message): message is string => message !== null); const aptssItems = aptssAvailable && aptssResult.code === 0 ? parseAptssUpgradableOutput(aptssResult.stdout) : []; const apmItems = apmEnabled && apmResult.code === 0 ? parseApmUpgradableOutput(apmResult.stdout) : []; console.log(`[UpdateCenter] parsed aptssItems count=${aptssItems.length}`, aptssItems.map((i) => `${i.pkgname} ${i.currentVersion}->${i.nextVersion}`)); console.log(`[UpdateCenter] parsed apmItems count=${apmItems.length}`, apmItems.map((i) => `${i.pkgname} ${i.currentVersion}->${i.nextVersion}`)); const installedSources = buildInstalledSourceMap( aptssAvailable && aptssInstalledResult.code === 0 ? aptssInstalledResult.stdout : "", apmInstalledResult.code === 0 ? apmInstalledResult.stdout : "", ); console.log(`[UpdateCenter] installedSources size=${installedSources.size}`); const [categorizedAptssItems, categorizedApmItems] = await Promise.all([ aptssAvailable ? enrichItemCategories(aptssItems) : Promise.resolve([]), apmEnabled ? enrichItemCategories(apmItems) : Promise.resolve([]), ]); const [enrichedAptssItems, enrichedApmItems] = await Promise.all([ aptssAvailable ? enrichAptssItems(categorizedAptssItems, runCommand) : Promise.resolve({ items: [], warnings: [] }), apmEnabled ? enrichApmItems(categorizedApmItems, runCommand) : Promise.resolve({ items: [], warnings: [] }), ]); console.log(`[UpdateCenter] enrichedAptssItems: count=${enrichedAptssItems.items.length}, warnings=${enrichedAptssItems.warnings.length}`, enrichedAptssItems.warnings); console.log(`[UpdateCenter] enrichedApmItems: count=${enrichedApmItems.items.length}, warnings=${enrichedApmItems.warnings.length}`, enrichedApmItems.warnings); const mergedItems = mergeUpdateSources( enrichItemIcons(enrichedAptssItems.items), enrichItemIcons(enrichedApmItems.items), installedSources, ); console.log(`[UpdateCenter] mergedItems count=${mergedItems.length}`, mergedItems.map((i) => `${i.pkgname} (${i.source}) ${i.currentVersion}->${i.nextVersion}`)); return { items: mergedItems, warnings: [ ...warnings, ...enrichedAptssItems.warnings, ...enrichedApmItems.warnings, ], }; }; export const registerUpdateCenterIpc = ( ipc: Pick, service: Pick< UpdateCenterService, | "open" | "refresh" | "ignore" | "unignore" | "start" | "cancel" | "getState" | "subscribe" >, ): void => { ipc.handle( "update-center-open", (_event, storeFilter: StoreFilter = "both") => service.open(storeFilter), ); ipc.handle( "update-center-refresh", (_event, storeFilter: StoreFilter = "both") => service.refresh(storeFilter), ); ipc.handle( "update-center-ignore", (_event, payload: UpdateCenterIgnorePayload) => service.ignore(payload), ); ipc.handle( "update-center-unignore", (_event, payload: UpdateCenterIgnorePayload) => service.unignore(payload), ); ipc.handle("update-center-start", (_event, tasks: UpdateCenterStartTask[]) => service.start(tasks), ); ipc.handle("update-center-cancel", (_event, taskKey: string) => service.cancel(taskKey), ); ipc.handle("update-center-get-state", () => service.getState()); service.subscribe((snapshot) => { for (const win of BrowserWindow.getAllWindows()) { win.webContents.send("update-center-state", snapshot); } }); }; let updateCenterService: UpdateCenterService | null = null; export const initializeUpdateCenter = (): UpdateCenterService => { if (updateCenterService) { return updateCenterService; } updateCenterService = createUpdateCenterService({ loadItems: loadUpdateCenterItems, }); registerUpdateCenterIpc(ipcMain, updateCenterService); return updateCenterService; }; export { createUpdateCenterService } from "./service";