mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-08-07 03:43:57 +08:00
1. add real-time form validation prompt component for submission page 2. add URL validation for official website and screenshot format checks 3. refactor form validity calculation and user input status judgment 4. replace mirror switch with configurable base URL, support dev proxy path handling 5. add processing logic for base64 format screenshots 6. optimize history search and form reset logic
1979 lines
62 KiB
TypeScript
1979 lines
62 KiB
TypeScript
import { BrowserWindow, dialog, ipcMain, shell } from "electron";
|
|
import path from "node:path";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import https from "node:https";
|
|
import pino from "pino";
|
|
|
|
const logger = pino({ name: "submitter.ts" });
|
|
|
|
interface DebInfo {
|
|
pkgname: string;
|
|
version: string;
|
|
author: string;
|
|
maintainer: string;
|
|
homepage: string;
|
|
description: string;
|
|
architecture: string;
|
|
}
|
|
|
|
interface HistoryAppInfo {
|
|
id: number;
|
|
name: string;
|
|
pkgname: string;
|
|
version: string;
|
|
store: string;
|
|
author: string;
|
|
contributor: string;
|
|
website: string;
|
|
category: string;
|
|
tags: string;
|
|
more: string;
|
|
icon: string;
|
|
imgs: string[];
|
|
}
|
|
|
|
interface OssUploadMetadata {
|
|
code: number;
|
|
msg: string;
|
|
data: {
|
|
dir: string;
|
|
host: string;
|
|
ossAccessKeyId: string;
|
|
policy: string;
|
|
signature: string;
|
|
};
|
|
}
|
|
|
|
function generateUUID(): string {
|
|
const hexChars = "0123456789abcdef";
|
|
let uuid = "";
|
|
for (let i = 0; i < 32; i++) {
|
|
uuid += hexChars[Math.floor(Math.random() * 16)];
|
|
}
|
|
return uuid;
|
|
}
|
|
|
|
function getUUIDFileNameSuggestIcoPic(filePath: string): string {
|
|
const ext = path.extname(filePath).toLowerCase().replace(".", "");
|
|
const uuid = generateUUID();
|
|
return `${uuid}.${ext}`;
|
|
}
|
|
|
|
function getUUIDFileNameSuggestDeb(_filePath: string): string {
|
|
const uuid = generateUUID();
|
|
return `${uuid}.deb`;
|
|
}
|
|
|
|
async function getOssUploadMetadata(
|
|
type: "icons" | "pic" | "deb",
|
|
): Promise<OssUploadMetadata> {
|
|
const startTime = Date.now();
|
|
const pathMap: Record<string, string> = {
|
|
icons: "upload_icons",
|
|
pic: "upload_pic",
|
|
deb: "upload_deb",
|
|
};
|
|
const url = `https://upload.deepinos.org.cn/api/index/${pathMap[type]}`;
|
|
logger.info(
|
|
`[Submitter] ============== GET OSS METADATA START (${type}) ==============`,
|
|
);
|
|
logger.info(
|
|
{ url, timestamp: new Date().toISOString() },
|
|
`[Submitter] Getting OSS metadata for ${type}`,
|
|
);
|
|
|
|
const response = await fetch(url, {
|
|
method: "GET",
|
|
headers: {
|
|
"User-Agent": getUserAgent(),
|
|
},
|
|
});
|
|
|
|
const duration = Date.now() - startTime;
|
|
logger.info(
|
|
{ status: response.status, statusText: response.statusText, duration },
|
|
`[Submitter] OSS metadata response for ${type}`,
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
logger.error(
|
|
{ errorText },
|
|
`[Submitter] Failed to get OSS metadata for ${type}`,
|
|
);
|
|
throw new Error(
|
|
`获取 ${type} 上传签名失败: ${response.status} - ${errorText.substring(0, 200)}`,
|
|
);
|
|
}
|
|
|
|
const responseText = await response.text();
|
|
logger.info(
|
|
{
|
|
responseTextLength: responseText.length,
|
|
responseText: responseText.substring(0, 1000),
|
|
},
|
|
`[Submitter] Raw OSS metadata response for ${type}`,
|
|
);
|
|
|
|
let result: unknown;
|
|
try {
|
|
result = JSON.parse(responseText);
|
|
logger.info(
|
|
{
|
|
resultType: typeof result,
|
|
resultKeys:
|
|
typeof result === "object" && result !== null
|
|
? Object.keys(result as object)
|
|
: [],
|
|
},
|
|
`[Submitter] Parsed OSS metadata result type for ${type}`,
|
|
);
|
|
} catch (parseError) {
|
|
logger.error(
|
|
{ parseError: (parseError as Error)?.message },
|
|
`[Submitter] Failed to parse OSS metadata response for ${type}`,
|
|
);
|
|
throw new Error(
|
|
`解析 ${type} 上传签名响应失败: ${(parseError as Error)?.message}`,
|
|
);
|
|
}
|
|
|
|
if (typeof result !== "object" || result === null) {
|
|
logger.error(
|
|
{ result },
|
|
`[Submitter] OSS metadata response is not an object for ${type}`,
|
|
);
|
|
throw new Error(`${type} 上传签名响应格式错误`);
|
|
}
|
|
|
|
const resultObj = result as Record<string, unknown>;
|
|
if (resultObj.data === undefined || resultObj.data === null) {
|
|
logger.error(
|
|
{ result },
|
|
`[Submitter] OSS metadata response data field is undefined for ${type}`,
|
|
);
|
|
throw new Error(`${type} 上传签名响应缺少 data 字段`);
|
|
}
|
|
|
|
const dataObj = resultObj.data as Record<string, unknown>;
|
|
if (!dataObj.host || !dataObj.dir) {
|
|
logger.error(
|
|
{ data: resultObj.data },
|
|
`[Submitter] OSS metadata response data missing host or dir for ${type}`,
|
|
);
|
|
throw new Error(`${type} 上传签名响应 data 字段缺少 host 或 dir`);
|
|
}
|
|
|
|
const ossMetadata: OssUploadMetadata = {
|
|
code: Number(resultObj.code) || 0,
|
|
msg: String(resultObj.msg || ""),
|
|
data: {
|
|
dir: String(dataObj.dir),
|
|
host: String(dataObj.host),
|
|
ossAccessKeyId: String(
|
|
dataObj.OSSAccessKeyId ||
|
|
dataObj.ossAccessKeyId ||
|
|
dataObj.oss_access_key_id ||
|
|
"",
|
|
),
|
|
policy: String(dataObj.policy || ""),
|
|
signature: String(dataObj.signature || ""),
|
|
},
|
|
};
|
|
|
|
logger.info(
|
|
`[Submitter] ============== OSS METADATA RECEIVED (${type}) ==============`,
|
|
);
|
|
logger.info(
|
|
{ code: ossMetadata.code, msg: ossMetadata.msg },
|
|
`[Submitter] OSS metadata result for ${type}`,
|
|
);
|
|
logger.info(
|
|
{ host: ossMetadata.data.host, dir: ossMetadata.data.dir },
|
|
`[Submitter] OSS upload host and dir for ${type}`,
|
|
);
|
|
logger.info(
|
|
{
|
|
ossAccessKeyIdLength: ossMetadata.data.ossAccessKeyId.length,
|
|
policyLength: ossMetadata.data.policy.length,
|
|
signatureLength: ossMetadata.data.signature.length,
|
|
},
|
|
`[Submitter] OSS credential lengths for ${type}`,
|
|
);
|
|
|
|
return ossMetadata;
|
|
}
|
|
|
|
type UploadProgressCallback = (progress: number, fileType: string) => void;
|
|
|
|
async function uploadFileToOss(
|
|
metadata: OssUploadMetadata,
|
|
filePath: string,
|
|
fileName: string,
|
|
mimeType: string,
|
|
fileType: string,
|
|
progressCallback?: UploadProgressCallback,
|
|
): Promise<string> {
|
|
const startTime = Date.now();
|
|
const { host, dir, ossAccessKeyId, policy, signature } = metadata.data;
|
|
const uploadUrl = host;
|
|
const objectKey = `${dir}${fileName}`;
|
|
|
|
logger.info(
|
|
`[Submitter] ============== UPLOAD FILE START (${fileType}) ==============`,
|
|
);
|
|
logger.info(
|
|
{
|
|
uploadUrl,
|
|
objectKey,
|
|
filePath,
|
|
fileName,
|
|
mimeType,
|
|
timestamp: new Date().toISOString(),
|
|
},
|
|
`[Submitter] Starting upload for ${fileType}`,
|
|
);
|
|
|
|
const fileStat = fs.statSync(filePath);
|
|
const fileSize = fileStat.size;
|
|
logger.info(
|
|
{ fileSize, fileSizeHuman: `${(fileSize / 1024 / 1024).toFixed(2)} MB` },
|
|
`[Submitter] File size for ${fileType}`,
|
|
);
|
|
|
|
const fileBuffer = fs.readFileSync(filePath);
|
|
logger.info(
|
|
{ bufferSize: fileBuffer.length },
|
|
`[Submitter] File buffer ready for ${fileType}`,
|
|
);
|
|
|
|
const boundary = `----SparkStoreUploadBoundary${Date.now().toString(36)}`;
|
|
const CRLF = Buffer.from("\r\n", "ascii");
|
|
|
|
const encodeField = (str: string) => Buffer.from(str, "utf8");
|
|
|
|
const headerBuffers: Buffer[] = [];
|
|
headerBuffers.push(encodeField(`--${boundary}\r\n`));
|
|
headerBuffers.push(
|
|
encodeField(`Content-Disposition: form-data; name="key"\r\n\r\n`),
|
|
);
|
|
headerBuffers.push(encodeField(objectKey));
|
|
headerBuffers.push(CRLF);
|
|
|
|
headerBuffers.push(encodeField(`--${boundary}\r\n`));
|
|
headerBuffers.push(
|
|
encodeField(
|
|
`Content-Disposition: form-data; name="ossAccessKeyId"\r\n\r\n`,
|
|
),
|
|
);
|
|
headerBuffers.push(encodeField(ossAccessKeyId));
|
|
headerBuffers.push(CRLF);
|
|
|
|
headerBuffers.push(encodeField(`--${boundary}\r\n`));
|
|
headerBuffers.push(
|
|
encodeField(`Content-Disposition: form-data; name="policy"\r\n\r\n`),
|
|
);
|
|
headerBuffers.push(encodeField(policy));
|
|
headerBuffers.push(CRLF);
|
|
|
|
headerBuffers.push(encodeField(`--${boundary}\r\n`));
|
|
headerBuffers.push(
|
|
encodeField(`Content-Disposition: form-data; name="signature"\r\n\r\n`),
|
|
);
|
|
headerBuffers.push(encodeField(signature));
|
|
headerBuffers.push(CRLF);
|
|
|
|
headerBuffers.push(encodeField(`--${boundary}\r\n`));
|
|
headerBuffers.push(
|
|
encodeField(
|
|
`Content-Disposition: form-data; name="success_action_status"\r\n\r\n`,
|
|
),
|
|
);
|
|
headerBuffers.push(encodeField("200"));
|
|
headerBuffers.push(CRLF);
|
|
|
|
headerBuffers.push(encodeField(`--${boundary}\r\n`));
|
|
headerBuffers.push(
|
|
encodeField(
|
|
`Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n`,
|
|
),
|
|
);
|
|
headerBuffers.push(encodeField(`Content-Type: ${mimeType}\r\n\r\n`));
|
|
|
|
const formHeaderBuffer = Buffer.concat(headerBuffers);
|
|
const formFooterBuffer = Buffer.concat([
|
|
CRLF,
|
|
encodeField(`--${boundary}--\r\n`),
|
|
]);
|
|
const totalLength =
|
|
formHeaderBuffer.length + fileBuffer.length + formFooterBuffer.length;
|
|
|
|
logger.info(
|
|
{
|
|
formHeaderLength: formHeaderBuffer.length,
|
|
formFooterLength: formFooterBuffer.length,
|
|
totalLength,
|
|
fileSize,
|
|
},
|
|
`[Submitter] Form lengths for ${fileType}`,
|
|
);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const url = new URL(uploadUrl);
|
|
const options: https.RequestOptions = {
|
|
hostname: url.hostname,
|
|
port: url.port ? parseInt(url.port) : 443,
|
|
path: url.pathname + url.search,
|
|
method: "POST",
|
|
headers: {
|
|
"User-Agent": getUserAgent(),
|
|
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
|
"Content-Length": totalLength,
|
|
},
|
|
};
|
|
|
|
logger.info(
|
|
`[Submitter] ============== SENDING UPLOAD REQUEST (${fileType}) ==============`,
|
|
);
|
|
|
|
const req = https.request(options, (res) => {
|
|
let responseData = "";
|
|
res.on("data", (chunk) => {
|
|
responseData += chunk;
|
|
});
|
|
res.on("end", () => {
|
|
const duration = Date.now() - startTime;
|
|
logger.info(
|
|
`[Submitter] ============== UPLOAD RESPONSE RECEIVED (${fileType}) ==============`,
|
|
);
|
|
logger.info(
|
|
{ status: res.statusCode, duration },
|
|
`[Submitter] Upload response for ${fileType}`,
|
|
);
|
|
logger.info(
|
|
{ responseHeaders: res.headers },
|
|
`[Submitter] Response headers for ${fileType}`,
|
|
);
|
|
logger.info(
|
|
{ responseBody: responseData.substring(0, 1000) },
|
|
`[Submitter] Response body for ${fileType}`,
|
|
);
|
|
|
|
if (res.statusCode !== 200) {
|
|
logger.error(
|
|
{ errorText: responseData.substring(0, 500) },
|
|
`[Submitter] Upload failed for ${fileType}`,
|
|
);
|
|
reject(
|
|
new Error(
|
|
`${fileType} 上传失败: ${res.statusCode} - ${responseData.substring(0, 500)}`,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const finalUrl = `${host}${objectKey}`;
|
|
logger.info(
|
|
{ finalUrl },
|
|
`[Submitter] ${fileType} upload successful, URL: ${finalUrl}`,
|
|
);
|
|
resolve(finalUrl);
|
|
});
|
|
});
|
|
|
|
req.on("error", (err) => {
|
|
logger.error({ err }, `[Submitter] Upload request error for ${fileType}`);
|
|
reject(new Error(`${fileType} 上传失败: ${err.message}`));
|
|
});
|
|
|
|
req.write(formHeaderBuffer);
|
|
|
|
let uploadedBytes = formHeaderBuffer.length;
|
|
|
|
const chunkSize = 1024 * 1024;
|
|
for (let offset = 0; offset < fileBuffer.length; offset += chunkSize) {
|
|
const chunk = fileBuffer.slice(
|
|
offset,
|
|
Math.min(offset + chunkSize, fileBuffer.length),
|
|
);
|
|
req.write(chunk);
|
|
uploadedBytes += chunk.length;
|
|
|
|
if (progressCallback) {
|
|
const progress = Math.min((uploadedBytes / totalLength) * 100, 100);
|
|
progressCallback(progress, fileType);
|
|
}
|
|
}
|
|
|
|
req.write(formFooterBuffer);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
function getAppVersion(): string {
|
|
const appRoot = process.env.APP_ROOT;
|
|
if (!appRoot) return "dev";
|
|
const pkgPath = path.join(appRoot, "package.json");
|
|
try {
|
|
const raw = fs.readFileSync(pkgPath, "utf8");
|
|
const pkg = JSON.parse(raw) as { version?: string };
|
|
return typeof pkg.version === "string" ? pkg.version : "dev";
|
|
} catch {
|
|
return "dev";
|
|
}
|
|
}
|
|
|
|
function getUserAgent(): string {
|
|
return `Spark-Store/${getAppVersion()}`;
|
|
}
|
|
|
|
export function registerSubmitterHandlers(
|
|
preload: string,
|
|
indexHtml: string,
|
|
viteDevServerUrl: string | undefined,
|
|
getSubmitterWin: () => BrowserWindow | null,
|
|
setSubmitterWin: (win: BrowserWindow | null) => void,
|
|
) {
|
|
ipcMain.handle("launch-submitter", async () => {
|
|
try {
|
|
if (getSubmitterWin() && !getSubmitterWin()!.isDestroyed()) {
|
|
getSubmitterWin()!.show();
|
|
getSubmitterWin()!.focus();
|
|
return { success: true };
|
|
}
|
|
|
|
const newWin = new BrowserWindow({
|
|
title: "星火应用商店 - 投稿应用",
|
|
width: 800,
|
|
height: 900,
|
|
frame: false,
|
|
show: false,
|
|
autoHideMenuBar: true,
|
|
icon: path.join(process.env.VITE_PUBLIC!, "favicon.ico"),
|
|
webPreferences: {
|
|
preload,
|
|
},
|
|
});
|
|
|
|
if (viteDevServerUrl) {
|
|
newWin.loadURL(`${viteDevServerUrl}#submitter`);
|
|
} else {
|
|
newWin.loadFile(indexHtml, { hash: "submitter" });
|
|
}
|
|
|
|
newWin.once("ready-to-show", () => {
|
|
newWin.show();
|
|
newWin.focus();
|
|
});
|
|
|
|
newWin.on("closed", () => {
|
|
setSubmitterWin(null);
|
|
});
|
|
|
|
newWin.webContents.setWindowOpenHandler(({ url }) => {
|
|
if (url.startsWith("https:")) shell.openExternal(url);
|
|
return { action: "deny" };
|
|
});
|
|
|
|
setSubmitterWin(newWin);
|
|
logger.info("Submitter window opened");
|
|
return { success: true };
|
|
} catch (err) {
|
|
logger.error({ err }, "Failed to open submitter window");
|
|
return {
|
|
success: false,
|
|
message: (err as Error)?.message || String(err),
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.on("close-submitter-window", () => {
|
|
getSubmitterWin()?.close();
|
|
});
|
|
|
|
ipcMain.handle("select-deb-file", async (_event) => {
|
|
try {
|
|
const result = await dialog.showOpenDialog({
|
|
title: "选择 deb 安装包",
|
|
filters: [{ name: "Debian 包", extensions: ["deb"] }],
|
|
properties: ["openFile"],
|
|
});
|
|
|
|
if (!result.canceled && result.filePaths.length > 0) {
|
|
return { success: true, filePath: result.filePaths[0] };
|
|
}
|
|
|
|
return { success: false, message: "用户取消选择" };
|
|
} catch (err) {
|
|
logger.error({ err }, "Failed to select deb file");
|
|
return {
|
|
success: false,
|
|
message: (err as Error)?.message || "选择文件失败",
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("parse-deb-file", async (_event, debPath: string) => {
|
|
try {
|
|
logger.info({ debPath }, "[Submitter] Starting parse-deb-file handler");
|
|
|
|
const { exec } = await import("node:child_process");
|
|
const util = await import("util");
|
|
const execAsync = util.promisify(exec);
|
|
|
|
const absoluteDebPath = path.resolve(debPath);
|
|
logger.info(
|
|
{ debPath, absoluteDebPath },
|
|
"[Submitter] Resolved absolute deb path",
|
|
);
|
|
|
|
if (!fs.existsSync(absoluteDebPath)) {
|
|
logger.error(
|
|
{ debPath, absoluteDebPath },
|
|
"[Submitter] Deb file not found",
|
|
);
|
|
return { success: false, message: `文件不存在: ${absoluteDebPath}` };
|
|
}
|
|
|
|
logger.info(
|
|
{ absoluteDebPath },
|
|
"[Submitter] Deb file exists, executing dpkg-deb command",
|
|
);
|
|
|
|
const { stdout, stderr } = await execAsync(
|
|
`dpkg-deb -f "${absoluteDebPath}" Package Version Maintainer Homepage Description Architecture`,
|
|
);
|
|
|
|
logger.info({ stdout, stderr }, "[Submitter] dpkg-deb command executed");
|
|
|
|
if (stderr) {
|
|
logger.error({ stderr }, "[Submitter] dpkg-deb returned error");
|
|
return { success: false, message: stderr };
|
|
}
|
|
|
|
const lines = stdout.trim().split("\n");
|
|
logger.info(
|
|
{ lineCount: lines.length, rawOutput: stdout },
|
|
"[Submitter] Parsing dpkg-deb output",
|
|
);
|
|
|
|
const debInfo: DebInfo = {
|
|
pkgname: "",
|
|
version: "",
|
|
author: "",
|
|
maintainer: "",
|
|
homepage: "",
|
|
description: "",
|
|
architecture: "",
|
|
};
|
|
|
|
for (const line of lines) {
|
|
const [key, ...valueParts] = line.split(":");
|
|
const value = valueParts.join(":").trim();
|
|
logger.debug(
|
|
{ line, key, value },
|
|
"[Submitter] Processing dpkg-deb line",
|
|
);
|
|
|
|
switch (key.trim()) {
|
|
case "Package":
|
|
debInfo.pkgname = value.toLowerCase();
|
|
logger.info(
|
|
{ pkgname: debInfo.pkgname },
|
|
"[Submitter] Found Package name",
|
|
);
|
|
break;
|
|
case "Version":
|
|
debInfo.version = value;
|
|
logger.info(
|
|
{ version: debInfo.version },
|
|
"[Submitter] Found Version",
|
|
);
|
|
break;
|
|
case "Maintainer":
|
|
debInfo.maintainer = value;
|
|
debInfo.author = value;
|
|
logger.info(
|
|
{ maintainer: debInfo.maintainer },
|
|
"[Submitter] Found Maintainer",
|
|
);
|
|
break;
|
|
case "Homepage":
|
|
debInfo.homepage = value;
|
|
logger.info(
|
|
{ homepage: debInfo.homepage },
|
|
"[Submitter] Found Homepage",
|
|
);
|
|
break;
|
|
case "Description":
|
|
debInfo.description = value;
|
|
logger.info(
|
|
{ description: debInfo.description.substring(0, 100) + "..." },
|
|
"[Submitter] Found Description",
|
|
);
|
|
break;
|
|
case "Architecture":
|
|
debInfo.architecture = value;
|
|
logger.info(
|
|
{ architecture: debInfo.architecture },
|
|
"[Submitter] Found Architecture",
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
|
|
logger.info(
|
|
{ debInfo },
|
|
"[Submitter] Deb file parsing completed successfully",
|
|
);
|
|
|
|
return { success: true, data: debInfo };
|
|
} catch (err) {
|
|
logger.error(
|
|
{ err, debPath },
|
|
"[Submitter] Failed to parse deb file with exception",
|
|
);
|
|
return {
|
|
success: false,
|
|
message: (err as Error)?.message || "解析deb文件失败",
|
|
};
|
|
}
|
|
});
|
|
|
|
const FALLBACK_CATEGORIES = [
|
|
"chat",
|
|
"development",
|
|
"games",
|
|
"image_graphics",
|
|
"music",
|
|
"network",
|
|
"office",
|
|
"others",
|
|
"reading",
|
|
"themes",
|
|
"tools",
|
|
"video",
|
|
];
|
|
|
|
async function fetchCategoriesFromCdn(
|
|
baseUrl: string,
|
|
arch: string,
|
|
): Promise<string[]> {
|
|
const url = `${baseUrl}/${arch}/categories.json`;
|
|
logger.info({ url }, "[Submitter] Fetching categories from CDN");
|
|
try {
|
|
const response = await fetch(url, {
|
|
headers: { "User-Agent": getUserAgent() },
|
|
});
|
|
if (!response.ok) {
|
|
logger.warn(
|
|
{ status: response.status, arch },
|
|
"[Submitter] Failed to fetch categories.json, using fallback",
|
|
);
|
|
return FALLBACK_CATEGORIES;
|
|
}
|
|
const json = await response.json();
|
|
if (json && typeof json === "object" && !Array.isArray(json)) {
|
|
const keys = Object.keys(json);
|
|
logger.info(
|
|
{ arch, categories: keys, count: keys.length },
|
|
"[Submitter] Categories loaded from CDN",
|
|
);
|
|
return keys.length > 0 ? keys : FALLBACK_CATEGORIES;
|
|
}
|
|
logger.warn(
|
|
{ arch },
|
|
"[Submitter] Unexpected categories.json format, using fallback",
|
|
);
|
|
return FALLBACK_CATEGORIES;
|
|
} catch (err) {
|
|
logger.warn(
|
|
{ err, arch },
|
|
"[Submitter] Error fetching categories.json, using fallback",
|
|
);
|
|
return FALLBACK_CATEGORIES;
|
|
}
|
|
}
|
|
|
|
ipcMain.handle(
|
|
"search-history-app",
|
|
async (_event, pkgname: string, baseUrl?: string) => {
|
|
try {
|
|
let resolvedBaseUrl = baseUrl || "https://erotica.spark-app.store";
|
|
// 开发模式下来自 Vite 代理的路径(如 /local_amd64-store),
|
|
// 主进程的 fetch() 无法解析相对 URL,需要转为实际地址
|
|
if (resolvedBaseUrl.startsWith("/")) {
|
|
logger.info(
|
|
{ devPath: resolvedBaseUrl },
|
|
"[Submitter] Dev proxy path detected, resolving to production URL",
|
|
);
|
|
resolvedBaseUrl = "https://erotica.spark-app.store";
|
|
}
|
|
const storeArchs = [
|
|
"amd64-store",
|
|
"arm64-store",
|
|
"loong64-store",
|
|
"amd64-apm",
|
|
"arm64-apm",
|
|
"loong64-apm",
|
|
];
|
|
const results: HistoryAppInfo[] = [];
|
|
|
|
// 为每个架构目录获取各自的分类列表(不同目录可能有不同分类,如 apm-extensions 只在 *-apm 下有)
|
|
const archCategoriesMap = new Map<string, string[]>();
|
|
for (const arch of storeArchs) {
|
|
const cats = await fetchCategoriesFromCdn(resolvedBaseUrl, arch);
|
|
archCategoriesMap.set(arch, cats);
|
|
}
|
|
|
|
logger.info(
|
|
"[Submitter] ============== SEARCH HISTORY APP START ==============",
|
|
);
|
|
logger.info(
|
|
{
|
|
pkgname,
|
|
baseUrl: resolvedBaseUrl,
|
|
storeArchs,
|
|
archCategoryCounts: Array.from(archCategoriesMap.entries()).map(
|
|
([a, c]) => ({ arch: a, categoryCount: c.length }),
|
|
),
|
|
},
|
|
"[Submitter] Search parameters",
|
|
);
|
|
|
|
const allPromises: Promise<void>[] = [];
|
|
|
|
for (const arch of storeArchs) {
|
|
const categories = archCategoriesMap.get(arch) || FALLBACK_CATEGORIES;
|
|
for (const category of categories) {
|
|
const url = `${resolvedBaseUrl}/${arch}/${category}/${pkgname}/app.json`;
|
|
logger.info(
|
|
{ arch, category, url },
|
|
"[Submitter] Starting search request",
|
|
);
|
|
|
|
const promise = fetch(url, {
|
|
headers: { "User-Agent": getUserAgent() },
|
|
})
|
|
.then(async (response) => {
|
|
logger.info(
|
|
{ arch, category, status: response.status },
|
|
"[Submitter] Fetch completed",
|
|
);
|
|
|
|
if (response.ok) {
|
|
const json = await response.json();
|
|
logger.info(
|
|
{
|
|
arch,
|
|
category,
|
|
rawJson: JSON.stringify(json, null, 2),
|
|
},
|
|
"[Submitter] Response parsed",
|
|
);
|
|
|
|
const appPkgname =
|
|
json?.pkgname || json?.Pkgname || json?.packageName || "";
|
|
logger.info(
|
|
{ arch, category, appPkgname, searchPkgname: pkgname },
|
|
"[Submitter] Comparing pkgname",
|
|
);
|
|
|
|
if (appPkgname.toLowerCase() === pkgname.toLowerCase()) {
|
|
logger.info(
|
|
{ arch, category, item: json },
|
|
"[Submitter] Found matching item",
|
|
);
|
|
|
|
const iconUrl = json.icons || json.icon || "";
|
|
let imgs = json.imgUrls || json.imgs || json.img_urls || [];
|
|
|
|
if (typeof imgs === "string") {
|
|
try {
|
|
imgs = JSON.parse(imgs);
|
|
logger.info(
|
|
{
|
|
arch,
|
|
category,
|
|
parsedImgsCount: Array.isArray(imgs)
|
|
? imgs.length
|
|
: 0,
|
|
},
|
|
"[Submitter] Parsed img_urls from string",
|
|
);
|
|
} catch {
|
|
imgs = [];
|
|
logger.warn(
|
|
{ arch, category, imgsString: imgs },
|
|
"[Submitter] Failed to parse img_urls string",
|
|
);
|
|
}
|
|
}
|
|
|
|
results.push({
|
|
id: json.id || json.Id || 0,
|
|
name: json.name || json.Name || "",
|
|
pkgname: json.pkgname || json.Pkgname || "",
|
|
version: json.version || json.Version || "",
|
|
store: arch,
|
|
author: json.author || json.Author || "",
|
|
contributor: json.contributor || json.Contributor || "",
|
|
website: json.website || json.Website || "",
|
|
category: category,
|
|
tags: json.tags || json.Tags || "",
|
|
more: json.more || json.More || "",
|
|
icon: iconUrl,
|
|
imgs: imgs,
|
|
});
|
|
|
|
logger.info(
|
|
{ arch, count: results.length },
|
|
"[Submitter] Added to results",
|
|
);
|
|
}
|
|
} else {
|
|
logger.warn(
|
|
{
|
|
arch,
|
|
category,
|
|
url,
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
},
|
|
"[Submitter] Non-ok response",
|
|
);
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
const errMsg =
|
|
error instanceof Error ? error.message : String(error);
|
|
logger.warn(
|
|
{ arch, category, url, error: errMsg },
|
|
"[Submitter] Request failed or exception caught",
|
|
);
|
|
});
|
|
|
|
allPromises.push(promise);
|
|
}
|
|
}
|
|
|
|
await Promise.all(allPromises);
|
|
|
|
logger.info(
|
|
"[Submitter] ============== SEARCH COMPLETED ==============",
|
|
);
|
|
logger.info(
|
|
{ totalResults: results.length, results },
|
|
"[Submitter] Search results",
|
|
);
|
|
|
|
return { success: true, data: results };
|
|
} catch (err) {
|
|
logger.error("[Submitter] ============== SEARCH FAILED ==============");
|
|
logger.error(
|
|
{
|
|
errorType: (err as Error)?.name,
|
|
errorMessage: (err as Error)?.message,
|
|
errorStack: (err as Error)?.stack,
|
|
},
|
|
"[Submitter] Exception caught",
|
|
);
|
|
return {
|
|
success: false,
|
|
message: (err as Error)?.message || "搜索历史信息失败",
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
ipcMain.handle("get-category-list", async () => {
|
|
try {
|
|
const apiUrl = "https://upload.deepinos.org.cn/api/index/get_type_list";
|
|
logger.info(
|
|
"[Submitter] ============== GET CATEGORY LIST START ==============",
|
|
);
|
|
logger.info(
|
|
{ apiUrl, userAgent: getUserAgent() },
|
|
"[Submitter] Request parameters",
|
|
);
|
|
|
|
const startTime = Date.now();
|
|
const response = await fetch(apiUrl, {
|
|
headers: { "User-Agent": getUserAgent() },
|
|
});
|
|
const endTime = Date.now();
|
|
|
|
logger.info(
|
|
{
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
duration: endTime - startTime,
|
|
},
|
|
"[Submitter] Fetch completed",
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
logger.error(
|
|
{ status: response.status, errorBody: errorText },
|
|
"[Submitter] Request failed",
|
|
);
|
|
return {
|
|
success: false,
|
|
message: `获取分类列表失败 (${response.status})`,
|
|
};
|
|
}
|
|
|
|
const json = await response.json();
|
|
logger.info(
|
|
{
|
|
code: json?.code,
|
|
msg: json?.msg,
|
|
dataType: typeof json?.data,
|
|
dataLength: json?.data?.length || "N/A",
|
|
response: json,
|
|
},
|
|
"[Submitter] Response parsed",
|
|
);
|
|
|
|
return { success: true, data: json };
|
|
} catch (err) {
|
|
logger.error(
|
|
{
|
|
errorType: (err as Error)?.name,
|
|
errorMessage: (err as Error)?.message,
|
|
errorStack: (err as Error)?.stack,
|
|
},
|
|
"[Submitter] Exception caught",
|
|
);
|
|
return {
|
|
success: false,
|
|
message: (err as Error)?.message || "获取分类列表失败",
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("get-git-email", async () => {
|
|
try {
|
|
const { exec } = await import("node:child_process");
|
|
const util = await import("util");
|
|
const execAsync = util.promisify(exec);
|
|
const { stdout } = await execAsync("git config user.email");
|
|
const email = stdout.trim();
|
|
logger.info({ email }, "[Submitter] Git email retrieved");
|
|
return { success: true, data: email || "" };
|
|
} catch (err) {
|
|
logger.warn(
|
|
{ err },
|
|
"[Submitter] Failed to get git email, not a git repo or git not installed",
|
|
);
|
|
return { success: false, data: "" };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("get-tags-list", async () => {
|
|
try {
|
|
const apiUrl = "https://upload.deepinos.org.cn/api/index/get_tags_list";
|
|
logger.info(
|
|
"[Submitter] ============== GET TAGS LIST START ==============",
|
|
);
|
|
logger.info(
|
|
{ apiUrl, userAgent: getUserAgent() },
|
|
"[Submitter] Request parameters",
|
|
);
|
|
|
|
const startTime = Date.now();
|
|
const response = await fetch(apiUrl, {
|
|
headers: { "User-Agent": getUserAgent() },
|
|
});
|
|
const endTime = Date.now();
|
|
|
|
logger.info(
|
|
{
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
duration: endTime - startTime,
|
|
},
|
|
"[Submitter] Fetch completed",
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
logger.error(
|
|
{ status: response.status, errorBody: errorText },
|
|
"[Submitter] Request failed",
|
|
);
|
|
return {
|
|
success: false,
|
|
message: `获取标签列表失败 (${response.status})`,
|
|
};
|
|
}
|
|
|
|
const json = await response.json();
|
|
logger.info(
|
|
{
|
|
code: json?.code,
|
|
msg: json?.msg,
|
|
dataType: typeof json?.data,
|
|
dataLength: json?.data?.length || "N/A",
|
|
response: json,
|
|
},
|
|
"[Submitter] Response parsed",
|
|
);
|
|
|
|
return { success: true, data: json };
|
|
} catch (err) {
|
|
logger.error(
|
|
{
|
|
errorType: (err as Error)?.name,
|
|
errorMessage: (err as Error)?.message,
|
|
errorStack: (err as Error)?.stack,
|
|
},
|
|
"[Submitter] Exception caught",
|
|
);
|
|
return {
|
|
success: false,
|
|
message: (err as Error)?.message || "获取标签列表失败",
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("submit-app", async (event, formData: unknown) => {
|
|
try {
|
|
const startTime = Date.now();
|
|
logger.info("[Submitter] ============== SUBMIT APP START ==============");
|
|
logger.info(
|
|
{ timestamp: new Date().toISOString() },
|
|
"[Submitter] Submission started at",
|
|
);
|
|
|
|
if (typeof formData !== "object" || formData === null) {
|
|
logger.error("[Submitter] Form data is not an object");
|
|
return { success: false, message: "表单数据格式错误" };
|
|
}
|
|
|
|
const dataObj = formData as Record<string, unknown>;
|
|
logger.info(
|
|
"[Submitter] ============== FORM DATA RECEIVED ==============",
|
|
);
|
|
logger.info({ name: dataObj.name }, "[Submitter] App name");
|
|
logger.info({ pkgname: dataObj.pkgname }, "[Submitter] Package name");
|
|
logger.info({ version: dataObj.version }, "[Submitter] Version");
|
|
logger.info({ author: dataObj.author }, "[Submitter] Author");
|
|
logger.info(
|
|
{ contributor: dataObj.contributor },
|
|
"[Submitter] Contributor",
|
|
);
|
|
logger.info({ website: dataObj.website }, "[Submitter] Website");
|
|
logger.info(
|
|
{ debFilePath: dataObj.debFilePath },
|
|
"[Submitter] Deb file path",
|
|
);
|
|
logger.info({ iconPath: dataObj.iconPath }, "[Submitter] Icon path");
|
|
logger.info({ category: dataObj.category }, "[Submitter] Category");
|
|
logger.info({ tags: dataObj.tags }, "[Submitter] Tags");
|
|
logger.info(
|
|
{
|
|
descriptionLength:
|
|
typeof dataObj.description === "string"
|
|
? dataObj.description.length
|
|
: 0,
|
|
},
|
|
"[Submitter] Description length",
|
|
);
|
|
logger.info(
|
|
{
|
|
screenshotsCount: Array.isArray(dataObj.screenshots)
|
|
? dataObj.screenshots.length
|
|
: 0,
|
|
},
|
|
"[Submitter] Screenshots count",
|
|
);
|
|
|
|
const debFilePath = String(dataObj.debFilePath || "");
|
|
const iconPath = String(dataObj.iconPath || "");
|
|
const screenshots = Array.isArray(dataObj.screenshots)
|
|
? dataObj.screenshots
|
|
: [];
|
|
|
|
if (!debFilePath) {
|
|
logger.error("[Submitter] Deb file path is empty");
|
|
return { success: false, message: "请选择 deb 文件" };
|
|
}
|
|
|
|
if (!fs.existsSync(debFilePath)) {
|
|
logger.error({ debFilePath }, "[Submitter] Deb file does not exist");
|
|
return { success: false, message: `deb 文件不存在: ${debFilePath}` };
|
|
}
|
|
|
|
const sendUploadProgress = (
|
|
step: string,
|
|
progress: number,
|
|
message: string,
|
|
) => {
|
|
event.sender.send("submit-upload-progress", {
|
|
step,
|
|
progress,
|
|
message,
|
|
});
|
|
};
|
|
|
|
let iconUrl = "";
|
|
if (iconPath) {
|
|
logger.info(
|
|
"[Submitter] ============== STEP 1: UPLOAD ICON ==============",
|
|
);
|
|
let iconFilePath = iconPath;
|
|
|
|
if (iconPath.startsWith("data:")) {
|
|
logger.info("[Submitter] Icon is a Base64 data URL, decoding");
|
|
const base64Data = iconPath.split(",")[1];
|
|
if (!base64Data) {
|
|
return { success: false, message: "图标数据格式错误" };
|
|
}
|
|
const tempDir = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), "spark-store-submitter-"),
|
|
);
|
|
iconFilePath = path.join(tempDir, "icon.png");
|
|
try {
|
|
fs.writeFileSync(iconFilePath, Buffer.from(base64Data, "base64"));
|
|
logger.info(
|
|
{ iconFilePath },
|
|
"[Submitter] Icon decoded from data URL",
|
|
);
|
|
} catch (err) {
|
|
logger.error({ err }, "[Submitter] Failed to decode icon data URL");
|
|
return {
|
|
success: false,
|
|
message: `图标解码失败: ${(err as Error).message}`,
|
|
};
|
|
}
|
|
} else if (
|
|
iconPath.startsWith("http://") ||
|
|
iconPath.startsWith("https://")
|
|
) {
|
|
logger.info(
|
|
{ iconPath },
|
|
"[Submitter] Icon is a remote URL, downloading first",
|
|
);
|
|
const tempDir = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), "spark-store-submitter-"),
|
|
);
|
|
iconFilePath = path.join(tempDir, "icon.png");
|
|
|
|
try {
|
|
const response = await fetch(iconPath);
|
|
if (!response.ok) {
|
|
throw new Error(`下载图标失败: ${response.status}`);
|
|
}
|
|
const blob = await response.blob();
|
|
const buffer = Buffer.from(await blob.arrayBuffer());
|
|
fs.writeFileSync(iconFilePath, buffer);
|
|
logger.info(
|
|
{ iconFilePath, size: buffer.length },
|
|
"[Submitter] Icon downloaded successfully",
|
|
);
|
|
} catch (err) {
|
|
logger.error(
|
|
{ err, iconPath },
|
|
"[Submitter] Failed to download icon from URL",
|
|
);
|
|
return {
|
|
success: false,
|
|
message: `下载图标失败: ${(err as Error).message}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (fs.existsSync(iconFilePath)) {
|
|
const iconMetadata = await getOssUploadMetadata("icons");
|
|
const iconFileName = getUUIDFileNameSuggestIcoPic(iconFilePath);
|
|
sendUploadProgress("icon", 0, "正在上传图标...");
|
|
iconUrl = await uploadFileToOss(
|
|
iconMetadata,
|
|
iconFilePath,
|
|
iconFileName,
|
|
"image/png",
|
|
"icon",
|
|
(progress) => {
|
|
sendUploadProgress(
|
|
"icon",
|
|
progress,
|
|
`正在上传图标... ${Math.floor(progress)}%`,
|
|
);
|
|
},
|
|
);
|
|
sendUploadProgress("icon", 100, "图标上传完成");
|
|
logger.info({ iconUrl }, "[Submitter] Icon upload completed");
|
|
|
|
if (
|
|
iconPath.startsWith("http://") ||
|
|
iconPath.startsWith("https://") ||
|
|
iconPath.startsWith("data:")
|
|
) {
|
|
fs.unlinkSync(iconFilePath);
|
|
fs.rmdirSync(path.dirname(iconFilePath));
|
|
}
|
|
} else {
|
|
logger.error(
|
|
{ iconFilePath },
|
|
"[Submitter] Icon file does not exist",
|
|
);
|
|
return {
|
|
success: false,
|
|
message: `图标文件不存在: ${iconFilePath}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
const screenshotUrls: string[] = [];
|
|
for (let i = 0; i < screenshots.length; i++) {
|
|
const screenshot = screenshots[i];
|
|
logger.info(
|
|
{ index: i, screenshot },
|
|
"[Submitter] Processing screenshot",
|
|
);
|
|
|
|
if (typeof screenshot === "string") {
|
|
logger.info(
|
|
`[Submitter] ============== STEP 2: UPLOAD SCREENSHOT ${i + 1} ==============`,
|
|
);
|
|
let screenshotFilePath = screenshot;
|
|
|
|
if (
|
|
screenshot.startsWith("http://") ||
|
|
screenshot.startsWith("https://")
|
|
) {
|
|
logger.info(
|
|
{ screenshot },
|
|
"[Submitter] Screenshot is a remote URL, downloading first",
|
|
);
|
|
const tempDir = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), "spark-store-submitter-"),
|
|
);
|
|
screenshotFilePath = path.join(tempDir, `screen_${i + 1}.png`);
|
|
|
|
try {
|
|
const response = await fetch(screenshot);
|
|
if (!response.ok) {
|
|
throw new Error(`下载截图失败: ${response.status}`);
|
|
}
|
|
const blob = await response.blob();
|
|
const buffer = Buffer.from(await blob.arrayBuffer());
|
|
fs.writeFileSync(screenshotFilePath, buffer);
|
|
logger.info(
|
|
{ screenshotFilePath, size: buffer.length },
|
|
"[Submitter] Screenshot downloaded successfully",
|
|
);
|
|
} catch (err) {
|
|
logger.error(
|
|
{ err, screenshot },
|
|
"[Submitter] Failed to download screenshot from URL",
|
|
);
|
|
continue;
|
|
}
|
|
} else if (screenshot.startsWith("data:")) {
|
|
logger.info(
|
|
"[Submitter] Screenshot is a Base64 data URL, decoding",
|
|
);
|
|
const base64Data = screenshot.split(",")[1];
|
|
if (!base64Data) {
|
|
logger.warn("[Submitter] Invalid screenshot data URL, skipping");
|
|
continue;
|
|
}
|
|
const tempDir = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), "spark-store-submitter-"),
|
|
);
|
|
screenshotFilePath = path.join(tempDir, `screen_${i + 1}.png`);
|
|
try {
|
|
fs.writeFileSync(
|
|
screenshotFilePath,
|
|
Buffer.from(base64Data, "base64"),
|
|
);
|
|
logger.info(
|
|
{ screenshotFilePath },
|
|
"[Submitter] Screenshot decoded from data URL",
|
|
);
|
|
} catch (err) {
|
|
logger.error(
|
|
{ err },
|
|
"[Submitter] Failed to decode screenshot data URL",
|
|
);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (fs.existsSync(screenshotFilePath)) {
|
|
const picMetadata = await getOssUploadMetadata("pic");
|
|
const picFileName =
|
|
getUUIDFileNameSuggestIcoPic(screenshotFilePath);
|
|
sendUploadProgress(
|
|
`screenshot-${i}`,
|
|
0,
|
|
`正在上传截图 ${i + 1}...`,
|
|
);
|
|
const picUrl = await uploadFileToOss(
|
|
picMetadata,
|
|
screenshotFilePath,
|
|
picFileName,
|
|
"image/png",
|
|
`screenshot ${i + 1}`,
|
|
(progress) => {
|
|
sendUploadProgress(
|
|
`screenshot-${i}`,
|
|
progress,
|
|
`正在上传截图 ${i + 1}... ${Math.floor(progress)}%`,
|
|
);
|
|
},
|
|
);
|
|
sendUploadProgress(
|
|
`screenshot-${i}`,
|
|
100,
|
|
`截图 ${i + 1} 上传完成`,
|
|
);
|
|
screenshotUrls.push(picUrl);
|
|
logger.info(
|
|
{ picUrl },
|
|
`[Submitter] Screenshot ${i + 1} upload completed`,
|
|
);
|
|
|
|
if (
|
|
screenshot.startsWith("http://") ||
|
|
screenshot.startsWith("https://") ||
|
|
screenshot.startsWith("data:")
|
|
) {
|
|
fs.unlinkSync(screenshotFilePath);
|
|
fs.rmdirSync(path.dirname(screenshotFilePath));
|
|
}
|
|
} else {
|
|
logger.warn(
|
|
{ screenshotFilePath },
|
|
"[Submitter] Screenshot file does not exist, skipping",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.info(
|
|
"[Submitter] ============== STEP 3: UPLOAD DEB ==============",
|
|
);
|
|
logger.info(
|
|
{
|
|
debFilePath,
|
|
debFileName: getUUIDFileNameSuggestDeb(debFilePath),
|
|
},
|
|
"[Submitter] Starting deb upload",
|
|
);
|
|
const debMetadata = await getOssUploadMetadata("deb");
|
|
const debFileName = getUUIDFileNameSuggestDeb(debFilePath);
|
|
sendUploadProgress("deb", 0, "正在上传安装包...");
|
|
const debUrl = await uploadFileToOss(
|
|
debMetadata,
|
|
debFilePath,
|
|
debFileName,
|
|
"application/vnd.debian.binary-package",
|
|
"deb",
|
|
(progress) => {
|
|
sendUploadProgress(
|
|
"deb",
|
|
progress,
|
|
`正在上传安装包... ${Math.floor(progress)}%`,
|
|
);
|
|
},
|
|
);
|
|
sendUploadProgress("deb", 100, "安装包上传完成");
|
|
logger.info(
|
|
"[Submitter] ============== DEB UPLOAD SUCCESSFUL ==============",
|
|
);
|
|
logger.info(
|
|
{ debUrl, debFileName },
|
|
"[Submitter] Deb upload completed successfully",
|
|
);
|
|
|
|
logger.info(
|
|
"[Submitter] ============== STEP 4: SUBMIT APPLICATION ==============",
|
|
);
|
|
sendUploadProgress("submit", 0, "正在提交投稿信息...");
|
|
|
|
const debFileStat = fs.statSync(debFilePath);
|
|
|
|
// 参考老 Qt 投稿器:根据 deb 元数据构造 file_name: {pkgname}_{version}_{arch}.deb
|
|
// arch 由前端解析 deb 时获取并传入,无需再次调用 dpkg-deb
|
|
const debArch = String(dataObj.arch || "amd64");
|
|
const pkgVersion = String(dataObj.version || "0.0.0");
|
|
const debPkgName = String(dataObj.pkgname || "unknown");
|
|
const formFileName = `${debPkgName}_${pkgVersion}_${debArch}.deb`;
|
|
logger.info(
|
|
{ formFileName, debArch },
|
|
"[Submitter] Constructed file_name",
|
|
);
|
|
|
|
const categoryName = String(dataObj.category || "");
|
|
const categoryId = Number(dataObj.categoryId) || 0;
|
|
logger.info(
|
|
{ categoryName, categoryId },
|
|
"[Submitter] Category name and ID",
|
|
);
|
|
|
|
const tagsString = String(dataObj.tags || "");
|
|
const tagsArray = tagsString
|
|
? tagsString
|
|
.split(";")
|
|
.map((t: string) => t.trim())
|
|
.filter((t: string) => t)
|
|
: [];
|
|
logger.info({ tagsString, tagsArray }, "[Submitter] Tags conversion");
|
|
|
|
const remark =
|
|
((dataObj.remark as string) || "") +
|
|
" - (来自于投稿器_v" +
|
|
getAppVersion() +
|
|
")";
|
|
|
|
const submitData: Record<string, unknown> = {
|
|
application_name: debPkgName,
|
|
application_name_zh: String(dataObj.name || ""),
|
|
contributor: String(dataObj.contributor || ""),
|
|
icons: iconUrl,
|
|
size: debFileStat.size,
|
|
file_name: formFileName,
|
|
website: String(dataObj.website || ""),
|
|
version: pkgVersion,
|
|
more: String(dataObj.description || ""),
|
|
type_id: categoryId,
|
|
author: String(dataObj.author || ""),
|
|
remark,
|
|
img_urls: screenshotUrls,
|
|
deb_url: debUrl,
|
|
mail: String(dataObj.mail || dataObj.contributor || ""),
|
|
tags: tagsArray,
|
|
architecture: debArch,
|
|
};
|
|
|
|
logger.info(
|
|
"[Submitter] ============== VALIDATING SUBMISSION DATA ==============",
|
|
);
|
|
// 对齐老 Qt 投稿器 isReadySubmit() 的完整校验
|
|
const checks: Array<{ field: keyof typeof submitData; label: string }> = [
|
|
{ field: "application_name", label: "包名" },
|
|
{ field: "application_name_zh", label: "应用名称" },
|
|
{ field: "contributor", label: "贡献者" },
|
|
{ field: "icons", label: "图标URL" },
|
|
{ field: "size", label: "文件大小" },
|
|
{ field: "file_name", label: "文件名" },
|
|
{ field: "website", label: "官网地址" },
|
|
{ field: "version", label: "版本号" },
|
|
{ field: "more", label: "应用描述" },
|
|
{ field: "type_id", label: "分类ID" },
|
|
{ field: "author", label: "作者" },
|
|
{ field: "remark", label: "测试情况" },
|
|
{ field: "deb_url", label: "安装包URL" },
|
|
{ field: "mail", label: "联系邮箱" },
|
|
];
|
|
const missingFields: string[] = [];
|
|
for (const { field, label } of checks) {
|
|
const val = submitData[field];
|
|
if (val === undefined || val === null || val === "" || val === 0) {
|
|
missingFields.push(`${label}(${field})`);
|
|
}
|
|
}
|
|
if (screenshotUrls.length === 0) {
|
|
missingFields.push("截图(img_urls)");
|
|
}
|
|
if (tagsArray.length === 0) {
|
|
missingFields.push("标签(tags)");
|
|
}
|
|
if (missingFields.length > 0) {
|
|
logger.error({ missingFields }, "[Submitter] Missing required fields");
|
|
return {
|
|
success: false,
|
|
message: `缺少必填字段: ${missingFields.join(", ")}`,
|
|
};
|
|
}
|
|
|
|
logger.info(
|
|
"[Submitter] ============== PREPARING SUBMISSION REQUEST ==============",
|
|
);
|
|
logger.info({ submitData }, "[Submitter] Final submission data");
|
|
logger.info(
|
|
`[Submitter] ============== FULL SUBMIT JSON ==============\n${JSON.stringify(submitData, null, 2)}`,
|
|
);
|
|
|
|
const submitterApiUrl =
|
|
"https://upload.deepinos.org.cn/api/index/upload_application";
|
|
logger.info({ submitterApiUrl }, "[Submitter] Submission API URL");
|
|
|
|
logger.info(
|
|
"[Submitter] ============== SENDING SUBMISSION REQUEST ==============",
|
|
);
|
|
logger.info(
|
|
{ submitterApiUrl, timestamp: new Date().toISOString() },
|
|
"[Submitter] Submission request sent to API",
|
|
);
|
|
|
|
const response = await fetch(submitterApiUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"User-Agent": getUserAgent(),
|
|
},
|
|
body: JSON.stringify(submitData),
|
|
});
|
|
|
|
const requestDuration = Date.now() - startTime;
|
|
logger.info(
|
|
"[Submitter] ============== SUBMISSION RESPONSE RECEIVED ==============",
|
|
);
|
|
logger.info(
|
|
{ status: response.status, statusText: response.statusText },
|
|
"[Submitter] Submission response status",
|
|
);
|
|
logger.info(
|
|
{ duration: requestDuration },
|
|
"[Submitter] Total submission duration (ms)",
|
|
);
|
|
|
|
const responseText = await response.text();
|
|
logger.info(
|
|
{ responseTextLength: responseText.length },
|
|
"[Submitter] Submission response text length",
|
|
);
|
|
|
|
if (!response.ok) {
|
|
logger.error(
|
|
"[Submitter] ============== SUBMISSION FAILED ==============",
|
|
);
|
|
logger.error(
|
|
{ status: response.status, statusText: response.statusText },
|
|
"[Submitter] Submission failed with status",
|
|
);
|
|
logger.error(
|
|
{ responseText: responseText.substring(0, 2000) },
|
|
"[Submitter] Submission API error response (full)",
|
|
);
|
|
|
|
let message = "提交失败";
|
|
let apiResponse: unknown = null;
|
|
|
|
try {
|
|
apiResponse = JSON.parse(responseText);
|
|
logger.error(
|
|
{ apiResponse },
|
|
"[Submitter] Parsed API error response",
|
|
);
|
|
} catch (parseError) {
|
|
logger.warn(
|
|
{ parseError: (parseError as Error)?.message },
|
|
"[Submitter] Failed to parse error response as JSON",
|
|
);
|
|
}
|
|
|
|
if (response.status === 521) {
|
|
message = "服务器暂时不可用,请稍后重试";
|
|
} else if (response.status === 400) {
|
|
if (typeof apiResponse === "object" && apiResponse !== null) {
|
|
const errorObj = apiResponse as Record<string, unknown>;
|
|
message = String(
|
|
errorObj.msg || errorObj.message || "请求参数错误",
|
|
);
|
|
} else {
|
|
message = responseText.substring(0, 200) || "请求参数错误";
|
|
}
|
|
} else if (response.status === 401) {
|
|
message = "未授权,请登录后再试";
|
|
} else if (response.status === 403) {
|
|
message = "权限不足";
|
|
} else if (response.status === 429) {
|
|
message = "请求过于频繁,请稍后重试";
|
|
} else {
|
|
message = `提交失败 [${response.status}]: ${responseText.substring(0, 200)}`;
|
|
}
|
|
|
|
logger.error(
|
|
{ finalMessage: message },
|
|
"[Submitter] Final error message to user",
|
|
);
|
|
return { success: false, message, apiResponse };
|
|
}
|
|
|
|
let result: unknown;
|
|
try {
|
|
result = JSON.parse(responseText);
|
|
} catch (parseError) {
|
|
logger.warn(
|
|
{ parseError: (parseError as Error)?.message },
|
|
"[Submitter] Failed to parse success response as JSON",
|
|
);
|
|
result = responseText;
|
|
}
|
|
|
|
// 检查响应体中是否有业务错误码(部分 API HTTP 200 但业务失败)
|
|
if (result && typeof result === "object" && !Array.isArray(result)) {
|
|
const respObj = result as Record<string, unknown>;
|
|
if (respObj.code !== undefined && respObj.code !== 0) {
|
|
logger.error(
|
|
{ respCode: respObj.code, respMsg: respObj.msg },
|
|
"[Submitter] API returned business error",
|
|
);
|
|
return {
|
|
success: false,
|
|
message: String(respObj.msg || respObj.message || "提交失败"),
|
|
apiResponse: result,
|
|
};
|
|
}
|
|
}
|
|
|
|
logger.info(
|
|
"[Submitter] ============== SUBMISSION SUCCESSFUL ==============",
|
|
);
|
|
logger.info({ result }, "[Submitter] Submission API response content");
|
|
logger.info(
|
|
{ duration: requestDuration },
|
|
"[Submitter] Total duration (ms)",
|
|
);
|
|
sendUploadProgress("submit", 100, "投稿信息提交完成");
|
|
|
|
return { success: true, data: result };
|
|
} catch (err) {
|
|
logger.error(
|
|
"[Submitter] ============== EXCEPTION CAUGHT ==============",
|
|
);
|
|
logger.error(
|
|
{ errorType: (err as Error)?.name },
|
|
"[Submitter] Error type",
|
|
);
|
|
logger.error(
|
|
{ errorMessage: (err as Error)?.message },
|
|
"[Submitter] Error message",
|
|
);
|
|
logger.error(
|
|
{ errorStack: (err as Error)?.stack },
|
|
"[Submitter] Error stack",
|
|
);
|
|
|
|
return {
|
|
success: false,
|
|
message: (err as Error)?.message || "提交失败",
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("package-app", async (event, formData: unknown) => {
|
|
try {
|
|
const startTime = Date.now();
|
|
logger.info(
|
|
"[Submitter] ============== PACKAGE APP START ==============",
|
|
);
|
|
|
|
if (typeof formData !== "object" || formData === null) {
|
|
return { success: false, message: "表单数据格式错误" };
|
|
}
|
|
|
|
const dataObj = formData as Record<string, unknown>;
|
|
const pkgname = String(dataObj.pkgname || "");
|
|
const packageName = String(dataObj.name || "");
|
|
const packageVersion = String(dataObj.version || "");
|
|
const packageCategory = String(dataObj.category || "");
|
|
const packageAuthor = String(dataObj.author || "");
|
|
const packageContributor = String(dataObj.contributor || "");
|
|
const packageWebsite = String(dataObj.website || "");
|
|
const packageDescription = String(dataObj.description || "");
|
|
const packageTags = String(dataObj.tags || "");
|
|
const debFilePath = String(dataObj.debFilePath || "");
|
|
const iconPath = String(dataObj.iconPath || "");
|
|
const screenshots = Array.isArray(dataObj.screenshots)
|
|
? dataObj.screenshots
|
|
: [];
|
|
const storeArch = String(dataObj.storeArch || "store");
|
|
|
|
if (!debFilePath) return { success: false, message: "请选择 deb 文件" };
|
|
if (!fs.existsSync(debFilePath))
|
|
return { success: false, message: `deb 文件不存在: ${debFilePath}` };
|
|
if (!pkgname) return { success: false, message: "包名不能为空" };
|
|
|
|
const sendPackageProgress = (
|
|
step: string,
|
|
progress: number,
|
|
message: string,
|
|
) => {
|
|
try {
|
|
event.sender.send("package-progress", {
|
|
step,
|
|
progress,
|
|
message,
|
|
});
|
|
} catch (e) {
|
|
logger.warn({ e }, "[Submitter] Failed to send progress event");
|
|
}
|
|
};
|
|
|
|
const { exec } = await import("node:child_process");
|
|
const util = await import("util");
|
|
const execAsync = util.promisify(exec);
|
|
|
|
sendPackageProgress("init", 0, "正在创建临时目录...");
|
|
|
|
const { stdout: tempDir } = await execAsync("mktemp -d");
|
|
const baseTempDir = tempDir.trim();
|
|
logger.info({ baseTempDir }, "[Submitter] Temp directory created");
|
|
|
|
const packDir = path.join(baseTempDir, packageCategory, pkgname);
|
|
fs.mkdirSync(packDir, { recursive: true });
|
|
logger.info({ packDir }, "[Submitter] Package directory created");
|
|
|
|
sendPackageProgress("icon", 10, "正在处理图标...");
|
|
logger.info({ iconPath }, "[Submitter] Processing icon...");
|
|
if (iconPath) {
|
|
const iconDest = path.join(packDir, "icon.png");
|
|
if (iconPath.startsWith("http://") || iconPath.startsWith("https://")) {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
try {
|
|
const response = await fetch(iconPath, {
|
|
signal: controller.signal,
|
|
});
|
|
clearTimeout(timeout);
|
|
if (response.ok) {
|
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
fs.writeFileSync(iconDest, buffer);
|
|
} else {
|
|
return {
|
|
success: false,
|
|
message: `下载图标失败: ${response.status}`,
|
|
};
|
|
}
|
|
} catch (e) {
|
|
clearTimeout(timeout);
|
|
return {
|
|
success: false,
|
|
message: `下载图标失败: ${(e as Error).message}`,
|
|
};
|
|
}
|
|
} else if (iconPath.startsWith("data:")) {
|
|
const base64Data = iconPath.split(",")[1];
|
|
if (base64Data) {
|
|
fs.writeFileSync(iconDest, Buffer.from(base64Data, "base64"));
|
|
} else {
|
|
return { success: false, message: "图标数据格式错误" };
|
|
}
|
|
} else if (fs.existsSync(iconPath)) {
|
|
fs.copyFileSync(iconPath, iconDest);
|
|
} else {
|
|
return {
|
|
success: false,
|
|
message: `图标文件不存在: ${iconPath}`,
|
|
};
|
|
}
|
|
} else {
|
|
return { success: false, message: "请选择应用图标" };
|
|
}
|
|
logger.info("[Submitter] Icon processed");
|
|
|
|
sendPackageProgress("screenshots", 30, "正在处理截图...");
|
|
logger.info(
|
|
{ count: screenshots.length },
|
|
"[Submitter] Processing screenshots...",
|
|
);
|
|
const imgUrls: string[] = [];
|
|
const originUrl = `https://spk-json.spark-app.store/${storeArch}/${packageCategory}/${pkgname}`;
|
|
|
|
for (let i = 0; i < screenshots.length && i < 5; i++) {
|
|
const screenshot = screenshots[i];
|
|
const screenFileName = `screen_${i + 1}.png`;
|
|
const screenFilePath = path.join(packDir, screenFileName);
|
|
|
|
if (typeof screenshot === "string") {
|
|
if (
|
|
screenshot.startsWith("http://") ||
|
|
screenshot.startsWith("https://")
|
|
) {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
try {
|
|
const response = await fetch(screenshot, {
|
|
signal: controller.signal,
|
|
});
|
|
clearTimeout(timeout);
|
|
if (response.ok) {
|
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
fs.writeFileSync(screenFilePath, buffer);
|
|
}
|
|
} catch {
|
|
clearTimeout(timeout);
|
|
logger.warn(
|
|
{ screenshot },
|
|
"[Submitter] Failed to download screenshot, skipping",
|
|
);
|
|
}
|
|
} else if (screenshot.startsWith("data:")) {
|
|
const base64Data = screenshot.split(",")[1];
|
|
if (base64Data) {
|
|
fs.writeFileSync(
|
|
screenFilePath,
|
|
Buffer.from(base64Data, "base64"),
|
|
);
|
|
}
|
|
} else if (fs.existsSync(screenshot)) {
|
|
fs.copyFileSync(screenshot, screenFilePath);
|
|
}
|
|
imgUrls.push(`${originUrl}/${screenFileName}`);
|
|
}
|
|
}
|
|
logger.info("[Submitter] Screenshots processed");
|
|
|
|
sendPackageProgress("deb", 50, "正在复制安装包...");
|
|
logger.info({ debFilePath }, "[Submitter] Processing deb package...");
|
|
let debInfoRaw: string;
|
|
try {
|
|
const result = await execAsync(
|
|
`dpkg-deb -f "${debFilePath}" Package Version Architecture`,
|
|
);
|
|
debInfoRaw = result.stdout;
|
|
} catch (debErr) {
|
|
logger.error({ debErr }, "[Submitter] dpkg-deb failed");
|
|
return {
|
|
success: false,
|
|
message: `读取 deb 信息失败: ${(debErr as Error).message}`,
|
|
};
|
|
}
|
|
logger.info({ debInfoRaw }, "[Submitter] dpkg-deb output");
|
|
let debPkgName = pkgname;
|
|
let debVersion = packageVersion;
|
|
let debArch = "amd64";
|
|
|
|
for (const line of debInfoRaw.trim().split("\n")) {
|
|
const [key, ...valueParts] = line.split(":");
|
|
const value = valueParts.join(":").trim();
|
|
switch (key.trim()) {
|
|
case "Package":
|
|
debPkgName = value.toLowerCase();
|
|
break;
|
|
case "Version":
|
|
debVersion = value;
|
|
break;
|
|
case "Architecture":
|
|
debArch = value;
|
|
break;
|
|
}
|
|
}
|
|
logger.info(
|
|
{ debPkgName, debVersion, debArch },
|
|
"[Submitter] Deb metadata parsed",
|
|
);
|
|
|
|
const packDebFileName = `${debPkgName}_${debVersion}_${debArch}.deb`;
|
|
logger.info(
|
|
{
|
|
from: debFilePath,
|
|
to: path.join(packDir, packDebFileName),
|
|
},
|
|
"[Submitter] Copying deb file...",
|
|
);
|
|
fs.copyFileSync(debFilePath, path.join(packDir, packDebFileName));
|
|
logger.info({ packDebFileName }, "[Submitter] Deb copied");
|
|
|
|
const debFileStat = fs.statSync(debFilePath);
|
|
const sizeInMb = debFileStat.size / 1000 / 1000;
|
|
const sizeStr =
|
|
sizeInMb < 10
|
|
? `${Math.round(debFileStat.size / 1000)} Kb`
|
|
: `${Math.round(sizeInMb)} Mb`;
|
|
logger.info({ sizeStr }, "[Submitter] Deb size calculated");
|
|
|
|
sendPackageProgress("json", 70, "正在生成 app.json...");
|
|
logger.info("[Submitter] Generating app.json...");
|
|
|
|
const now = new Date();
|
|
const updateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;
|
|
|
|
const appJson = {
|
|
Name: packageName,
|
|
Version: packageVersion,
|
|
Filename: packDebFileName,
|
|
Torrent_address: `${packDebFileName}.torrent`,
|
|
Pkgname: pkgname,
|
|
Author: packageAuthor,
|
|
Contributor: packageContributor,
|
|
Website: packageWebsite,
|
|
Update: updateStr,
|
|
Size: sizeStr,
|
|
More: packageDescription,
|
|
Tags: packageTags,
|
|
img_urls: imgUrls,
|
|
icons: `${originUrl}/icon.png`,
|
|
};
|
|
|
|
fs.writeFileSync(
|
|
path.join(packDir, "app.json"),
|
|
JSON.stringify(appJson, null, 2),
|
|
);
|
|
logger.info("[Submitter] app.json written");
|
|
|
|
sendPackageProgress("tar", 85, "正在打包 tar.gz...");
|
|
logger.info("[Submitter] Starting tar...");
|
|
|
|
const tarFileName = `${pkgname}-${storeArch}.tar.gz`;
|
|
const tarOutputPath = path.join(baseTempDir, tarFileName);
|
|
try {
|
|
await execAsync(
|
|
`tar czf "${tarOutputPath}" -C "${baseTempDir}" "${packageCategory}"`,
|
|
);
|
|
logger.info({ tarOutputPath }, "[Submitter] tar command succeeded");
|
|
} catch (tarErr) {
|
|
logger.error({ tarErr }, "[Submitter] tar command failed");
|
|
return {
|
|
success: false,
|
|
message: `打包失败: ${(tarErr as Error).message}`,
|
|
};
|
|
}
|
|
|
|
if (!fs.existsSync(tarOutputPath)) {
|
|
return {
|
|
success: false,
|
|
message: "打包后的 tar.gz 文件未找到",
|
|
};
|
|
}
|
|
|
|
sendPackageProgress("done", 100, "打包完成!");
|
|
|
|
logger.info({ baseTempDir }, "[Submitter] Opening folder");
|
|
await shell.openPath(baseTempDir);
|
|
|
|
const duration = Date.now() - startTime;
|
|
logger.info(
|
|
{
|
|
tarOutputPath: tarOutputPath,
|
|
duration,
|
|
tempDir: baseTempDir,
|
|
},
|
|
"[Submitter] Package completed successfully",
|
|
);
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
tarPath: tarOutputPath,
|
|
tempDir: baseTempDir,
|
|
tarFileName,
|
|
},
|
|
};
|
|
} catch (err) {
|
|
logger.error(
|
|
"[Submitter] ============== PACKAGE EXCEPTION ==============",
|
|
);
|
|
logger.error(
|
|
{
|
|
errorMessage: (err as Error)?.message,
|
|
errorStack: (err as Error)?.stack,
|
|
},
|
|
"[Submitter] Exception caught",
|
|
);
|
|
return {
|
|
success: false,
|
|
message: (err as Error)?.message || "打包失败",
|
|
};
|
|
}
|
|
});
|
|
}
|