Files
spark-store/electron/main/index.ts
T
2026-08-18 05:50:21 +00:00

903 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
app,
BrowserWindow,
ipcMain,
Menu,
nativeImage,
net,
shell,
Tray,
nativeTheme,
screen,
session,
} from "electron";
import { fileURLToPath } from "node:url";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import pino from "pino";
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,
runSystemUpdateSources,
} from "./backend/update-center/index.js";
import {
getMainWindowCloseAction,
type MainWindowCloseGuardState,
} from "./window-close-guard.js";
import { registerSubmitterHandlers } from "./backend/submitter.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
process.env.APP_ROOT = path.join(__dirname, "../..");
/** 与项目 package.json 一致的版本号:打包用 app.getVersion(),未打包时读 package.json */
function getAppVersion(): string {
if (app.isPackaged) return app.getVersion();
const pkgPath = path.join(process.env.APP_ROOT ?? __dirname, "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 getSystemInfo(): { distro: string } {
try {
const raw = fs.readFileSync("/etc/os-release", "utf8");
const fields = Object.fromEntries(
raw
.split("\n")
.map((line) => line.match(/^([A-Z_]+)=(.*)$/))
.filter((match): match is RegExpMatchArray => match !== null)
.map((match) => [match[1], match[2].replace(/^"|"$/g, "")]),
);
const distro = fields.PRETTY_NAME || fields.NAME || "unknown";
return { distro };
} catch {
return { distro: "unknown" };
}
}
// 处理 --version 参数(在单实例检查之前)
if (process.argv.includes("--version") || process.argv.includes("-v")) {
console.log(getAppVersion());
process.exit(0);
}
// Assure single instance application
if (!app.requestSingleInstanceLock()) {
app.exit(0);
}
import "./backend/install-manager.js";
import "./handle-url-scheme.js";
// 关闭 Linux 的覆盖式(overlay)滚动条,强制使用经典滚动条,
// 否则 GTK overlay 滚动条会忽略渲染进程的 ::-webkit-scrollbar 颜色,
// 导致暗色模式下滚动条始终是原生灰色。必须在 app ready 前设置。
if (process.platform === "linux") {
app.commandLine.appendSwitch("disable-features", "OverlayScrollbar");
}
const logger = pino({ name: "index.ts" });
const FLARUM_TOKEN_URL = "https://bbs.spark-app.store/api/token";
// The built directory structure
//
// ├─┬ dist-electron
// │ ├─┬ main
// │ │ └── index.js > Electron-Main
// │ └─┬ preload
// │ └── index.mjs > Preload-Scripts
// ├─┬ dist
// │ └── index.html > Electron-Renderer
//
export const MAIN_DIST = path.join(process.env.APP_ROOT, "dist-electron");
export const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist");
export const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;
// 进程专属临时目录,避免多实例/残留进程互相影响
// 退出时由 will-quit 统一清理
export const TEMP_BASE = path.join(os.tmpdir(), `spark-store-${process.pid}`);
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
? path.join(process.env.APP_ROOT, "public")
: RENDERER_DIST;
// Disable GPU Acceleration for Windows 7
if (os.release().startsWith("6.1")) app.disableHardwareAcceleration();
// Set application name for Windows 10+ notifications
if (process.platform === "win32") app.setAppUserModelId(app.getName());
if (!app.requestSingleInstanceLock()) {
app.quit();
process.exit(0);
}
let win: BrowserWindow | null = null;
let allowAppExit = false;
const preload = path.join(__dirname, "../preload/index.mjs");
const indexHtml = path.join(RENDERER_DIST, "index.html");
const getUserAgent = (): string => {
return `Spark-Store/${getAppVersion()}`;
};
let submitterWin: BrowserWindow | null = null;
registerSubmitterHandlers(
preload,
indexHtml,
VITE_DEV_SERVER_URL,
() => submitterWin,
(w) => {
submitterWin = w;
},
);
logger.info("User Agent: " + getUserAgent());
/** 根据启动参数 --no-apm / --no-spark 决定只展示的来源 */
function getStoreFilterFromArgv(): "spark" | "apm" | "both" {
if (process.arch === "loong64") {
// Currently loong64 only have spark support,
// 但用户显式传入 --no-spark 时应允许回退到 apm
if (process.argv.includes("--no-spark")) return "apm";
return "spark";
} else {
const argv = process.argv;
const noApm = argv.includes("--no-apm");
const noSpark = argv.includes("--no-spark");
if (noApm && noSpark) return "both";
if (noApm) return "spark";
if (noSpark) return "apm";
return "both";
}
}
ipcMain.handle("get-store-filter", (): "spark" | "apm" | "both" =>
getStoreFilterFromArgv(),
);
// 渲染端在窗口尺寸变化时(包括无边框窗口鼠标拉边角)经此保存当前窗口尺寸
ipcMain.handle("save-window-bounds", (): boolean => {
if (win && !win.isDestroyed()) scheduleSaveBounds(win);
return true;
});
// 渲染端(设置页)切换界面整体缩放系数,经此实时应用到主窗口。
// factor 限定在 0.5~3 之间,避免极端值导致界面不可用。
ipcMain.handle("set-zoom-factor", (_event, factor: unknown): boolean => {
if (typeof factor !== "number" || !Number.isFinite(factor)) return false;
const clamped = Math.min(3, Math.max(0.5, factor));
if (win && !win.isDestroyed()) {
win.webContents.setZoomFactor(clamped);
return true;
}
return false;
});
ipcMain.handle("get-app-version", (): string => getAppVersion());
ipcMain.handle("get-system-info", (): { distro: string } => getSystemInfo());
ipcMain.handle("request-flarum-token", async (_event, payload: unknown) => {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
throw new Error("登录信息格式不正确,请重新输入。");
}
const credentials = payload as Record<string, unknown>;
if (
typeof credentials.identification !== "string" ||
typeof credentials.password !== "string"
) {
throw new Error("登录信息格式不正确,请重新输入。");
}
logger.info({ endpoint: FLARUM_TOKEN_URL }, "Requesting Flarum login token");
let response: Response;
try {
response = await fetch(FLARUM_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": getUserAgent(),
},
body: JSON.stringify({
identification: credentials.identification,
password: credentials.password,
}),
});
} catch (err) {
logger.error(
{ err, endpoint: FLARUM_TOKEN_URL },
"Flarum token request failed before response",
);
throw new Error("无法连接星火论坛,请检查网络后重试。");
}
if (!response.ok) {
logger.warn(
{ endpoint: FLARUM_TOKEN_URL, status: response.status },
"Flarum rejected login token request",
);
throw new Error("论坛登录失败,请检查账号和密码。");
}
const data = (await response.json()) as Record<string, unknown>;
const userId = data.userId ?? data.user_id;
if (
typeof data.token !== "string" ||
userId === undefined ||
userId === null
) {
logger.warn(
{
endpoint: FLARUM_TOKEN_URL,
hasToken: typeof data.token === "string" && data.token.length > 0,
hasUserId: userId !== undefined && userId !== null,
},
"Flarum token response missing required fields",
);
throw new Error("论坛登录响应异常,请稍后重试。");
}
return {
token: data.token,
userId: String(userId),
};
});
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();
};
const showAndFocusMainWindow = async (): Promise<void> => {
if (!win || win.isDestroyed()) {
// 等待窗口创建完成,创建失败时调用方可通过异常感知
await createWindow();
return;
}
if (win.isMinimized()) {
win.restore();
}
win.show();
win.setSkipTaskbar(false);
win.focus();
};
// 窗口尺寸持久化:保存/恢复上一次调整后的窗口大小,避免每次打开都使用默认尺寸
const DEFAULT_WINDOW_SIZE = { width: 1366, height: 768 };
const MIN_WINDOW_SIZE = { width: 800, height: 500 };
// 超过该尺寸的窗口(通常为全屏/最大化状态)在恢复时回退到默认尺寸,避免「启动即全屏、还原按钮失效」
const OVERSIZED_WINDOW_THRESHOLD = { width: 1600, height: 900 };
interface WindowState {
width?: number;
height?: number;
x?: number;
y?: number;
maximized?: boolean;
/** 界面整体缩放系数(Electron webContents.setZoomFactor),默认 1 */
zoomFactor?: number;
}
function getWindowStatePath(): string {
// 延迟到调用时再取 userData,避免在 app ready 之前调用 app.getPath 出错
return path.join(app.getPath("userData"), "window-state.json");
}
// 校验保存的窗口位置是否至少部分落在某个显示器可见区域内,避免窗口跑到屏幕外
function isVisible(bounds: WindowState): boolean {
// 解构为局部常量后,控制流收窄(const 不可变)可穿透到下方嵌套闭包,
// 消除 x/y/width/height 的 “可能为未定义” 告警
const { x, y, width, height } = bounds;
if (
x === undefined ||
y === undefined ||
width === undefined ||
height === undefined
) {
return false;
}
const displays = screen.getAllDisplays();
return displays.some((display) => {
const w = display.workArea;
const horizontally = x < w.x + w.width && x + width > w.x;
const vertically = y < w.y + w.height && y + height > w.y;
return horizontally && vertically;
});
}
function loadWindowState(): WindowState {
try {
const file = getWindowStatePath();
if (fs.existsSync(file)) {
const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as WindowState;
if (
parsed.width !== undefined &&
parsed.height !== undefined &&
parsed.width >= MIN_WINDOW_SIZE.width &&
parsed.height >= MIN_WINDOW_SIZE.height &&
isVisible(parsed)
) {
return parsed;
}
logger.warn({ parsed }, "已保存的窗口状态无效,使用默认尺寸");
}
} catch (err) {
logger.warn({ err }, "读取窗口状态失败,使用默认尺寸");
}
return {};
}
function saveWindowState(state: WindowState): void {
try {
fs.writeFileSync(getWindowStatePath(), JSON.stringify(state));
logger.info({ state }, "已保存窗口状态");
} catch (err) {
logger.warn({ err }, "保存窗口状态失败");
}
}
let saveBoundsTimer: NodeJS.Timeout | null = null;
function flushSaveBounds(): void {
if (saveBoundsTimer) {
clearTimeout(saveBoundsTimer);
saveBoundsTimer = null;
}
if (win && !win.isDestroyed()) {
const { width, height, x, y } = win.getBounds();
saveWindowState({
width,
height,
x,
y,
maximized: win.isMaximized(),
zoomFactor: win.webContents.getZoomFactor(),
});
}
}
function scheduleSaveBounds(winInstance: BrowserWindow): void {
if (saveBoundsTimer) clearTimeout(saveBoundsTimer);
saveBoundsTimer = setTimeout(() => {
if (winInstance.isDestroyed()) return;
const { width, height, x, y } = winInstance.getBounds();
saveWindowState({
width,
height,
x,
y,
maximized: winInstance.isMaximized(),
zoomFactor: winInstance.webContents.getZoomFactor(),
});
}, 400);
}
// 应用退出前立即持久化(防抖 400ms 可能在快速关闭时丢失最后一次状态)
app.on("before-quit", () => {
flushSaveBounds();
});
async function createWindow() {
const saved = loadWindowState();
// 恢复时:若上次窗口过大(>1600x900,通常为全屏/最大化),回退到默认尺寸并居中,
// 避免「启动即全屏、还原按钮失效」;其余情况保留上次记录的实际尺寸(并居中)。
// 放大/还原仍交由标题栏按钮控制。
const oversized =
(saved.width ?? 0) > OVERSIZED_WINDOW_THRESHOLD.width ||
(saved.height ?? 0) > OVERSIZED_WINDOW_THRESHOLD.height;
const restoredWidth = oversized
? DEFAULT_WINDOW_SIZE.width
: Math.max(saved.width ?? DEFAULT_WINDOW_SIZE.width, MIN_WINDOW_SIZE.width);
const restoredHeight = oversized
? DEFAULT_WINDOW_SIZE.height
: Math.max(
saved.height ?? DEFAULT_WINDOW_SIZE.height,
MIN_WINDOW_SIZE.height,
);
const mainWindow = new BrowserWindow({
title: "星火应用商店",
width: restoredWidth,
height: restoredHeight,
center: true,
minWidth: MIN_WINDOW_SIZE.width,
minHeight: MIN_WINDOW_SIZE.height,
frame: false,
autoHideMenuBar: true,
icon: path.join(process.env.VITE_PUBLIC, "favicon.ico"),
webPreferences: {
preload,
// Warning: Enable nodeIntegration and disable contextIsolation is not secure in production
// nodeIntegration: true,
// Consider using contextBridge.exposeInMainWorld
// Read more on https://www.electronjs.org/docs/latest/tutorial/context-isolation
// contextIsolation: false,
},
});
win = mainWindow;
// 启动即应用持久化的界面缩放系数(默认 1 = 100%)。
// 必须在 loadURL/loadFile 前设置,使首帧即按目标缩放渲染,避免闪烁。
const savedZoom =
typeof saved.zoomFactor === "number" &&
saved.zoomFactor >= 0.5 &&
saved.zoomFactor <= 3
? saved.zoomFactor
: 1;
mainWindow.webContents.setZoomFactor(savedZoom);
logger.info({ zoomFactor: savedZoom }, "已应用界面缩放系数");
// 设计意图(非缺陷,勿改为自动恢复 maximized):
// 启动时不自动恢复最大化状态。原因——最大化窗口在多数屏幕上 bounds 会超过
// OVERSIZED_WINDOW_THRESHOLD(1600x900),若强行恢复最大化会导致「启动即全屏、
// 还原按钮失效」的体验问题;故最大化/全屏状态在关闭后统一回退为默认尺寸并居中,
// 放大/还原交由标题栏按钮由用户主动控制。WindowState 仍保存 maximized 字段
// (供需要该信息的场景读取),但恢复阶段有意不调用 win.maximize()。
logger.info(
{ saved, restoredWidth, restoredHeight, oversized },
"已恢复窗口状态(过大窗口回退默认尺寸并居中;最大化状态有意不自动恢复)",
);
// 窗口大小/位置/最大化变化后防抖保存,下次启动时恢复
// 位置/最大化变化由主进程事件保存;尺寸变化由渲染端 DOM resize 经 IPC 兜底保存
mainWindow.on("moved", () => scheduleSaveBounds(mainWindow));
mainWindow.on("maximize", () => scheduleSaveBounds(mainWindow));
mainWindow.on("unmaximize", () => scheduleSaveBounds(mainWindow));
if (VITE_DEV_SERVER_URL) {
// #298
mainWindow.loadURL(VITE_DEV_SERVER_URL);
// Open devTool if the app is not packaged
mainWindow.webContents.openDevTools({ mode: "detach" });
} else {
mainWindow.loadFile(indexHtml);
}
// Test actively push message to the Electron-Renderer
mainWindow.webContents.on("did-finish-load", () => {
mainWindow.webContents.send(
"main-process-message",
new Date().toLocaleString(),
);
logger.info("Renderer process is ready.");
});
// 仅允许可信域名的 https 链接通过浏览器打开,避免钓鱼/恶意站。
// 协议前缀 + 域名后缀白名单双重校验;非法/无效 URL 一律拒绝。
const ALLOWED_EXTERNAL_HOSTS = [
"spark-app.store",
"gitee.com",
"bbs.spark-app.store",
"spark-app.cn",
];
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
try {
const parsed = new URL(url);
if (
parsed.protocol === "https:" &&
ALLOWED_EXTERNAL_HOSTS.some((h) => parsed.hostname === h || parsed.hostname.endsWith(`.${h}`))
) {
shell.openExternal(url);
}
} catch {
// 无效 URL,拒绝打开
}
return { action: "deny" };
});
// win.webContents.on('will-navigate', (event, url) => { }) #344
mainWindow.on("closed", () => {
if (win === mainWindow) {
win = null;
}
});
mainWindow.on("close", (event) => {
if (allowAppExit) {
// 真正退出前同步保存最终窗口尺寸(防抖可能尚未触发)
// 先清除尚未触发的防抖定时器,避免旧定时器随后覆盖本次同步写入
if (saveBoundsTimer) clearTimeout(saveBoundsTimer);
const { width, height, x, y } = mainWindow.getBounds();
saveWindowState({
width,
height,
x,
y,
maximized: mainWindow.isMaximized(),
});
return;
}
// 截获 close 默认行为
event.preventDefault();
applyMainWindowCloseAction();
});
}
ipcMain.on("renderer-ready", (event, args) => {
logger.info(
"Received renderer-ready event with args: " + JSON.stringify(args),
);
isLoaded.value = args.status;
logger.info(`isLoaded set to: ${isLoaded.value}`);
});
ipcMain.on("set-theme-source", (event, theme: "system" | "light" | "dark") => {
nativeTheme.themeSource = theme;
});
ipcMain.on("window-control-minimize", () => {
win?.minimize();
});
ipcMain.on("window-control-toggle-maximize", () => {
if (!win) {
return;
}
if (win.isMaximized()) {
win.unmaximize();
return;
}
win.maximize();
});
ipcMain.on("window-control-close", () => {
win?.close();
});
// 配置文件路径
const SPARK_CONFIG_DIR = path.join(
os.homedir(),
".config/spark-union/spark-store",
);
const UPDATE_CHECK_CONFIG = "ssshell-config-do-not-show-upgrade-notify";
const CREATE_DESKTOP_CONFIG = "ssshell-config-do-not-create-desktop";
// 获取安装设置
ipcMain.handle("get-install-settings", async () => {
try {
const result: Record<string, boolean> = {};
// 检查更新检测配置
result[UPDATE_CHECK_CONFIG] = fs.existsSync(
path.join(SPARK_CONFIG_DIR, UPDATE_CHECK_CONFIG),
);
// 检查自动创建桌面启动器配置
result[CREATE_DESKTOP_CONFIG] = fs.existsSync(
path.join(SPARK_CONFIG_DIR, CREATE_DESKTOP_CONFIG),
);
return { success: true, data: result };
} catch (err) {
logger.error({ err }, "Failed to get install settings");
return { success: false, message: (err as Error)?.message || String(err) };
}
});
// 设置安装设置
ipcMain.handle(
"set-install-settings",
async (
_event,
settings: {
[UPDATE_CHECK_CONFIG]?: boolean;
[CREATE_DESKTOP_CONFIG]?: boolean;
},
) => {
try {
// 确保配置目录存在
if (!fs.existsSync(SPARK_CONFIG_DIR)) {
fs.mkdirSync(SPARK_CONFIG_DIR, { recursive: true });
}
// 更新检测配置
const updateCheckPath = path.join(SPARK_CONFIG_DIR, UPDATE_CHECK_CONFIG);
if (settings[UPDATE_CHECK_CONFIG]) {
fs.writeFileSync(updateCheckPath, "");
} else {
if (fs.existsSync(updateCheckPath)) {
fs.unlinkSync(updateCheckPath);
}
}
// 自动创建桌面启动器配置
const createDesktopPath = path.join(
SPARK_CONFIG_DIR,
CREATE_DESKTOP_CONFIG,
);
if (settings[CREATE_DESKTOP_CONFIG]) {
fs.writeFileSync(createDesktopPath, "");
} else {
if (fs.existsSync(createDesktopPath)) {
fs.unlinkSync(createDesktopPath);
}
}
return { success: true };
} catch (err) {
logger.error({ err }, "Failed to set install settings");
return {
success: false,
message: (err as Error)?.message || String(err),
};
}
},
);
// 检查更新
ipcMain.handle("check-for-updates", async () => {
try {
const { spawn } = await import("node:child_process");
const scriptPath =
"/opt/durapps/spark-store/bin/update-upgrade/ss-do-upgrade.sh";
const child = spawn("systemd-run", ["--user", scriptPath], {
detached: true,
stdio: "ignore",
});
child.unref();
logger.info(`Launched update check script: ${scriptPath}`);
return { success: true };
} catch (err) {
logger.error({ err }, "Failed to launch update check script");
return { success: false, message: (err as Error)?.message || String(err) };
}
});
// 启动投稿器窗口
// Register custom protocol handlers
if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient("spk", process.execPath, [
path.resolve(process.argv[1]),
]);
app.setAsDefaultProtocolClient("apt", process.execPath, [
path.resolve(process.argv[1]),
]);
}
} else {
app.setAsDefaultProtocolClient("spk");
app.setAsDefaultProtocolClient("apt");
}
app.whenReady().then(() => {
// Set User-Agent for client
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
details.requestHeaders["User-Agent"] = getUserAgent();
// 数据 JSONapplist / categories / priority-config / sidebar-config 等)禁用客户端缓存。
// nginx 默认不发 Cache-ControlChromium 会按启发式 TTL(≈(now-LastModified)/10)复用陈旧副本,
// 导致服务端上新应用后商店内仍搜不到、且重启无效(仅删 Cache 目录才生效)。
// 注意:C2 会给 URL 追加 ?_t=... 版本戳,故需按 query 前的 pathname 判断,而非 endsWith(".json")。
const dataUrlPath = details.url.split("?")[0];
if (dataUrlPath.endsWith(".json")) {
details.requestHeaders["Cache-Control"] = "no-cache";
details.requestHeaders["Pragma"] = "no-cache";
}
callback({ cancel: false, requestHeaders: details.requestHeaders });
});
createWindow();
handleCommandLine(process.argv);
initializeUpdateCenter();
// 启动后执行一次遥测(仅 Linux,不阻塞)
sendTelemetryOnce(getAppVersion());
// 注册“渲染进程首页加载完成”信号:收到后立即开始后台刷新软件源,
// 趁系统负载不高时提前刷新 aptss/apm 源,用户稍后打开“软件更新”即可秒出。
// 同时保留一个兜底定时器,防止渲染进程未发信号时完全不刷新。
ipcMain.on("update-center-trigger-prefetch", () => {
startSourcePreRefreshOnce();
});
setTimeout(startSourcePreRefreshOnce, PRE_REFRESH_FALLBACK_MS);
});
// 启动后空闲预刷新软件源(带重试),不阻塞启动流程
// 与 src/modules/updateCenter.ts 的 backgroundRefresh 重试为【对称设计】,非代码遗漏:
// 主进程负责“启动预热”,前端负责“打开兜底”,
// 两者进程/守卫/调用目标不同,故各自保留一份,勿抽共享。
const PRE_REFRESH_BACKOFF_MS = [2000, 4000, 8000];
const PRE_REFRESH_MAX_RETRIES = 3;
const PRE_REFRESH_FALLBACK_MS = 15_000; // 渲染信号未到达时的兜底,15s 后也跑
const PRE_REFRESH_TIMEOUT_MS = 60_000; // 单次预刷新整体超时,避免 pkexec 卡死挂起
let preRefreshStarted = false;
// 按重试次数取退避毫秒(越界时回退到最大间隔)
const getPreRefreshBackoffDelay = (attempt: number): number =>
PRE_REFRESH_BACKOFF_MS[attempt - 1] ??
PRE_REFRESH_BACKOFF_MS[PRE_REFRESH_BACKOFF_MS.length - 1];
// 确保预刷新只触发一次(渲染信号或兜底定时器 whichever first)。
// 设计意图:单次会话仅预热一次(preRefreshStarted 置 true 后不再重置),
// 避免用户在更新中心 Tab 间快速切换时重复触发 pkexec 弹窗造成困惑。
const startSourcePreRefreshOnce = (attempt = 1): void => {
if (preRefreshStarted) return;
preRefreshStarted = true;
const run = (): void => {
// 网络可用性前置检查:离线时不发起提权刷新(pkexec 弹窗无意义且困惑)
if (!net.isOnline()) {
logger.info("[UpdateCenter] 预刷新跳过:当前离线");
return;
}
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("pre-refresh timeout")),
PRE_REFRESH_TIMEOUT_MS,
),
);
Promise.race([runSystemUpdateSources("both"), timeoutPromise])
.then((results) => {
console.log("[UpdateCenter] pre-refresh done:", results);
})
.catch((error) => {
console.warn("[UpdateCenter] pre-refresh failed:", error);
if (attempt < PRE_REFRESH_MAX_RETRIES) {
const delay = getPreRefreshBackoffDelay(attempt);
setTimeout(() => startSourcePreRefreshOnce(attempt + 1), delay);
}
});
};
run();
};
app.on("window-all-closed", () => {
win = null;
allowAppExit = false;
if (process.platform !== "darwin") app.quit();
});
app.on("second-instance", () => {
void showAndFocusMainWindow();
});
app.on("activate", () => {
void showAndFocusMainWindow();
});
app.on("will-quit", () => {
// 清理本进程专属临时目录(PID 隔离,不影响其他实例)
logger.info("Cleaning up temp dir");
fs.rmSync(TEMP_BASE, { recursive: true, force: true });
logger.info("Done, exiting");
});
// 设置托盘:系统中应用名称为 spark-store,图标优先 spark-store,其次 spark-store.svg,再次替代图标
const ICONS_DIR = app.isPackaged
? path.join(process.resourcesPath, "icons")
: path.join(__dirname, "../..", "icons");
function resolveIconPath(filename: string): string {
return path.join(ICONS_DIR, filename);
}
/** 按优先级返回托盘图标路径:spark-store(.png|.ico) → amber-pm-logo.png。托盘不支持 SVG,故不尝试 spark-store.svg */
function getTrayIconPath(): string | null {
const ext = process.platform === "win32" ? ".ico" : ".png";
const candidates = [`spark-store${ext}`];
for (const name of candidates) {
const iconPath = resolveIconPath(name);
if (fs.existsSync(iconPath)) {
logger.info("托盘图标使用: " + iconPath);
return iconPath;
}
}
logger.warn("未找到托盘图标,将使用替代图标。查找目录: " + ICONS_DIR);
return null;
}
/** 16x16 透明 PNG,用作托盘无图标时的替代 */
const FALLBACK_TRAY_PNG =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAHklEQVQ4T2NkYGD4z0ABYBwNwMAwGoChNQAAAABJRU5ErkJggg==";
function getTrayImage():
| string
| ReturnType<typeof nativeImage.createFromDataURL> {
const iconPath = getTrayIconPath();
if (iconPath) return iconPath;
return nativeImage.createFromDataURL(FALLBACK_TRAY_PNG);
}
let tray: Tray | null = null;
app.whenReady().then(() => {
tray = new Tray(getTrayImage());
const contextMenu = Menu.buildFromTemplate([
{
label: "显示主界面",
click: () => {
void showAndFocusMainWindow();
},
},
{
label: "退出程序",
click: () => {
requestApplicationExit();
},
},
]);
tray.setToolTip("星火应用商店");
tray.setContextMenu(contextMenu);
// 双击触发
tray.on("click", () => {
// 双击通知区图标实现应用的显示或隐藏
if (win && !win.isDestroyed() && win.isVisible()) {
win.hide();
win.setSkipTaskbar(true);
} else {
void showAndFocusMainWindow();
}
});
});
ipcMain.handle('window:state', () => {
const win = BrowserWindow.getFocusedWindow()
if (!win) return 'normal'
if (win.isMaximized()) return 'maximized'
if (win.isMinimized()) return 'minimized'
return 'normal'
})
// New window example arg: new windows url
// ipcMain.handle('open-win', (_, arg) => {
// const childWindow = new BrowserWindow({
// webPreferences: {
// preload,
// nodeIntegration: true,
// contextIsolation: false,
// },
// })
// if (VITE_DEV_SERVER_URL) {
// childWindow.loadURL(`${VITE_DEV_SERVER_URL}#${arg}`)
// } else {
// childWindow.loadFile(indexHtml, { hash: arg })
// }
// })