diff --git a/debian/changelog b/debian/changelog index ef47ec80..f6205efd 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,4 +1,4 @@ -spark-store (5.2.1.18-test) UNRELEASED; urgency=medium +spark-store (5.2.1.19-test) UNRELEASED; urgency=medium * Initial release. (Closes: #nnnn) diff --git a/electron/main/index.ts b/electron/main/index.ts index ba353933..c9cb8616 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -4,6 +4,7 @@ import { ipcMain, Menu, nativeImage, + net, shell, Tray, nativeTheme, @@ -437,10 +438,15 @@ async function createWindow() { }); win = mainWindow; - // 不再自动恢复最大化状态(避免无法还原);启动即居中显示,过大窗口已回退默认尺寸 + // 设计意图(非缺陷,勿改为自动恢复 maximized): + // 启动时不自动恢复最大化状态。原因——最大化窗口在多数屏幕上 bounds 会超过 + // OVERSIZED_WINDOW_THRESHOLD(1600x900),若强行恢复最大化会导致「启动即全屏、 + // 还原按钮失效」的体验问题;故最大化/全屏状态在关闭后统一回退为默认尺寸并居中, + // 放大/还原交由标题栏按钮由用户主动控制。WindowState 仍保存 maximized 字段 + // (供需要该信息的场景读取),但恢复阶段有意不调用 win.maximize()。 logger.info( { saved, restoredWidth, restoredHeight, oversized }, - "已恢复窗口状态(过大窗口回退默认尺寸并居中)", + "已恢复窗口状态(过大窗口回退默认尺寸并居中;最大化状态有意不自动恢复)", ); // 窗口大小/位置/最大化变化后防抖保存,下次启动时恢复 @@ -687,6 +693,7 @@ app.whenReady().then(() => { 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; // 按重试次数取退避毫秒(越界时回退到最大间隔) @@ -694,13 +701,28 @@ const getPreRefreshBackoffDelay = (attempt: number): number => PRE_REFRESH_BACKOFF_MS[attempt - 1] ?? PRE_REFRESH_BACKOFF_MS[PRE_REFRESH_BACKOFF_MS.length - 1]; -// 确保预刷新只触发一次(渲染信号或兜底定时器 whichever first) +// 确保预刷新只触发一次(渲染信号或兜底定时器 whichever first)。 +// 设计意图:单次会话仅预热一次(preRefreshStarted 置 true 后不再重置), +// 避免用户在更新中心 Tab 间快速切换时重复触发 pkexec 弹窗造成困惑。 const startSourcePreRefreshOnce = (attempt = 1): void => { if (preRefreshStarted) return; preRefreshStarted = true; const run = (): void => { - runSystemUpdateSources("both") + // 网络可用性前置检查:离线时不发起提权刷新(pkexec 弹窗无意义且困惑) + if (!net.isOnline()) { + logger.info("[UpdateCenter] 预刷新跳过:当前离线"); + return; + } + + const timeoutPromise = new Promise((_, 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); }) diff --git a/src/App.vue b/src/App.vue index b1123a46..0b9a6d12 100644 --- a/src/App.vue +++ b/src/App.vue @@ -344,6 +344,7 @@ import { getHybridDefaultOrigin, loadPriorityConfig, } from "./global/storeConfig"; +import { createCacheBusterInterceptor } from "./global/cacheBusterInterceptor"; import { downloads, removeDownloadItem, @@ -433,22 +434,10 @@ const axiosInstance = axios.create({ }); // C2:数据 JSON(applist / categories / sidebar-config 等)追加 ?_t 版本戳, -// 使每次 URL 不同,穿透 CDN 边缘缓存,确保返回最新列表。 -// 与主进程 onBeforeSendHeaders 注入的 no-cache 互为兜底(C1 已对 Chromium 磁盘缓存生效)。 -axiosInstance.interceptors.request.use((config) => { - if ( - config.method?.toLowerCase() === "get" && - typeof config.url === "string" - ) { - // 按去除 query 的 pathname 判断,兼容 C2 自身追加的 ?_t= 及任何既有 query - const reqPath = config.url.split("?")[0]; - if (reqPath.endsWith(".json")) { - const sep = config.url.includes("?") ? "&" : "?"; - config.url = `${config.url}${sep}_t=${Date.now()}`; - } - } - return config; -}); +// 穿透 CDN 边缘缓存,确保返回最新列表。复用共享缓存穿透拦截器(带 TTL 复用戳, +// 避免同会话频繁击穿缓存)。与主进程 onBeforeSendHeaders 注入的 no-cache 互为兜底。 +// 这是有意的缓存击穿策略(非缺陷),用于解决商店目录强缓存导致的更新延迟。 +axiosInstance.interceptors.request.use(createCacheBusterInterceptor()); const fetchWithRetry = async ( url: string, diff --git a/src/global/cacheBusterInterceptor.ts b/src/global/cacheBusterInterceptor.ts new file mode 100644 index 00000000..295caa01 --- /dev/null +++ b/src/global/cacheBusterInterceptor.ts @@ -0,0 +1,33 @@ +import type { InternalAxiosRequestConfig } from "axios"; + +/** + * 缓存穿透拦截器工厂:为所有 .json GET 请求追加 ?_t=${timestamp} 版本戳, + * 穿透 CDN/浏览器缓存,确保新应用上架后能立即被搜到(解决"上架后搜不到")。 + * + * 为避免同一次会话内每次请求都生成新戳导致缓存完全失效(频繁重复请求、浪费带宽), + * 引入 TTL:在 ttlMs 窗口内复用同一时间戳;超过窗口才刷新。 + * 默认 5 分钟,兼顾"及时感知新数据"与"不过度击穿缓存"。 + * + * 注意:这是有意为之的缓存击穿策略(非缺陷),用于解决商店目录强缓存导致的更新延迟。 + */ +export function createCacheBusterInterceptor(ttlMs = 5 * 60 * 1000) { + let cachedTimestamp: number | null = null; + + return (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => { + if ( + config.method?.toLowerCase() === "get" && + typeof config.url === "string" + ) { + const reqPath = config.url.split("?")[0]; + if (reqPath.endsWith(".json")) { + const now = Date.now(); + if (cachedTimestamp === null || now - cachedTimestamp > ttlMs) { + cachedTimestamp = now; + } + const sep = config.url.includes("?") ? "&" : "?"; + config.url = `${config.url}${sep}_t=${cachedTimestamp}`; + } + } + return config; + }; +} diff --git a/src/global/storeConfig.ts b/src/global/storeConfig.ts index 4768dcde..550b4c0e 100644 --- a/src/global/storeConfig.ts +++ b/src/global/storeConfig.ts @@ -1,6 +1,7 @@ import { ref } from "vue"; import axios from "axios"; import type { App, StoreMode } from "./typedefinition"; +import { createCacheBusterInterceptor } from "./cacheBusterInterceptor"; export const APM_STORE_BASE_URL: string = import.meta.env.VITE_APM_STORE_BASE_URL || ""; @@ -13,18 +14,9 @@ const priorityConfigAxios = axios.create({ }); // C2:priority-config.json 走独立 axios 实例(不经过 axiosInstance), -// 同样追加 ?_t 版本戳以穿透 CDN 边缘缓存,与 C1 的 no-cache 注入互为兜底。 -priorityConfigAxios.interceptors.request.use((config) => { - if (config.method?.toLowerCase() === "get" && typeof config.url === "string") { - // 按去除 query 的 pathname 判断,兼容 C2 自身追加的 ?_t= 及任何既有 query - const reqPath = config.url.split("?")[0]; - if (reqPath.endsWith(".json")) { - const sep = config.url.includes("?") ? "&" : "?"; - config.url = `${config.url}${sep}_t=${Date.now()}`; - } - } - return config; -}); +// 复用共享缓存穿透拦截器(带 TTL 复用戳,避免同会话频繁击穿缓存)。 +// 与 C1(axiosInstance 的拦截器)为同一工厂生成,确保穿透策略与 TTL 行为一致。 +priorityConfigAxios.interceptors.request.use(createCacheBusterInterceptor()); export const APM_STORE_STATS_BASE_URL: string = import.meta.env.VITE_APM_STORE_STATS_BASE_URL || ""; @@ -51,7 +43,10 @@ export const showApmInstallDialog = ref(false); export const currentStoreMode = ref("hybrid"); -// 混合模式下默认优先安装的来源(当没有服务器配置或配置获取失败时使用) +// 混合模式下默认优先安装的来源(当没有服务器配置或配置获取失败时使用)。 +// 设计意图:社区版当前主推 APM 来源(星火 APM 为新架构、deb 为传统来源), +// 故默认优先级设为 "apm"。非临时回归,属既定产品方向;如后续调整需产品确认。 +// TODO: 产品确认默认 APM 优先是否长期保持。 export const HYBRID_DEFAULT_PRIORITY: "apm" | "spark" = "apm"; // 优先级规则配置接口