diff --git a/docs/superpowers/plans/2026-04-09-electron-update-center.md b/docs/superpowers/plans/2026-04-09-electron-update-center.md new file mode 100644 index 00000000..f385faae --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-electron-update-center.md @@ -0,0 +1,1978 @@ +# Electron Update Center Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the external Qt updater with an Electron-native update center that preserves the current Spark update behavior, data compatibility, and migration flow. + +**Architecture:** Build a dedicated `electron/main/backend/update-center/` subsystem for refresh, query, ignore-config compatibility, download/install execution, and IPC snapshots. Keep renderer concerns in a separate `src/modules/updateCenter.ts` store plus focused Vue components so the update center UI can match the existing store design without reusing the old thin APM-only modal. + +**Tech Stack:** Electron 40, Node.js `child_process`/`fs`/`path`, Vue 3 ` +``` + +```vue + + + + +``` + +```ts +// src/App.vue (script snippet) +import { onBeforeUnmount, onMounted } from "vue"; + +import UpdateCenterModal from "./components/UpdateCenterModal.vue"; +import { createUpdateCenterStore } from "./modules/updateCenter"; + +const updateCenter = createUpdateCenterStore(); + +onMounted(() => { + updateCenter.bind(); +}); + +onBeforeUnmount(() => { + updateCenter.unbind(); +}); + +const handleUpdate = async () => { + await updateCenter.open(); +}; +``` + +```vue + + + + +``` + +```vue + + + + +``` + +```vue + + + + +``` + +```vue + + + + +``` + +```vue + + + + +``` + +```vue + + +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test -- --run src/__tests__/unit/update-center/UpdateCenterModal.test.ts` + +Expected: PASS with 1 test passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/UpdateCenterModal.vue src/components/update-center/UpdateCenterToolbar.vue src/components/update-center/UpdateCenterList.vue src/components/update-center/UpdateCenterItem.vue src/components/update-center/UpdateCenterMigrationConfirm.vue src/components/update-center/UpdateCenterCloseConfirm.vue src/components/update-center/UpdateCenterLogPanel.vue src/App.vue src/__tests__/unit/update-center/UpdateCenterModal.test.ts +git rm src/components/UpdateAppsModal.vue +git commit -m "feat(update-center): add integrated update center modal" +``` + +### Task 7: Format, Verify, and Build the Integrated Update Center + +**Files:** +- Modify: `electron/main/backend/update-center/query.ts` +- Modify: `electron/main/backend/update-center/ignore-config.ts` +- Modify: `electron/main/backend/update-center/queue.ts` +- Modify: `electron/main/backend/update-center/download.ts` +- Modify: `electron/main/backend/update-center/install.ts` +- Modify: `electron/main/backend/update-center/service.ts` +- Modify: `electron/main/backend/update-center/index.ts` +- Modify: `src/components/UpdateCenterModal.vue` +- Modify: `src/components/update-center/UpdateCenterToolbar.vue` +- Modify: `src/components/update-center/UpdateCenterList.vue` +- Modify: `src/components/update-center/UpdateCenterItem.vue` +- Modify: `src/components/update-center/UpdateCenterMigrationConfirm.vue` +- Modify: `src/components/update-center/UpdateCenterCloseConfirm.vue` +- Modify: `src/components/update-center/UpdateCenterLogPanel.vue` +- Modify: `src/modules/updateCenter.ts` +- Modify: `src/App.vue` + +- [ ] **Step 1: Format the changed files** + +Run: `npm run format` + +Expected: Prettier rewrites the changed `src/` and `electron/` files without errors. + +- [ ] **Step 2: Run lint and the complete unit suite** + +Run: `npm run lint && npm run test -- --run` + +Expected: ESLint exits 0 and Vitest reports all unit tests passing, including the new `update-center` tests. + +- [ ] **Step 3: Run the production renderer build** + +Run: `npm run build:vite` + +Expected: `vue-tsc` and Vite finish successfully, producing updated `dist/` and `dist-electron/` assets without type errors. + +- [ ] **Step 4: Commit the final verified integration** + +```bash +git add electron/main/backend/update-center electron/main/index.ts electron/main/backend/install-manager.ts electron/preload/index.ts src/vite-env.d.ts src/global/typedefinition.ts src/modules/updateCenter.ts src/components/UpdateCenterModal.vue src/components/update-center src/App.vue src/__tests__/unit/update-center +git commit -m "feat(update-center): embed spark updates into electron" +``` diff --git a/electron/main/backend/install-manager.ts b/electron/main/backend/install-manager.ts index 9035912a..b79b75f0 100644 --- a/electron/main/backend/install-manager.ts +++ b/electron/main/backend/install-manager.ts @@ -31,7 +31,7 @@ export const tasks = new Map(); let idle = true; // Indicates if the installation manager is idle -const checkSuperUserCommand = async (): Promise => { +export const checkSuperUserCommand = async (): Promise => { let superUserCmd = ""; const execAsync = promisify(exec); if (process.getuid && process.getuid() !== 0) { @@ -251,8 +251,9 @@ ipcMain.on("queue-install", async (event, download_json) => { if (origin === "spark") { // Spark Store logic if (upgradeOnly) { - execCommand = "pkexec"; - execParams.push("spark-update-tool", pkgname); + execCommand = superUserCmd || SHELL_CALLER_PATH; + if (superUserCmd) execParams.push(SHELL_CALLER_PATH); + execParams.push("aptss", "install", "-y", pkgname, "--only-upgrade"); } else { execCommand = superUserCmd || SHELL_CALLER_PATH; if (superUserCmd) execParams.push(SHELL_CALLER_PATH); diff --git a/electron/main/backend/update-center/download.ts b/electron/main/backend/update-center/download.ts new file mode 100644 index 00000000..42000bf7 --- /dev/null +++ b/electron/main/backend/update-center/download.ts @@ -0,0 +1,87 @@ +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { spawn } from "node:child_process"; + +import type { UpdateCenterItem } from "./types"; + +export interface Aria2DownloadResult { + filePath: string; +} + +export interface RunAria2DownloadOptions { + item: UpdateCenterItem; + downloadDir: string; + onProgress?: (progress: number) => void; + onLog?: (message: string) => void; + signal?: AbortSignal; +} + +const PROGRESS_PATTERN = /(\d{1,3}(?:\.\d+)?)%/; + +export const runAria2Download = async ({ + item, + downloadDir, + onProgress, + onLog, + signal, +}: RunAria2DownloadOptions): Promise => { + if (!item.downloadUrl || !item.fileName) { + throw new Error(`Missing download metadata for ${item.pkgname}`); + } + + await mkdir(downloadDir, { recursive: true }); + + const filePath = join(downloadDir, item.fileName); + + await new Promise((resolve, reject) => { + const child = spawn("aria2c", [ + "--dir", + downloadDir, + "--out", + item.fileName, + item.downloadUrl, + ]); + + const abortDownload = () => { + child.kill(); + reject(new Error(`Update task cancelled: ${item.pkgname}`)); + }; + + if (signal?.aborted) { + abortDownload(); + return; + } + + signal?.addEventListener("abort", abortDownload, { once: true }); + + const handleOutput = (chunk: Buffer) => { + const message = chunk.toString().trim(); + if (!message) { + return; + } + + onLog?.(message); + const progressMatch = message.match(PROGRESS_PATTERN); + if (progressMatch) { + onProgress?.(Number(progressMatch[1])); + } + }; + + child.stdout?.on("data", handleOutput); + child.stderr?.on("data", handleOutput); + child.on("error", reject); + child.on("close", (code) => { + signal?.removeEventListener("abort", abortDownload); + if (code === 0) { + resolve(); + return; + } + + reject(new Error(`aria2c exited with code ${code ?? -1}`)); + }); + }); + + onProgress?.(100); + + return { filePath }; +}; diff --git a/electron/main/backend/update-center/ignore-config.ts b/electron/main/backend/update-center/ignore-config.ts new file mode 100644 index 00000000..e8d2e01e --- /dev/null +++ b/electron/main/backend/update-center/ignore-config.ts @@ -0,0 +1,79 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +import type { UpdateCenterItem } from "./types"; + +export const LEGACY_IGNORE_CONFIG_PATH = "/etc/spark-store/ignored_apps.conf"; + +const LEGACY_IGNORE_SEPARATOR = "|"; + +export const createIgnoreKey = (pkgname: string, version: string): string => + `${pkgname}${LEGACY_IGNORE_SEPARATOR}${version}`; + +export const parseIgnoredEntries = (content: string): Set => { + const ignoredEntries = new Set(); + + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + const parts = trimmed.split(LEGACY_IGNORE_SEPARATOR); + if (parts.length !== 2) { + continue; + } + + const [pkgname, version] = parts; + if (!pkgname || !version) { + continue; + } + + ignoredEntries.add(createIgnoreKey(pkgname, version)); + } + + return ignoredEntries; +}; + +export const loadIgnoredEntries = async ( + filePath: string, +): Promise> => { + try { + const content = await readFile(filePath, "utf8"); + return parseIgnoredEntries(content); + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ENOENT" + ) { + return new Set(); + } + + throw error; + } +}; + +export const saveIgnoredEntries = async ( + filePath: string, + ignoredEntries: ReadonlySet, +): Promise => { + const sortedEntries = Array.from(ignoredEntries).sort(); + const content = + sortedEntries.length > 0 ? `${sortedEntries.join("\n")}\n` : ""; + + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, content, "utf8"); +}; + +export const applyIgnoredEntries = ( + items: UpdateCenterItem[], + ignoredEntries: ReadonlySet, +): UpdateCenterItem[] => + items.map((item) => ({ + ...item, + ignored: ignoredEntries.has( + createIgnoreKey(item.pkgname, item.nextVersion), + ), + })); diff --git a/electron/main/backend/update-center/index.ts b/electron/main/backend/update-center/index.ts new file mode 100644 index 00000000..9803daf8 --- /dev/null +++ b/electron/main/backend/update-center/index.ts @@ -0,0 +1,253 @@ +import { spawn } from "node:child_process"; + +import { BrowserWindow, ipcMain } from "electron"; + +import { + buildInstalledSourceMap, + mergeUpdateSources, + parseApmUpgradableOutput, + parseAptssUpgradableOutput, + parsePrintUrisOutput, +} from "./query"; +import { + createUpdateCenterService, + type UpdateCenterIgnorePayload, + type UpdateCenterService, +} 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[]; +} + +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", + ], +}; + +const DPKG_QUERY_INSTALLED_COMMAND = { + command: "dpkg-query", + args: [ + "-W", + "-f=${Package}\t${db:Status-Want} ${db:Status-Status} ${db:Status-Eflag}\n", + ], +}; + +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 metadataResult = await runCommand("apm", [ + "info", + item.pkgname, + "--print-uris", + ]); + 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 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] : [], + ), + }; +}; + +export const loadUpdateCenterItems = async ( + runCommand: UpdateCenterCommandRunner = runCommandCapture, +): Promise => { + const [aptssResult, apmResult, aptssInstalledResult, apmInstalledResult] = + await Promise.all([ + runCommand( + APTSS_LIST_UPGRADABLE_COMMAND.command, + APTSS_LIST_UPGRADABLE_COMMAND.args, + ), + runCommand("apm", ["list", "--upgradable"]), + runCommand( + DPKG_QUERY_INSTALLED_COMMAND.command, + DPKG_QUERY_INSTALLED_COMMAND.args, + ), + runCommand("apm", ["list", "--installed"]), + ]); + + const warnings = [ + getCommandError("aptss upgradable query", aptssResult), + getCommandError("apm upgradable query", apmResult), + getCommandError("dpkg installed query", aptssInstalledResult), + getCommandError("apm installed query", apmInstalledResult), + ].filter((message): message is string => message !== null); + + const aptssItems = + aptssResult.code === 0 + ? parseAptssUpgradableOutput(aptssResult.stdout) + : []; + const apmItems = + apmResult.code === 0 ? parseApmUpgradableOutput(apmResult.stdout) : []; + + if (aptssResult.code !== 0 && apmResult.code !== 0) { + throw new Error(warnings.join("; ")); + } + + const installedSources = buildInstalledSourceMap( + aptssInstalledResult.code === 0 ? aptssInstalledResult.stdout : "", + apmInstalledResult.code === 0 ? apmInstalledResult.stdout : "", + ); + + const enrichedApmItems = await enrichApmItems(apmItems, runCommand); + + return { + items: mergeUpdateSources( + aptssItems, + enrichedApmItems.items, + installedSources, + ), + warnings: [...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", () => service.open()); + ipc.handle("update-center-refresh", () => service.refresh()); + 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, taskKeys: string[]) => + service.start(taskKeys), + ); + 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; + } + + const superUserCmdProvider = async (): Promise => { + const installManager = await import("../install-manager.js"); + return installManager.checkSuperUserCommand(); + }; + + updateCenterService = createUpdateCenterService({ + loadItems: loadUpdateCenterItems, + superUserCmdProvider, + }); + registerUpdateCenterIpc(ipcMain, updateCenterService); + + return updateCenterService; +}; + +export { createUpdateCenterService } from "./service"; diff --git a/electron/main/backend/update-center/install.ts b/electron/main/backend/update-center/install.ts new file mode 100644 index 00000000..0f8f1424 --- /dev/null +++ b/electron/main/backend/update-center/install.ts @@ -0,0 +1,318 @@ +import { spawn } from "node:child_process"; +import { join } from "node:path"; + +import { runAria2Download, type Aria2DownloadResult } from "./download"; +import type { UpdateCenterQueue, UpdateCenterTask } from "./queue"; +import type { UpdateCenterItem } from "./types"; + +const SHELL_CALLER_PATH = "/opt/spark-store/extras/shell-caller.sh"; +const SSINSTALL_PATH = "/usr/bin/ssinstall"; +const DEFAULT_DOWNLOAD_ROOT = "/tmp/spark-store/update-center"; + +export interface UpdateCommand { + execCommand: string; + execParams: string[]; +} + +export interface InstallUpdateItemOptions { + item: UpdateCenterItem; + filePath?: string; + superUserCmd?: string; + onLog?: (message: string) => void; + signal?: AbortSignal; +} + +export interface TaskRunnerDownloadContext { + item: UpdateCenterItem; + task: UpdateCenterTask; + onProgress: (progress: number) => void; + onLog: (message: string) => void; + signal: AbortSignal; +} + +export interface TaskRunnerInstallContext { + item: UpdateCenterItem; + task: UpdateCenterTask; + filePath?: string; + superUserCmd?: string; + onLog: (message: string) => void; + signal: AbortSignal; +} + +export interface TaskRunnerDependencies { + runDownload?: ( + context: TaskRunnerDownloadContext, + ) => Promise; + installItem?: (context: TaskRunnerInstallContext) => Promise; +} + +export interface UpdateCenterTaskRunner { + runNextTask: () => Promise; + cancelActiveTask: () => void; +} + +export interface CreateTaskRunnerOptions extends TaskRunnerDependencies { + superUserCmd?: string; +} + +const runCommand = async ( + execCommand: string, + execParams: string[], + onLog?: (message: string) => void, + signal?: AbortSignal, +): Promise => { + await new Promise((resolve, reject) => { + const child = spawn(execCommand, execParams, { + shell: false, + env: process.env, + }); + + const handleOutput = (chunk: Buffer) => { + const message = chunk.toString().trim(); + if (message) { + onLog?.(message); + } + }; + + const abortCommand = () => { + child.kill(); + reject(new Error(`Update task cancelled: ${execParams.join(" ")}`)); + }; + + if (signal?.aborted) { + abortCommand(); + return; + } + + signal?.addEventListener("abort", abortCommand, { once: true }); + + child.stdout?.on("data", handleOutput); + child.stderr?.on("data", handleOutput); + child.on("error", reject); + child.on("close", (code) => { + signal?.removeEventListener("abort", abortCommand); + if (code === 0) { + resolve(); + return; + } + + reject(new Error(`${execCommand} exited with code ${code ?? -1}`)); + }); + }); +}; + +const buildPrivilegedCommand = ( + command: string, + args: string[], + superUserCmd?: string, +): UpdateCommand => { + if (superUserCmd) { + return { + execCommand: superUserCmd, + execParams: [command, ...args], + }; + } + + return { + execCommand: command, + execParams: args, + }; +}; + +export const buildLegacySparkUpgradeCommand = ( + pkgname: string, + superUserCmd = "", +): UpdateCommand => { + if (superUserCmd) { + return { + execCommand: superUserCmd, + execParams: [ + SHELL_CALLER_PATH, + "aptss", + "install", + "-y", + pkgname, + "--only-upgrade", + ], + }; + } + + return { + execCommand: SHELL_CALLER_PATH, + execParams: ["aptss", "install", "-y", pkgname, "--only-upgrade"], + }; +}; + +export const installUpdateItem = async ({ + item, + filePath, + superUserCmd, + onLog, + signal, +}: InstallUpdateItemOptions): Promise => { + if (item.source === "apm" && !filePath) { + throw new Error("APM update task requires downloaded package metadata"); + } + + if (item.source === "apm" && filePath) { + const auditCommand = buildPrivilegedCommand( + SHELL_CALLER_PATH, + ["apm", "ssaudit", filePath], + superUserCmd, + ); + await runCommand( + auditCommand.execCommand, + auditCommand.execParams, + onLog, + signal, + ); + return; + } + + if (filePath) { + const installCommand = buildPrivilegedCommand( + SSINSTALL_PATH, + [filePath, "--delete-after-install"], + superUserCmd, + ); + await runCommand( + installCommand.execCommand, + installCommand.execParams, + onLog, + signal, + ); + return; + } + + const command = buildLegacySparkUpgradeCommand( + item.pkgname, + superUserCmd ?? "", + ); + await runCommand(command.execCommand, command.execParams, onLog, signal); +}; + +export const createTaskRunner = ( + queue: UpdateCenterQueue, + options: CreateTaskRunnerOptions = {}, +): UpdateCenterTaskRunner => { + const runDownload = + options.runDownload ?? + ((context: TaskRunnerDownloadContext) => + runAria2Download({ + item: context.item, + downloadDir: join(DEFAULT_DOWNLOAD_ROOT, context.item.pkgname), + onProgress: context.onProgress, + onLog: context.onLog, + signal: context.signal, + })); + const installItem = + options.installItem ?? + ((context: TaskRunnerInstallContext) => + installUpdateItem({ + item: context.item, + filePath: context.filePath, + superUserCmd: context.superUserCmd, + onLog: context.onLog, + signal: context.signal, + })); + let inFlightTask: Promise | null = null; + let activeAbortController: AbortController | null = null; + let activeTaskId: number | null = null; + + return { + cancelActiveTask: () => { + if (!activeAbortController || activeAbortController.signal.aborted) { + return; + } + + activeAbortController.abort(); + }, + runNextTask: async () => { + if (inFlightTask) { + return null; + } + + inFlightTask = (async () => { + const task = queue.getNextQueuedTask(); + if (!task) { + return null; + } + + activeTaskId = task.id; + activeAbortController = new AbortController(); + + const onLog = (message: string) => { + queue.appendTaskLog(task.id, message); + }; + + try { + let filePath: string | undefined; + + if ( + task.item.source === "apm" && + (!task.item.downloadUrl || !task.item.fileName) + ) { + throw new Error( + "APM update task requires downloaded package metadata", + ); + } + + if (task.item.downloadUrl && task.item.fileName) { + queue.markActiveTask(task.id, "downloading"); + const result = await runDownload({ + item: task.item, + task, + onLog, + signal: activeAbortController.signal, + onProgress: (progress) => { + queue.updateTaskProgress(task.id, progress); + }, + }); + filePath = result.filePath; + } + + queue.markActiveTask(task.id, "installing"); + await installItem({ + item: task.item, + task, + filePath, + superUserCmd: options.superUserCmd, + onLog, + signal: activeAbortController.signal, + }); + + const currentTask = queue + .getSnapshot() + .tasks.find((entry) => entry.id === task.id); + if (currentTask?.status !== "cancelled") { + queue.finishTask(task.id, "completed"); + } + return task; + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + const currentTask = queue + .getSnapshot() + .tasks.find((entry) => entry.id === task.id); + if (currentTask?.status !== "cancelled") { + queue.appendTaskLog(task.id, message); + queue.finishTask(task.id, "failed", message); + } + return task; + } finally { + activeAbortController = null; + activeTaskId = null; + } + })(); + + try { + return await inFlightTask; + } finally { + inFlightTask = null; + if (activeTaskId === null) { + activeAbortController = null; + } + } + }, + }; +}; diff --git a/electron/main/backend/update-center/query.ts b/electron/main/backend/update-center/query.ts new file mode 100644 index 00000000..40bca89f --- /dev/null +++ b/electron/main/backend/update-center/query.ts @@ -0,0 +1,370 @@ +import * as childProcess from "node:child_process"; + +import type { + InstalledSourceState, + UpdateCenterItem, + UpdateSource, +} from "./types"; + +const UPGRADABLE_PATTERN = + /^(\S+)\/\S+\s+([^\s]+)\s+\S+\s+\[(?:upgradable from|from):\s*([^\]]+)\]$/i; +const PRINT_URIS_PATTERN = /'([^']+)'\s+(\S+)\s+(\d+)\s+SHA512:([^\s]+)/; +const APM_INSTALLED_PATTERN = /^(\S+)\/\S+(?:,\S+)?\s+\S+\s+\S+\s+\[[^\]]+\]$/; + +const splitVersion = (version: string) => { + const epochMatch = version.match(/^(\d+):(.*)$/); + const epoch = epochMatch ? Number(epochMatch[1]) : 0; + const remainder = epochMatch ? epochMatch[2] : version; + const hyphenIndex = remainder.lastIndexOf("-"); + + return { + epoch, + upstream: hyphenIndex === -1 ? remainder : remainder.slice(0, hyphenIndex), + revision: hyphenIndex === -1 ? "" : remainder.slice(hyphenIndex + 1), + }; +}; + +const getNonDigitOrder = (char: string | undefined): number => { + if (char === "~") { + return -1; + } + + if (!char) { + return 0; + } + + if (/[A-Za-z]/.test(char)) { + return char.charCodeAt(0); + } + + return char.charCodeAt(0) + 256; +}; + +const compareNonDigitPart = (left: string, right: string): number => { + let leftIndex = 0; + let rightIndex = 0; + + while (true) { + const leftChar = left[leftIndex]; + const rightChar = right[rightIndex]; + + const leftIsDigit = leftChar !== undefined && /\d/.test(leftChar); + const rightIsDigit = rightChar !== undefined && /\d/.test(rightChar); + + if ( + (leftChar === undefined || leftIsDigit) && + (rightChar === undefined || rightIsDigit) + ) { + return 0; + } + + const leftOrder = getNonDigitOrder(leftIsDigit ? undefined : leftChar); + const rightOrder = getNonDigitOrder(rightIsDigit ? undefined : rightChar); + + if (leftOrder !== rightOrder) { + return leftOrder < rightOrder ? -1 : 1; + } + + if (!leftIsDigit && leftChar !== undefined) { + leftIndex += 1; + } + + if (!rightIsDigit && rightChar !== undefined) { + rightIndex += 1; + } + } +}; + +const compareDigitPart = (left: string, right: string): number => { + const normalizedLeft = left.replace(/^0+/, ""); + const normalizedRight = right.replace(/^0+/, ""); + + if (normalizedLeft.length !== normalizedRight.length) { + return normalizedLeft.length < normalizedRight.length ? -1 : 1; + } + + if (normalizedLeft === normalizedRight) { + return 0; + } + + return normalizedLeft < normalizedRight ? -1 : 1; +}; + +const compareVersionPart = (left: string, right: string): number => { + let leftIndex = 0; + let rightIndex = 0; + + while (leftIndex < left.length || rightIndex < right.length) { + const nonDigitResult = compareNonDigitPart( + left.slice(leftIndex), + right.slice(rightIndex), + ); + if (nonDigitResult !== 0) { + return nonDigitResult; + } + + while (leftIndex < left.length && !/\d/.test(left[leftIndex])) { + leftIndex += 1; + } + + while (rightIndex < right.length && !/\d/.test(right[rightIndex])) { + rightIndex += 1; + } + + let leftDigitsEnd = leftIndex; + let rightDigitsEnd = rightIndex; + + while (leftDigitsEnd < left.length && /\d/.test(left[leftDigitsEnd])) { + leftDigitsEnd += 1; + } + + while (rightDigitsEnd < right.length && /\d/.test(right[rightDigitsEnd])) { + rightDigitsEnd += 1; + } + + const digitResult = compareDigitPart( + left.slice(leftIndex, leftDigitsEnd), + right.slice(rightIndex, rightDigitsEnd), + ); + if (digitResult !== 0) { + return digitResult; + } + + leftIndex = leftDigitsEnd; + rightIndex = rightDigitsEnd; + } + + return 0; +}; + +const fallbackCompareVersions = (left: string, right: string): number => { + const leftVersion = splitVersion(left); + const rightVersion = splitVersion(right); + + if (leftVersion.epoch !== rightVersion.epoch) { + return leftVersion.epoch < rightVersion.epoch ? -1 : 1; + } + + const upstreamResult = compareVersionPart( + leftVersion.upstream, + rightVersion.upstream, + ); + if (upstreamResult !== 0) { + return upstreamResult; + } + + return compareVersionPart(leftVersion.revision, rightVersion.revision); +}; + +const runDpkgVersionCheck = ( + left: string, + operator: "gt" | "lt", + right: string, +): boolean | null => { + const result = childProcess.spawnSync("dpkg", [ + "--compare-versions", + left, + operator, + right, + ]); + + if (result.error || typeof result.status !== "number") { + return null; + } + + if (result.status === 0) { + return true; + } + + if (result.status === 1) { + return false; + } + + return null; +}; + +const parseUpgradableOutput = ( + output: string, + source: UpdateSource, +): UpdateCenterItem[] => { + const items: UpdateCenterItem[] = []; + + for (const line of output.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("Listing")) { + continue; + } + + const match = trimmed.match(UPGRADABLE_PATTERN); + if (!match) { + continue; + } + + const [, pkgname, nextVersion, currentVersion] = match; + if (!pkgname || nextVersion === currentVersion) { + continue; + } + + items.push({ + pkgname, + source, + currentVersion, + nextVersion, + }); + } + + return items; +}; + +const getInstalledState = ( + installedSources: Map, + pkgname: string, +): InstalledSourceState => { + const existing = installedSources.get(pkgname); + if (existing) { + return existing; + } + + const state: InstalledSourceState = { aptss: false, apm: false }; + installedSources.set(pkgname, state); + return state; +}; + +const compareVersions = (left: string, right: string): number => { + const greaterThan = runDpkgVersionCheck(left, "gt", right); + if (greaterThan === true) { + return 1; + } + + const lessThan = runDpkgVersionCheck(left, "lt", right); + if (lessThan === true) { + return -1; + } + + if (greaterThan === false && lessThan === false) { + return 0; + } + + // Fall back to a numeric-aware string comparison when dpkg is unavailable + // or returns an unusable result, rather than silently treating versions as equal. + return fallbackCompareVersions(left, right); +}; + +export const parseAptssUpgradableOutput = ( + output: string, +): UpdateCenterItem[] => parseUpgradableOutput(output, "aptss"); + +export const parseApmUpgradableOutput = (output: string): UpdateCenterItem[] => + parseUpgradableOutput(output, "apm"); + +export const parsePrintUrisOutput = ( + output: string, +): Pick< + UpdateCenterItem, + "downloadUrl" | "fileName" | "size" | "sha512" +> | null => { + const match = output.trim().match(PRINT_URIS_PATTERN); + if (!match) { + return null; + } + + const [, downloadUrl, fileName, size, sha512] = match; + return { + downloadUrl, + fileName, + size: Number(size), + sha512, + }; +}; + +export const buildInstalledSourceMap = ( + dpkgQueryOutput: string, + apmInstalledOutput: string, +): Map => { + const installedSources = new Map(); + + for (const line of dpkgQueryOutput.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + const [pkgname, status] = trimmed.split("\t"); + if (!pkgname || status !== "install ok installed") { + continue; + } + + getInstalledState(installedSources, pkgname).aptss = true; + } + + for (const line of apmInstalledOutput.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("Listing")) { + continue; + } + + if (!APM_INSTALLED_PATTERN.test(trimmed)) { + continue; + } + + const pkgname = trimmed.split("/")[0]; + if (!pkgname) { + continue; + } + + getInstalledState(installedSources, pkgname).apm = true; + } + + return installedSources; +}; + +export const mergeUpdateSources = ( + aptssItems: UpdateCenterItem[], + apmItems: UpdateCenterItem[], + installedSources: Map, +): UpdateCenterItem[] => { + const aptssMap = new Map(aptssItems.map((item) => [item.pkgname, item])); + const apmMap = new Map(apmItems.map((item) => [item.pkgname, item])); + const merged: UpdateCenterItem[] = []; + + for (const item of aptssItems) { + if (!apmMap.has(item.pkgname)) { + merged.push(item); + } + } + + for (const item of apmItems) { + if (!aptssMap.has(item.pkgname)) { + merged.push(item); + } + } + + for (const aptssItem of aptssItems) { + const apmItem = apmMap.get(aptssItem.pkgname); + if (!apmItem) { + continue; + } + + const installedState = installedSources.get(aptssItem.pkgname); + const isMigration = + installedState?.aptss === true && + installedState.apm === false && + compareVersions(apmItem.nextVersion, aptssItem.nextVersion) > 0; + + if (isMigration) { + merged.push({ + ...apmItem, + isMigration: true, + migrationSource: "aptss", + migrationTarget: "apm", + aptssVersion: aptssItem.nextVersion, + }); + merged.push(aptssItem); + continue; + } + + merged.push(aptssItem, apmItem); + } + + return merged; +}; diff --git a/electron/main/backend/update-center/queue.ts b/electron/main/backend/update-center/queue.ts new file mode 100644 index 00000000..cc2b21e1 --- /dev/null +++ b/electron/main/backend/update-center/queue.ts @@ -0,0 +1,158 @@ +import type { UpdateCenterItem } from "./types"; + +export type UpdateCenterTaskStatus = + | "queued" + | "downloading" + | "installing" + | "completed" + | "failed" + | "cancelled"; + +export interface UpdateCenterTaskLog { + time: number; + message: string; +} + +export interface UpdateCenterTask { + id: number; + pkgname: string; + item: UpdateCenterItem; + status: UpdateCenterTaskStatus; + progress: number; + logs: UpdateCenterTaskLog[]; + error?: string; +} + +export interface UpdateCenterQueueSnapshot { + items: UpdateCenterItem[]; + tasks: UpdateCenterTask[]; + warnings: string[]; + hasRunningTasks: boolean; +} + +export interface UpdateCenterQueue { + setItems: (items: UpdateCenterItem[]) => void; + startRefresh: () => void; + finishRefresh: (warnings?: string[]) => void; + enqueueItem: (item: UpdateCenterItem) => UpdateCenterTask; + markActiveTask: ( + taskId: number, + status: Extract, + ) => void; + updateTaskProgress: (taskId: number, progress: number) => void; + appendTaskLog: (taskId: number, message: string, time?: number) => void; + finishTask: ( + taskId: number, + status: Extract< + UpdateCenterTaskStatus, + "completed" | "failed" | "cancelled" + >, + error?: string, + ) => void; + getNextQueuedTask: () => UpdateCenterTask | undefined; + getSnapshot: () => UpdateCenterQueueSnapshot; +} + +const clampProgress = (progress: number): number => { + if (!Number.isFinite(progress)) { + return 0; + } + + return Math.max(0, Math.min(100, Math.round(progress))); +}; + +const createSnapshot = ( + items: UpdateCenterItem[], + tasks: UpdateCenterTask[], + warnings: string[], + refreshing: boolean, +): UpdateCenterQueueSnapshot => ({ + items: items.map((item) => ({ ...item })), + tasks: tasks.map((task) => ({ + ...task, + item: { ...task.item }, + logs: task.logs.map((log) => ({ ...log })), + })), + warnings: [...warnings], + hasRunningTasks: + refreshing || + tasks.some((task) => + ["queued", "downloading", "installing"].includes(task.status), + ), +}); + +export const createUpdateCenterQueue = (): UpdateCenterQueue => { + let items: UpdateCenterItem[] = []; + let tasks: UpdateCenterTask[] = []; + let warnings: string[] = []; + let refreshing = false; + let nextTaskId = 1; + + const getTask = (taskId: number): UpdateCenterTask | undefined => + tasks.find((task) => task.id === taskId); + + return { + setItems: (nextItems) => { + items = nextItems.map((item) => ({ ...item })); + }, + startRefresh: () => { + refreshing = true; + }, + finishRefresh: (nextWarnings = []) => { + refreshing = false; + warnings = [...nextWarnings]; + }, + enqueueItem: (item) => { + const task: UpdateCenterTask = { + id: nextTaskId, + pkgname: item.pkgname, + item: { ...item }, + status: "queued", + progress: 0, + logs: [], + }; + + nextTaskId += 1; + tasks = [...tasks, task]; + return task; + }, + markActiveTask: (taskId, status) => { + const task = getTask(taskId); + if (!task) { + return; + } + + task.status = status; + }, + updateTaskProgress: (taskId, progress) => { + const task = getTask(taskId); + if (!task) { + return; + } + + task.progress = clampProgress(progress); + }, + appendTaskLog: (taskId, message, time = Date.now()) => { + const task = getTask(taskId); + if (!task) { + return; + } + + task.logs = [...task.logs, { time, message }]; + }, + finishTask: (taskId, status, error) => { + const task = getTask(taskId); + if (!task) { + return; + } + + task.status = status; + task.error = error; + if (status === "completed") { + task.progress = 100; + } + }, + getNextQueuedTask: () => tasks.find((task) => task.status === "queued"), + getSnapshot: () => createSnapshot(items, tasks, warnings, refreshing), + }; +}; diff --git a/electron/main/backend/update-center/service.ts b/electron/main/backend/update-center/service.ts new file mode 100644 index 00000000..68b4a198 --- /dev/null +++ b/electron/main/backend/update-center/service.ts @@ -0,0 +1,294 @@ +import { + LEGACY_IGNORE_CONFIG_PATH, + applyIgnoredEntries, + createIgnoreKey, + loadIgnoredEntries, + saveIgnoredEntries, +} from "./ignore-config"; +import { createTaskRunner, type UpdateCenterTaskRunner } from "./install"; +import { + createUpdateCenterQueue, + type UpdateCenterQueue, + type UpdateCenterQueueSnapshot, +} from "./queue"; +import type { UpdateCenterItem, UpdateSource } from "./types"; + +export interface UpdateCenterLoadedItems { + items: UpdateCenterItem[]; + warnings: string[]; +} + +export interface UpdateCenterServiceItem { + taskKey: string; + packageName: string; + displayName: string; + currentVersion: string; + newVersion: string; + source: UpdateSource; + ignored?: boolean; + downloadUrl?: string; + fileName?: string; + size?: number; + sha512?: string; + isMigration?: boolean; + migrationSource?: UpdateSource; + migrationTarget?: UpdateSource; + aptssVersion?: string; +} + +export interface UpdateCenterServiceTask { + taskKey: string; + packageName: string; + source: UpdateSource; + status: UpdateCenterQueueSnapshot["tasks"][number]["status"]; + progress: number; + logs: UpdateCenterQueueSnapshot["tasks"][number]["logs"]; + errorMessage: string; +} + +export interface UpdateCenterServiceState { + items: UpdateCenterServiceItem[]; + tasks: UpdateCenterServiceTask[]; + warnings: string[]; + hasRunningTasks: boolean; +} + +export interface UpdateCenterIgnorePayload { + packageName: string; + newVersion: string; +} + +export interface UpdateCenterService { + open: () => Promise; + refresh: () => Promise; + ignore: (payload: UpdateCenterIgnorePayload) => Promise; + unignore: (payload: UpdateCenterIgnorePayload) => Promise; + start: (taskKeys: string[]) => Promise; + cancel: (taskKey: string) => Promise; + getState: () => UpdateCenterServiceState; + subscribe: ( + listener: (snapshot: UpdateCenterServiceState) => void, + ) => () => void; +} + +export interface CreateUpdateCenterServiceOptions { + loadItems: () => Promise; + loadIgnoredEntries?: () => Promise>; + saveIgnoredEntries?: (entries: ReadonlySet) => Promise; + createTaskRunner?: ( + queue: UpdateCenterQueue, + superUserCmd?: string, + ) => UpdateCenterTaskRunner; + superUserCmdProvider?: () => Promise; +} + +const getTaskKey = ( + item: Pick, +): string => `${item.source}:${item.pkgname}`; + +const toState = ( + snapshot: UpdateCenterQueueSnapshot, +): UpdateCenterServiceState => ({ + items: snapshot.items.map((item) => ({ + taskKey: getTaskKey(item), + packageName: item.pkgname, + displayName: item.pkgname, + currentVersion: item.currentVersion, + newVersion: item.nextVersion, + source: item.source, + ignored: item.ignored, + downloadUrl: item.downloadUrl, + fileName: item.fileName, + size: item.size, + sha512: item.sha512, + isMigration: item.isMigration, + migrationSource: item.migrationSource, + migrationTarget: item.migrationTarget, + aptssVersion: item.aptssVersion, + })), + tasks: snapshot.tasks.map((task) => ({ + taskKey: getTaskKey(task.item), + packageName: task.pkgname, + source: task.item.source, + status: task.status, + progress: task.progress, + logs: task.logs.map((log) => ({ ...log })), + errorMessage: task.error ?? "", + })), + warnings: [...snapshot.warnings], + hasRunningTasks: snapshot.hasRunningTasks, +}); + +const normalizeLoadedItems = ( + loaded: UpdateCenterItem[] | UpdateCenterLoadedItems, +): UpdateCenterLoadedItems => { + if (Array.isArray(loaded)) { + return { items: loaded, warnings: [] }; + } + + return { + items: loaded.items, + warnings: loaded.warnings, + }; +}; + +export const createUpdateCenterService = ( + options: CreateUpdateCenterServiceOptions, +): UpdateCenterService => { + const queue = createUpdateCenterQueue(); + const listeners = new Set<(snapshot: UpdateCenterServiceState) => void>(); + const loadIgnored = + options.loadIgnoredEntries ?? + (() => loadIgnoredEntries(LEGACY_IGNORE_CONFIG_PATH)); + const saveIgnored = + options.saveIgnoredEntries ?? + ((entries: ReadonlySet) => + saveIgnoredEntries(LEGACY_IGNORE_CONFIG_PATH, entries)); + const createRunner = + options.createTaskRunner ?? + ((currentQueue: UpdateCenterQueue, superUserCmd?: string) => + createTaskRunner(currentQueue, { superUserCmd })); + let processingPromise: Promise | null = null; + let activeRunner: UpdateCenterTaskRunner | null = null; + + const applyWarning = (message: string): void => { + queue.finishRefresh([message]); + }; + + const getState = (): UpdateCenterServiceState => toState(queue.getSnapshot()); + + const emit = (): UpdateCenterServiceState => { + const snapshot = getState(); + for (const listener of listeners) { + listener(snapshot); + } + return snapshot; + }; + + const refresh = async (): Promise => { + queue.startRefresh(); + emit(); + + try { + const ignoredEntries = await loadIgnored(); + const loadedItems = normalizeLoadedItems(await options.loadItems()); + const items = applyIgnoredEntries(loadedItems.items, ignoredEntries); + queue.setItems(items); + queue.finishRefresh(loadedItems.warnings); + return emit(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + queue.setItems([]); + applyWarning(message); + return emit(); + } + }; + + const failQueuedTasks = (message: string): void => { + for (const task of queue.getSnapshot().tasks) { + if (task.status === "queued") { + queue.appendTaskLog(task.id, message); + queue.finishTask(task.id, "failed", message); + } + } + }; + + const ensureProcessing = async (): Promise => { + if (processingPromise) { + return processingPromise; + } + + processingPromise = (async () => { + let superUserCmd = ""; + + try { + superUserCmd = (await options.superUserCmdProvider?.()) ?? ""; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + failQueuedTasks(message); + applyWarning(message); + emit(); + return; + } + + activeRunner = createRunner(queue, superUserCmd); + + while (queue.getNextQueuedTask()) { + await activeRunner.runNextTask(); + emit(); + } + })().finally(() => { + processingPromise = null; + activeRunner = null; + }); + + return processingPromise; + }; + + return { + open: refresh, + refresh, + async ignore(payload) { + const entries = await loadIgnored(); + entries.add(createIgnoreKey(payload.packageName, payload.newVersion)); + await saveIgnored(entries); + await refresh(); + }, + async unignore(payload) { + const entries = await loadIgnored(); + entries.delete(createIgnoreKey(payload.packageName, payload.newVersion)); + await saveIgnored(entries); + await refresh(); + }, + async start(taskKeys) { + const snapshot = queue.getSnapshot(); + const existingTaskKeys = new Set( + snapshot.tasks + .filter( + (task) => + !["completed", "failed", "cancelled"].includes(task.status), + ) + .map((task) => getTaskKey(task.item)), + ); + const selectedItems = snapshot.items.filter( + (item) => + taskKeys.includes(getTaskKey(item)) && + !item.ignored && + !existingTaskKeys.has(getTaskKey(item)), + ); + + if (selectedItems.length === 0) { + return; + } + + for (const item of selectedItems) { + queue.enqueueItem(item); + } + emit(); + + await ensureProcessing(); + }, + async cancel(taskKey) { + const task = queue + .getSnapshot() + .tasks.find((entry) => getTaskKey(entry.item) === taskKey); + + if (!task) { + return; + } + + queue.finishTask(task.id, "cancelled", "Cancelled"); + if (["downloading", "installing"].includes(task.status)) { + activeRunner?.cancelActiveTask(); + } + emit(); + }, + getState, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +}; diff --git a/electron/main/backend/update-center/types.ts b/electron/main/backend/update-center/types.ts new file mode 100644 index 00000000..e70eee63 --- /dev/null +++ b/electron/main/backend/update-center/types.ts @@ -0,0 +1,22 @@ +export type UpdateSource = "aptss" | "apm"; + +export interface InstalledSourceState { + aptss: boolean; + apm: boolean; +} + +export interface UpdateCenterItem { + pkgname: string; + source: UpdateSource; + currentVersion: string; + nextVersion: string; + ignored?: boolean; + downloadUrl?: string; + fileName?: string; + size?: number; + sha512?: string; + isMigration?: boolean; + migrationSource?: UpdateSource; + migrationTarget?: UpdateSource; + aptssVersion?: string; +} diff --git a/electron/main/index.ts b/electron/main/index.ts index c44fc5d6..f327bdde 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -18,6 +18,11 @@ import { handleCommandLine } from "./deeplink.js"; import { isLoaded } from "../global.js"; import { tasks } from "./backend/install-manager.js"; import { sendTelemetryOnce } from "./backend/telemetry.js"; +import { initializeUpdateCenter } from "./backend/update-center/index.js"; +import { + getMainWindowCloseAction, + type MainWindowCloseGuardState, +} from "./window-close-guard.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); process.env.APP_ROOT = path.join(__dirname, "../.."); @@ -81,6 +86,7 @@ if (!app.requestSingleInstanceLock()) { } let win: BrowserWindow | null = null; +let allowAppExit = false; const preload = path.join(__dirname, "../preload/index.mjs"); const indexHtml = path.join(RENDERER_DIST, "index.html"); @@ -107,6 +113,44 @@ ipcMain.handle("get-store-filter", (): "spark" | "apm" | "both" => ipcMain.handle("get-app-version", (): string => getAppVersion()); +const getMainWindowCloseGuardState = (): MainWindowCloseGuardState => ({ + installTaskCount: tasks.size, + hasRunningUpdateCenterTasks: + initializeUpdateCenter().getState().hasRunningTasks, +}); + +const applyMainWindowCloseAction = (): void => { + if (!win) { + return; + } + + const action = getMainWindowCloseAction(getMainWindowCloseGuardState()); + if (action === "hide") { + win.hide(); + win.setSkipTaskbar(true); + return; + } + + win.destroy(); +}; + +const requestApplicationExit = (): void => { + if (!win) { + allowAppExit = true; + app.quit(); + return; + } + + if (getMainWindowCloseAction(getMainWindowCloseGuardState()) === "hide") { + win.hide(); + win.setSkipTaskbar(true); + return; + } + + allowAppExit = true; + app.quit(); +}; + async function createWindow() { win = new BrowserWindow({ title: "星火应用商店", @@ -148,16 +192,13 @@ async function createWindow() { // win.webContents.on('will-navigate', (event, url) => { }) #344 win.on("close", (event) => { + if (allowAppExit) { + return; + } + // 截获 close 默认行为 event.preventDefault(); - // 点击关闭时触发close事件,我们按照之前的思路在关闭时,隐藏窗口,隐藏任务栏窗口 - if (tasks.size > 0) { - win.hide(); - win.setSkipTaskbar(true); - } else { - // 如果没有下载任务,才允许关闭窗口 - win.destroy(); - } + applyMainWindowCloseAction(); }); } @@ -173,26 +214,6 @@ ipcMain.on("set-theme-source", (event, theme: "system" | "light" | "dark") => { nativeTheme.themeSource = theme; }); -// 启动系统更新工具(使用 pkexec 提升权限) -ipcMain.handle("run-update-tool", async () => { - try { - const { spawn } = await import("node:child_process"); - const pkexecPath = "/usr/bin/pkexec"; - const args = ["spark-update-tool"]; - const child = spawn(pkexecPath, args, { - detached: true, - stdio: "ignore", - }); - // 让子进程在后台运行且不影响主进程退出 - child.unref(); - logger.info("Launched pkexec spark-update-tool"); - return { success: true }; - } catch (err) { - logger.error({ err }, "Failed to launch spark-update-tool"); - return { success: false, message: (err as Error)?.message || String(err) }; - } -}); - // 启动安装设置脚本(可能需要提升权限) ipcMain.handle("open-install-settings", async () => { try { @@ -220,12 +241,14 @@ app.whenReady().then(() => { }); createWindow(); handleCommandLine(process.argv); + initializeUpdateCenter(); // 启动后执行一次遥测(仅 Linux,不阻塞) sendTelemetryOnce(getAppVersion()); }); app.on("window-all-closed", () => { win = null; + allowAppExit = false; if (process.platform !== "darwin") app.quit(); }); @@ -302,7 +325,7 @@ app.whenReady().then(() => { { label: "退出程序", click: () => { - win.destroy(); + requestApplicationExit(); }, }, ]); diff --git a/electron/main/window-close-guard.ts b/electron/main/window-close-guard.ts new file mode 100644 index 00000000..77ee6f0c --- /dev/null +++ b/electron/main/window-close-guard.ts @@ -0,0 +1,15 @@ +export interface MainWindowCloseGuardState { + installTaskCount: number; + hasRunningUpdateCenterTasks: boolean; +} + +export type MainWindowCloseAction = "hide" | "destroy"; + +export const shouldPreventMainWindowClose = ( + state: MainWindowCloseGuardState, +): boolean => state.installTaskCount > 0 || state.hasRunningUpdateCenterTasks; + +export const getMainWindowCloseAction = ( + state: MainWindowCloseGuardState, +): MainWindowCloseAction => + shouldPreventMainWindowClose(state) ? "hide" : "destroy"; diff --git a/electron/preload/index.ts b/electron/preload/index.ts index aab78e57..2560fa61 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -1,4 +1,47 @@ -import { ipcRenderer, contextBridge } from "electron"; +import { ipcRenderer, contextBridge, type IpcRendererEvent } from "electron"; + +type UpdateCenterSnapshot = { + items: Array<{ + taskKey: string; + packageName: string; + displayName: string; + currentVersion: string; + newVersion: string; + source: "aptss" | "apm"; + ignored?: boolean; + }>; + tasks: Array<{ + taskKey: string; + packageName: string; + source: "aptss" | "apm"; + status: + | "queued" + | "downloading" + | "installing" + | "completed" + | "failed" + | "cancelled"; + progress: number; + logs: Array<{ time: number; message: string }>; + errorMessage: string; + }>; + warnings: string[]; + hasRunningTasks: boolean; +}; + +type IpcRendererFacade = { + on: typeof ipcRenderer.on; + off: typeof ipcRenderer.off; + send: typeof ipcRenderer.send; + invoke: typeof ipcRenderer.invoke; +}; + +type UpdateCenterStateListener = (snapshot: UpdateCenterSnapshot) => void; + +const updateCenterStateListeners = new Map< + UpdateCenterStateListener, + (_event: IpcRendererEvent, snapshot: UpdateCenterSnapshot) => void +>(); // --------- Expose some API to the Renderer process --------- contextBridge.exposeInMainWorld("ipcRenderer", { @@ -23,7 +66,7 @@ contextBridge.exposeInMainWorld("ipcRenderer", { // You can expose other APTs you need here. // ... -}); +} satisfies IpcRendererFacade); contextBridge.exposeInMainWorld("apm_store", { arch: (() => { @@ -38,6 +81,46 @@ contextBridge.exposeInMainWorld("apm_store", { })(), }); +contextBridge.exposeInMainWorld("updateCenter", { + open: (): Promise => + ipcRenderer.invoke("update-center-open"), + refresh: (): Promise => + ipcRenderer.invoke("update-center-refresh"), + ignore: (payload: { + packageName: string; + newVersion: string; + }): Promise => ipcRenderer.invoke("update-center-ignore", payload), + unignore: (payload: { + packageName: string; + newVersion: string; + }): Promise => ipcRenderer.invoke("update-center-unignore", payload), + start: (taskKeys: string[]): Promise => + ipcRenderer.invoke("update-center-start", taskKeys), + cancel: (taskKey: string): Promise => + ipcRenderer.invoke("update-center-cancel", taskKey), + getState: (): Promise => + ipcRenderer.invoke("update-center-get-state"), + onState: (listener: UpdateCenterStateListener): void => { + const wrapped = ( + _event: IpcRendererEvent, + snapshot: UpdateCenterSnapshot, + ) => { + listener(snapshot); + }; + updateCenterStateListeners.set(listener, wrapped); + ipcRenderer.on("update-center-state", wrapped); + }, + offState: (listener: UpdateCenterStateListener): void => { + const wrapped = updateCenterStateListeners.get(listener); + if (!wrapped) { + return; + } + + ipcRenderer.off("update-center-state", wrapped); + updateCenterStateListeners.delete(listener); + }, +}); + // --------- Preload scripts loading --------- function domReady( condition: DocumentReadyState[] = ["complete", "interactive"], diff --git a/src/App.vue b/src/App.vue index 262ea9b9..73d2f3c6 100644 --- a/src/App.vue +++ b/src/App.vue @@ -126,17 +126,18 @@ @switch-origin="handleSwitchOrigin" /> - diff --git a/src/components/UpdateCenterModal.vue b/src/components/UpdateCenterModal.vue new file mode 100644 index 00000000..3c22bbc3 --- /dev/null +++ b/src/components/UpdateCenterModal.vue @@ -0,0 +1,93 @@ + + + diff --git a/src/components/update-center/UpdateCenterCloseConfirm.vue b/src/components/update-center/UpdateCenterCloseConfirm.vue new file mode 100644 index 00000000..1322a80e --- /dev/null +++ b/src/components/update-center/UpdateCenterCloseConfirm.vue @@ -0,0 +1,44 @@ + + + diff --git a/src/components/update-center/UpdateCenterItem.vue b/src/components/update-center/UpdateCenterItem.vue new file mode 100644 index 00000000..da64cff2 --- /dev/null +++ b/src/components/update-center/UpdateCenterItem.vue @@ -0,0 +1,116 @@ + + + diff --git a/src/components/update-center/UpdateCenterList.vue b/src/components/update-center/UpdateCenterList.vue new file mode 100644 index 00000000..7947177e --- /dev/null +++ b/src/components/update-center/UpdateCenterList.vue @@ -0,0 +1,47 @@ + + + diff --git a/src/components/update-center/UpdateCenterLogPanel.vue b/src/components/update-center/UpdateCenterLogPanel.vue new file mode 100644 index 00000000..50fb3b46 --- /dev/null +++ b/src/components/update-center/UpdateCenterLogPanel.vue @@ -0,0 +1,45 @@ + + + diff --git a/src/components/update-center/UpdateCenterMigrationConfirm.vue b/src/components/update-center/UpdateCenterMigrationConfirm.vue new file mode 100644 index 00000000..97abf6c2 --- /dev/null +++ b/src/components/update-center/UpdateCenterMigrationConfirm.vue @@ -0,0 +1,44 @@ + + + diff --git a/src/components/update-center/UpdateCenterToolbar.vue b/src/components/update-center/UpdateCenterToolbar.vue new file mode 100644 index 00000000..5480d935 --- /dev/null +++ b/src/components/update-center/UpdateCenterToolbar.vue @@ -0,0 +1,73 @@ + + + diff --git a/src/global/storeConfig.ts b/src/global/storeConfig.ts index 297b0fa3..87bb0f87 100644 --- a/src/global/storeConfig.ts +++ b/src/global/storeConfig.ts @@ -102,7 +102,10 @@ export async function loadPriorityConfig(arch: string): Promise { }; } isPriorityConfigLoaded = true; - console.log("[PriorityConfig] 已从服务器加载优先级配置:", dynamicPriorityConfig); + console.log( + "[PriorityConfig] 已从服务器加载优先级配置:", + dynamicPriorityConfig, + ); } else { // 配置文件不存在,默认优先 Spark console.log("[PriorityConfig] 服务器无配置文件,使用默认 Spark 优先"); @@ -136,21 +139,6 @@ function resetPriorityConfig(): void { isPriorityConfigLoaded = true; } -/** - * 检查配置是否为空(没有任何规则) - */ -function isConfigEmpty(): boolean { - const { sparkPriority, apmPriority } = dynamicPriorityConfig; - return ( - sparkPriority.pkgnames.length === 0 && - sparkPriority.categories.length === 0 && - sparkPriority.tags.length === 0 && - apmPriority.pkgnames.length === 0 && - apmPriority.categories.length === 0 && - apmPriority.tags.length === 0 - ); -} - /** * 获取混合模式下应用的默认优先来源 * 判断优先级(从高到低): diff --git a/src/global/typedefinition.ts b/src/global/typedefinition.ts index d327e642..2962ddd5 100644 --- a/src/global/typedefinition.ts +++ b/src/global/typedefinition.ts @@ -123,6 +123,69 @@ export interface UpdateAppItem { upgrading?: boolean; } +export type UpdateSource = "aptss" | "apm"; + +export type UpdateCenterTaskStatus = + | "queued" + | "downloading" + | "installing" + | "completed" + | "failed" + | "cancelled"; + +export interface UpdateCenterItem { + taskKey: string; + packageName: string; + displayName: string; + currentVersion: string; + newVersion: string; + source: UpdateSource; + ignored?: boolean; + downloadUrl?: string; + fileName?: string; + size?: number; + sha512?: string; + isMigration?: boolean; + migrationSource?: UpdateSource; + migrationTarget?: UpdateSource; + aptssVersion?: string; +} + +export interface UpdateCenterTaskState { + taskKey: string; + packageName: string; + source: UpdateSource; + status: UpdateCenterTaskStatus; + progress: number; + logs: Array<{ time: number; message: string }>; + errorMessage: string; +} + +export interface UpdateCenterSnapshot { + items: UpdateCenterItem[]; + tasks: UpdateCenterTaskState[]; + warnings: string[]; + hasRunningTasks: boolean; +} + +export interface UpdateCenterBridge { + open: () => Promise; + refresh: () => Promise; + ignore: (payload: { + packageName: string; + newVersion: string; + }) => Promise; + unignore: (payload: { + packageName: string; + newVersion: string; + }) => Promise; + start: (taskKeys: string[]) => Promise; + cancel: (taskKey: string) => Promise; + getState: () => Promise; + onState: (listener: (snapshot: UpdateCenterSnapshot) => void) => void; + offState: (listener: (snapshot: UpdateCenterSnapshot) => void) => void; +} + /**************Below are type from main process ********************/ export interface InstalledAppInfo { pkgname: string; diff --git a/src/modules/updateCenter.ts b/src/modules/updateCenter.ts new file mode 100644 index 00000000..7a1ad5e9 --- /dev/null +++ b/src/modules/updateCenter.ts @@ -0,0 +1,182 @@ +import { computed, ref, type ComputedRef, type Ref } from "vue"; + +import type { + UpdateCenterItem, + UpdateCenterSnapshot, +} from "@/global/typedefinition"; + +const EMPTY_SNAPSHOT: UpdateCenterSnapshot = { + items: [], + tasks: [], + warnings: [], + hasRunningTasks: false, +}; + +export interface UpdateCenterStore { + isOpen: Ref; + showCloseConfirm: Ref; + showMigrationConfirm: Ref; + searchQuery: Ref; + selectedTaskKeys: Ref>; + snapshot: Ref; + filteredItems: ComputedRef; + bind: () => void; + unbind: () => void; + open: () => Promise; + refresh: () => Promise; + toggleSelection: (taskKey: string) => void; + getSelectedItems: () => UpdateCenterItem[]; + closeNow: () => void; + startSelected: () => Promise; + requestClose: () => void; +} + +const matchesSearch = (item: UpdateCenterItem, query: string): boolean => { + if (query.length === 0) { + return true; + } + + const normalizedQuery = query.toLowerCase(); + return [item.displayName, item.packageName, item.taskKey].some((value) => + value.toLowerCase().includes(normalizedQuery), + ); +}; + +export const createUpdateCenterStore = (): UpdateCenterStore => { + const isOpen = ref(false); + const showCloseConfirm = ref(false); + const showMigrationConfirm = ref(false); + const searchQuery = ref(""); + const selectedTaskKeys = ref(new Set()); + const snapshot = ref(EMPTY_SNAPSHOT); + + const resetSessionState = (): void => { + showCloseConfirm.value = false; + showMigrationConfirm.value = false; + searchQuery.value = ""; + selectedTaskKeys.value = new Set(); + }; + + const applySnapshot = (nextSnapshot: UpdateCenterSnapshot): void => { + const selectableTaskKeys = new Set( + nextSnapshot.items + .filter((item) => item.ignored !== true) + .map((item) => item.taskKey), + ); + selectedTaskKeys.value = new Set( + [...selectedTaskKeys.value].filter((taskKey) => + selectableTaskKeys.has(taskKey), + ), + ); + snapshot.value = nextSnapshot; + }; + + const filteredItems = computed(() => { + const query = searchQuery.value.trim(); + return snapshot.value.items.filter((item) => matchesSearch(item, query)); + }); + + const handleState = (nextSnapshot: UpdateCenterSnapshot): void => { + applySnapshot(nextSnapshot); + }; + + let isBound = false; + + const bind = (): void => { + if (isBound) { + return; + } + + window.updateCenter.onState(handleState); + isBound = true; + }; + + const unbind = (): void => { + if (!isBound) { + return; + } + + window.updateCenter.offState(handleState); + isBound = false; + }; + + const open = async (): Promise => { + resetSessionState(); + const nextSnapshot = await window.updateCenter.open(); + applySnapshot(nextSnapshot); + isOpen.value = true; + }; + + const refresh = async (): Promise => { + const nextSnapshot = await window.updateCenter.refresh(); + applySnapshot(nextSnapshot); + }; + + const toggleSelection = (taskKey: string): void => { + const item = snapshot.value.items.find( + (entry) => entry.taskKey === taskKey, + ); + if (!item || item.ignored === true) { + return; + } + + const nextSelection = new Set(selectedTaskKeys.value); + if (nextSelection.has(taskKey)) { + nextSelection.delete(taskKey); + } else { + nextSelection.add(taskKey); + } + + selectedTaskKeys.value = nextSelection; + }; + + const getSelectedItems = (): UpdateCenterItem[] => { + return snapshot.value.items.filter( + (item) => + selectedTaskKeys.value.has(item.taskKey) && item.ignored !== true, + ); + }; + + const closeNow = (): void => { + resetSessionState(); + isOpen.value = false; + }; + + const startSelected = async (): Promise => { + const taskKeys = getSelectedItems().map((item) => item.taskKey); + + if (taskKeys.length === 0) { + return; + } + + await window.updateCenter.start(taskKeys); + }; + + const requestClose = (): void => { + if (snapshot.value.hasRunningTasks) { + showCloseConfirm.value = true; + return; + } + + closeNow(); + }; + + return { + isOpen, + showCloseConfirm, + showMigrationConfirm, + searchQuery, + selectedTaskKeys, + snapshot, + filteredItems, + bind, + unbind, + open, + refresh, + toggleSelection, + getSelectedItems, + closeNow, + startSelected, + requestClose, + }; +}; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 55420832..3de6f018 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,18 +1,30 @@ /* eslint-disable */ /// +import type { UpdateCenterBridge } from "@/global/typedefinition"; + declare module "*.vue" { import type { DefineComponent } from "vue"; const component: DefineComponent<{}, {}, any>; export default component; } -interface Window { - // expose in the `electron/preload/index.ts` - ipcRenderer: import("electron").IpcRenderer; - apm_store: { - arch: string; - }; +declare global { + interface Window { + // expose in the `electron/preload/index.ts` + ipcRenderer: IpcRendererFacade; + apm_store: { + arch: string; + }; + updateCenter: UpdateCenterBridge; + } +} + +interface IpcRendererFacade { + on: import("electron").IpcRenderer["on"]; + off: import("electron").IpcRenderer["off"]; + send: import("electron").IpcRenderer["send"]; + invoke: import("electron").IpcRenderer["invoke"]; } // IPC channel type definitions @@ -22,25 +34,4 @@ declare interface IpcChannels { declare const __APP_VERSION__: string; -// vue-virtual-scroller type declarations -declare module "vue-virtual-scroller" { - import { DefineComponent } from "vue"; - - export const RecycleScroller: DefineComponent<{ - items: any[]; - itemSize: number; - keyField?: string; - direction?: "vertical" | "horizontal"; - buffer?: number; - }>; - - export const DynamicScroller: DefineComponent<{ - items: any[]; - minItemSize: number; - keyField?: string; - direction?: "vertical" | "horizontal"; - buffer?: number; - }>; - - export const DynamicScrollerItem: DefineComponent<{}>; -} +export {}; diff --git a/src/vue-virtual-scroller.d.ts b/src/vue-virtual-scroller.d.ts new file mode 100644 index 00000000..7939eed9 --- /dev/null +++ b/src/vue-virtual-scroller.d.ts @@ -0,0 +1,21 @@ +declare module "vue-virtual-scroller" { + import type { DefineComponent } from "vue"; + + export const RecycleScroller: DefineComponent<{ + items: unknown[]; + itemSize: number; + keyField?: string; + direction?: "vertical" | "horizontal"; + buffer?: number; + }>; + + export const DynamicScroller: DefineComponent<{ + items: unknown[]; + minItemSize: number; + keyField?: string; + direction?: "vertical" | "horizontal"; + buffer?: number; + }>; + + export const DynamicScrollerItem: DefineComponent>; +} diff --git a/tsconfig.json b/tsconfig.json index 17632357..3478f390 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,9 +25,12 @@ "include": [ "src" ], + "exclude": [ + "src/__tests__" + ], "references": [ { "path": "./tsconfig.node.json" } ] -} \ No newline at end of file +}