mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-04-26 09:20:18 +08:00
feat(update-center): add update list icons
This commit is contained in:
201
electron/main/backend/update-center/icons.ts
Normal file
201
electron/main/backend/update-center/icons.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { UpdateCenterItem } from "./types";
|
||||
|
||||
const APM_BASE_PATH = "/var/lib/apm/apm/files/ace-env/var/lib/apm";
|
||||
const REMOTE_ICON_BASE_URL = "https://erotica.spark-app.store";
|
||||
|
||||
const trimTrailingSlashes = (value: string): string =>
|
||||
value.replace(/\/+$/, "");
|
||||
|
||||
const readDesktopIcon = (desktopPath: string): string => {
|
||||
if (!fs.existsSync(desktopPath)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(desktopPath, "utf-8");
|
||||
const iconMatch = content.match(/^Icon=(.+)$/m);
|
||||
return iconMatch?.[1]?.trim() ?? "";
|
||||
};
|
||||
|
||||
const listPackageFiles = (pkgname: string): Set<string> => {
|
||||
const result = spawnSync("dpkg", ["-L", pkgname]);
|
||||
if (result.error || result.status !== 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return new Set(
|
||||
result.stdout
|
||||
.toString()
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0),
|
||||
);
|
||||
};
|
||||
|
||||
const findDesktopIconInDirectories = (
|
||||
directories: string[],
|
||||
pkgname: string,
|
||||
): string => {
|
||||
const packageFiles = listPackageFiles(pkgname);
|
||||
|
||||
for (const directory of directories) {
|
||||
if (!fs.existsSync(directory)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of fs.readdirSync(directory)) {
|
||||
if (!entry.endsWith(".desktop")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const desktopPath = path.join(directory, entry);
|
||||
if (
|
||||
!desktopPath.startsWith(`/opt/apps/${pkgname}/`) &&
|
||||
!packageFiles.has(desktopPath)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const desktopIcon = readDesktopIcon(desktopPath);
|
||||
if (!desktopIcon) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolvedIcon = resolveIconName(desktopIcon, [
|
||||
`/usr/share/pixmaps/${desktopIcon}.png`,
|
||||
`/usr/share/icons/hicolor/48x48/apps/${desktopIcon}.png`,
|
||||
`/usr/share/icons/hicolor/scalable/apps/${desktopIcon}.svg`,
|
||||
`/opt/apps/${pkgname}/entries/icons/hicolor/48x48/apps/${desktopIcon}.png`,
|
||||
]);
|
||||
if (resolvedIcon) {
|
||||
return resolvedIcon;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
const resolveIconName = (iconName: string, candidates: string[]): string => {
|
||||
if (path.isAbsolute(iconName)) {
|
||||
return fs.existsSync(iconName) ? iconName : "";
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
export const resolveDesktopIcon = (pkgname: string): string => {
|
||||
return findDesktopIconInDirectories(
|
||||
["/usr/share/applications", `/opt/apps/${pkgname}/entries/applications`],
|
||||
pkgname,
|
||||
);
|
||||
};
|
||||
|
||||
export const resolveApmIcon = (pkgname: string): string => {
|
||||
const apmRoots = [APM_BASE_PATH, "/opt/apps"];
|
||||
|
||||
for (const apmRoot of apmRoots) {
|
||||
const desktopDirectory = path.join(
|
||||
apmRoot,
|
||||
pkgname,
|
||||
"entries",
|
||||
"applications",
|
||||
);
|
||||
if (!fs.existsSync(desktopDirectory)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const desktopFile of fs.readdirSync(desktopDirectory)) {
|
||||
if (!desktopFile.endsWith(".desktop")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const desktopIcon = readDesktopIcon(
|
||||
path.join(desktopDirectory, desktopFile),
|
||||
);
|
||||
if (!desktopIcon) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolvedIcon = resolveIconName(desktopIcon, [
|
||||
path.join(
|
||||
apmRoot,
|
||||
pkgname,
|
||||
"entries",
|
||||
"icons",
|
||||
"hicolor",
|
||||
"48x48",
|
||||
"apps",
|
||||
`${desktopIcon}.png`,
|
||||
),
|
||||
path.join(
|
||||
apmRoot,
|
||||
pkgname,
|
||||
"entries",
|
||||
"icons",
|
||||
"hicolor",
|
||||
"scalable",
|
||||
"apps",
|
||||
`${desktopIcon}.svg`,
|
||||
),
|
||||
`/usr/share/pixmaps/${desktopIcon}.png`,
|
||||
`/usr/share/icons/hicolor/48x48/apps/${desktopIcon}.png`,
|
||||
`/usr/share/icons/hicolor/scalable/apps/${desktopIcon}.svg`,
|
||||
]);
|
||||
if (resolvedIcon) {
|
||||
return resolvedIcon;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
export const buildRemoteFallbackIconUrl = ({
|
||||
pkgname,
|
||||
source,
|
||||
arch,
|
||||
category,
|
||||
}: Pick<
|
||||
UpdateCenterItem,
|
||||
"pkgname" | "source" | "arch" | "category"
|
||||
>): string => {
|
||||
const baseUrl = trimTrailingSlashes(REMOTE_ICON_BASE_URL);
|
||||
if (!baseUrl || !arch || !category) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const storeArch = arch.includes("-")
|
||||
? arch
|
||||
: `${arch}-${source === "aptss" ? "store" : "apm"}`;
|
||||
return `${baseUrl}/${storeArch}/${category}/${pkgname}/icon.png`;
|
||||
};
|
||||
|
||||
export const resolveUpdateItemIcon = (item: UpdateCenterItem): string => {
|
||||
const localIcon =
|
||||
item.source === "aptss"
|
||||
? resolveDesktopIcon(item.pkgname)
|
||||
: resolveApmIcon(item.pkgname);
|
||||
if (localIcon) {
|
||||
return localIcon;
|
||||
}
|
||||
|
||||
return (
|
||||
buildRemoteFallbackIconUrl({
|
||||
pkgname: item.pkgname,
|
||||
source: item.source,
|
||||
arch: item.arch,
|
||||
category: item.category,
|
||||
}) || ""
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
parseAptssUpgradableOutput,
|
||||
parsePrintUrisOutput,
|
||||
} from "./query";
|
||||
import { resolveUpdateItemIcon } from "./icons";
|
||||
import {
|
||||
createUpdateCenterService,
|
||||
type UpdateCenterIgnorePayload,
|
||||
@@ -32,6 +33,15 @@ export interface UpdateCenterLoadItemsResult {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
type StoreCategoryMap = Map<string, string>;
|
||||
|
||||
interface RemoteCategoryAppEntry {
|
||||
Pkgname?: string;
|
||||
}
|
||||
|
||||
const REMOTE_STORE_BASE_URL = "https://erotica.spark-app.store";
|
||||
const categoryCache = new Map<string, Promise<StoreCategoryMap>>();
|
||||
|
||||
const APTSS_LIST_UPGRADABLE_COMMAND = {
|
||||
command: "bash",
|
||||
args: [
|
||||
@@ -146,6 +156,105 @@ const enrichApmItems = async (
|
||||
};
|
||||
};
|
||||
|
||||
const getStoreArch = (
|
||||
item: Pick<UpdateCenterItem, "source" | "arch">,
|
||||
): string => {
|
||||
const arch = item.arch;
|
||||
if (!arch) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (arch.includes("-")) {
|
||||
return arch;
|
||||
}
|
||||
|
||||
return `${arch}-${item.source === "aptss" ? "store" : "apm"}`;
|
||||
};
|
||||
|
||||
const loadJson = async <T>(url: string): Promise<T> => {
|
||||
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<StoreCategoryMap> => {
|
||||
const categories = await loadJson<Record<string, unknown>>(
|
||||
`${REMOTE_STORE_BASE_URL}/${storeArch}/categories.json`,
|
||||
);
|
||||
const categoryEntries = await Promise.allSettled(
|
||||
Object.keys(categories).map(async (category) => {
|
||||
const apps = await loadJson<RemoteCategoryAppEntry[]>(
|
||||
`${REMOTE_STORE_BASE_URL}/${storeArch}/${category}/applist.json`,
|
||||
);
|
||||
|
||||
return { apps, category };
|
||||
}),
|
||||
);
|
||||
|
||||
const categoryMap: StoreCategoryMap = 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, entry.value.category);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return categoryMap;
|
||||
};
|
||||
|
||||
const getStoreCategoryMap = (storeArch: string): Promise<StoreCategoryMap> => {
|
||||
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<UpdateCenterItem[]> => {
|
||||
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 category = categoryMap.get(item.pkgname);
|
||||
return category ? { ...item, category } : item;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const enrichItemIcons = (items: UpdateCenterItem[]): UpdateCenterItem[] => {
|
||||
return items.map((item) => {
|
||||
const icon = resolveUpdateItemIcon(item);
|
||||
|
||||
return icon ? { ...item, icon } : item;
|
||||
});
|
||||
};
|
||||
|
||||
export const loadUpdateCenterItems = async (
|
||||
runCommand: UpdateCenterCommandRunner = runCommandCapture,
|
||||
): Promise<UpdateCenterLoadItemsResult> => {
|
||||
@@ -186,12 +295,19 @@ export const loadUpdateCenterItems = async (
|
||||
apmInstalledResult.code === 0 ? apmInstalledResult.stdout : "",
|
||||
);
|
||||
|
||||
const enrichedApmItems = await enrichApmItems(apmItems, runCommand);
|
||||
const [categorizedAptssItems, categorizedApmItems] = await Promise.all([
|
||||
enrichItemCategories(aptssItems),
|
||||
enrichItemCategories(apmItems),
|
||||
]);
|
||||
const enrichedApmItems = await enrichApmItems(
|
||||
categorizedApmItems,
|
||||
runCommand,
|
||||
);
|
||||
|
||||
return {
|
||||
items: mergeUpdateSources(
|
||||
aptssItems,
|
||||
enrichedApmItems.items,
|
||||
enrichItemIcons(categorizedAptssItems),
|
||||
enrichItemIcons(enrichedApmItems.items),
|
||||
installedSources,
|
||||
),
|
||||
warnings: [...warnings, ...enrichedApmItems.warnings],
|
||||
|
||||
@@ -201,6 +201,7 @@ const parseUpgradableOutput = (
|
||||
}
|
||||
|
||||
const [, pkgname, nextVersion, currentVersion] = match;
|
||||
const arch = trimmed.split(/\s+/)[2];
|
||||
if (!pkgname || nextVersion === currentVersion) {
|
||||
continue;
|
||||
}
|
||||
@@ -210,6 +211,7 @@ const parseUpgradableOutput = (
|
||||
source,
|
||||
currentVersion,
|
||||
nextVersion,
|
||||
arch,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface UpdateCenterServiceItem {
|
||||
currentVersion: string;
|
||||
newVersion: string;
|
||||
source: UpdateSource;
|
||||
icon?: string;
|
||||
ignored?: boolean;
|
||||
downloadUrl?: string;
|
||||
fileName?: string;
|
||||
@@ -40,6 +41,7 @@ export interface UpdateCenterServiceTask {
|
||||
taskKey: string;
|
||||
packageName: string;
|
||||
source: UpdateSource;
|
||||
icon?: string;
|
||||
status: UpdateCenterQueueSnapshot["tasks"][number]["status"];
|
||||
progress: number;
|
||||
logs: UpdateCenterQueueSnapshot["tasks"][number]["logs"];
|
||||
@@ -96,6 +98,7 @@ const toState = (
|
||||
currentVersion: item.currentVersion,
|
||||
newVersion: item.nextVersion,
|
||||
source: item.source,
|
||||
icon: item.icon,
|
||||
ignored: item.ignored,
|
||||
downloadUrl: item.downloadUrl,
|
||||
fileName: item.fileName,
|
||||
@@ -110,6 +113,7 @@ const toState = (
|
||||
taskKey: getTaskKey(task.item),
|
||||
packageName: task.pkgname,
|
||||
source: task.item.source,
|
||||
icon: task.item.icon,
|
||||
status: task.status,
|
||||
progress: task.progress,
|
||||
logs: task.logs.map((log) => ({ ...log })),
|
||||
|
||||
@@ -10,6 +10,9 @@ export interface UpdateCenterItem {
|
||||
source: UpdateSource;
|
||||
currentVersion: string;
|
||||
nextVersion: string;
|
||||
arch?: string;
|
||||
category?: string;
|
||||
icon?: string;
|
||||
ignored?: boolean;
|
||||
downloadUrl?: string;
|
||||
fileName?: string;
|
||||
|
||||
Reference in New Issue
Block a user