diff --git a/README.md b/README.md index 9d6c611..346b3d3 100644 --- a/README.md +++ b/README.md @@ -76,10 +76,17 @@ npm run build:release npm run package:internal ``` +生成更新清单: + +```bash +npm run write:latest +``` + 生成结果: - 构建目录:`dist-release/` - 压缩包:`release/star-chart-search-enhancer-internal.zip` +- 更新清单:`release/latest.json` 说明: diff --git a/dist-release/background/index.js b/dist-release/background/index.js index df638d0..0ed7e76 100644 --- a/dist-release/background/index.js +++ b/dist-release/background/index.js @@ -3203,6 +3203,18 @@ }); return true; } + if (isDownloadUpdateMessage(message2)) { + void triggerUpdateDownload(chromeLike, message2).then(() => { + sendResponse({ ok: true, type: "update:download-ack" }); + }).catch((error) => { + sendResponse({ + error: error instanceof Error ? error.message : String(error), + ok: false, + type: "update:download-error" + }); + }); + return true; + } if (isBatchSubmitMessage(message2)) { authController ??= createAuthController({ authClient: createLogtoAuthClient() @@ -3270,6 +3282,18 @@ return true; }); } + async function triggerUpdateDownload(chromeLike, message2) { + if (!chromeLike.downloads?.download) { + throw new Error("chrome.downloads.download is unavailable"); + } + await Promise.resolve( + chromeLike.downloads.download({ + filename: message2.filename, + saveAs: true, + url: message2.url + }) + ); + } async function handleAuthMessage(authController, message2) { if (message2.type === "auth:get-state") { return { @@ -3323,6 +3347,13 @@ const candidate = message2; return candidate.type === "download-market-csv" && typeof candidate.csv === "string" && typeof candidate.filename === "string"; } + function isDownloadUpdateMessage(message2) { + if (!message2 || typeof message2 !== "object") { + return false; + } + const candidate = message2; + return candidate.type === "update:download" && typeof candidate.filename === "string" && typeof candidate.url === "string" && candidate.url.startsWith("https://"); + } function isBatchSubmitMessage(message2) { if (!message2 || typeof message2 !== "object") { return false; diff --git a/dist-release/manifest.json b/dist-release/manifest.json index 9df1c42..669a3bb 100644 --- a/dist-release/manifest.json +++ b/dist-release/manifest.json @@ -53,6 +53,7 @@ "https://*.xingtu.cn/ad/creator/market*", "https://login-api.intelligrow.cn/*", "https://talent-search.intelligrow.cn/*", - "http://192.168.31.21:8083/*" + "http://192.168.31.21:8083/*", + "https://*/*" ] } diff --git a/dist-release/popup/index.js b/dist-release/popup/index.js index 2003be9..2c2f7b5 100644 --- a/dist-release/popup/index.js +++ b/dist-release/popup/index.js @@ -19,10 +19,74 @@

\u5DF2\u767B\u5F55

${userInfo?.name ?? userInfo?.username ?? "\u672A\u77E5\u7528\u6237"}

${userInfo?.email ?? ""}

+
+

\u7248\u672C\u66F4\u65B0

+

\u6B63\u5728\u68C0\u67E5\u66F4\u65B0...

+
`; } + function renderUpdateStatus(root, options) { + const container = root.querySelector('[data-popup-update="root"]'); + if (!container) { + return; + } + if (options.status === "checking") { + container.innerHTML = ` +

\u7248\u672C\u66F4\u65B0

+

\u5F53\u524D\u7248\u672C\uFF1A${options.currentVersion}

+

\u6B63\u5728\u68C0\u67E5\u66F4\u65B0...

+ `; + return; + } + if (options.status === "error") { + container.innerHTML = ` +

\u7248\u672C\u66F4\u65B0

+

\u5F53\u524D\u7248\u672C\uFF1A${options.currentVersion}

+

\u6682\u65F6\u65E0\u6CD5\u68C0\u67E5\u66F4\u65B0

+

\u5982\u679C\u9700\u8981\u65B0\u7248\uFF0C\u8BF7\u8054\u7CFB\u7EF4\u62A4\u540C\u4E8B\u83B7\u53D6\u66F4\u65B0\u5305\u3002

+ `; + return; + } + if (options.status === "latest" || !options.manifest) { + container.innerHTML = ` +

\u7248\u672C\u66F4\u65B0

+

\u5F53\u524D\u7248\u672C\uFF1A${options.currentVersion}

+

\u5F53\u524D\u5DF2\u662F\u6700\u65B0\u7248\u672C

+ `; + return; + } + container.innerHTML = ` +

\u7248\u672C\u66F4\u65B0

+

\u5F53\u524D\u7248\u672C\uFF1A${options.currentVersion}

+

\u53D1\u73B0\u65B0\u7248\u672C\uFF1A${options.manifest.latestVersion}

+ ${renderReleaseNotes(options.manifest.releaseNotes)} + + +

\u4E0B\u8F7D\u540E\u8BF7\u89E3\u538B\u65B0\u7248 zip\uFF0C\u5E76\u5728 chrome://extensions \u91CC\u91CD\u65B0\u52A0\u8F7D\u63D2\u4EF6\u3002

+ `; + } + function setUpdateDownloadStatus(root, value) { + const output = root.querySelector('[data-popup-update-download-status="text"]'); + if (!output) { + return; + } + output.textContent = value; + } + function renderReleaseNotes(releaseNotes) { + if (releaseNotes.length === 0) { + return ""; + } + return ` + + `; + } + function escapeHtml(value) { + return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } function renderDevPanel(root, authState) { const panel = root.ownerDocument.createElement("section"); panel.dataset.popupDevPanel = "root"; @@ -134,10 +198,78 @@ return response.value.accessToken; } + // src/shared/update-check.ts + function compareExtensionVersions(left, right) { + const leftParts = parseVersionParts(left); + const rightParts = parseVersionParts(right); + const maxLength = Math.max(leftParts.length, rightParts.length); + for (let index = 0; index < maxLength; index += 1) { + const leftValue = leftParts[index] ?? 0; + const rightValue = rightParts[index] ?? 0; + if (leftValue !== rightValue) { + return leftValue - rightValue; + } + } + return 0; + } + function parseUpdateManifest(value) { + if (!value || typeof value !== "object") { + return null; + } + const candidate = value; + if (!isVersionString(candidate.latestVersion) || !isVersionString(candidate.minSupportedVersion) || !isHttpsUrl(candidate.zipUrl) || !isHttpsUrl(candidate.guideUrl) || typeof candidate.publishedAt !== "string" || !Array.isArray(candidate.releaseNotes) || !candidate.releaseNotes.every((note) => typeof note === "string")) { + return null; + } + return { + guideUrl: candidate.guideUrl, + latestVersion: candidate.latestVersion, + minSupportedVersion: candidate.minSupportedVersion, + publishedAt: candidate.publishedAt, + releaseNotes: candidate.releaseNotes, + zipUrl: candidate.zipUrl + }; + } + async function fetchUpdateManifest(manifestUrl, fetchImpl = fetch) { + const response = await fetchImpl(manifestUrl, { + cache: "no-store" + }); + if (!response.ok) { + throw new Error(`update manifest request failed: ${response.status}`); + } + const manifest = parseUpdateManifest(await response.json()); + if (!manifest) { + throw new Error("update manifest is invalid"); + } + return manifest; + } + function parseVersionParts(value) { + return value.split(".").map((part) => { + const parsed = Number.parseInt(part, 10); + return Number.isFinite(parsed) ? parsed : 0; + }); + } + function isVersionString(value) { + return typeof value === "string" && /^\d+(?:\.\d+)*$/.test(value); + } + function isHttpsUrl(value) { + if (typeof value !== "string") { + return false; + } + try { + return new URL(value).protocol === "https:"; + } catch { + return false; + } + } + + // src/shared/update-config.ts + var UPDATE_MANIFEST_URL = "https://example.com/star-chart-search-enhancer/latest.json"; + // src/popup/index.ts async function bootPopup(options = {}) { const currentDocument = options.document ?? document; const popupConfig = readAuthConfig(options.config); + const currentVersion = options.currentVersion ?? readCurrentVersion(); const root = currentDocument.querySelector("#app"); const HTMLElementCtor = currentDocument.defaultView?.HTMLElement; if (!root || HTMLElementCtor && !(root instanceof HTMLElementCtor)) { @@ -150,9 +282,15 @@ baseUrl: "http://127.0.0.1:4319", sendMessage }).loadProtectedMockData; - await renderCurrentAuthState(root, popupConfig, sendMessage, fetchProtectedApi); + const fetchUpdateManifest2 = options.fetchUpdateManifest ?? (() => fetchUpdateManifest( + options.updateManifestUrl ?? UPDATE_MANIFEST_URL + )); + await renderCurrentAuthState(root, popupConfig, sendMessage, fetchProtectedApi, { + currentVersion, + fetchUpdateManifest: fetchUpdateManifest2 + }); } - async function renderCurrentAuthState(root, popupConfig, sendMessage, fetchProtectedApi) { + async function renderCurrentAuthState(root, popupConfig, sendMessage, fetchProtectedApi, updateOptions) { const response = await sendMessage({ type: "auth:get-state" }); if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") { renderLoggedOut(root, "\u8BA4\u8BC1\u72B6\u6001\u8BFB\u53D6\u5931\u8D25"); @@ -163,16 +301,19 @@ root.querySelector('[data-popup-sign-in="button"]')?.addEventListener("click", () => { void runAuthAction(root, popupConfig, sendMessage, { actionMessage: { type: "auth:sign-in" }, - fetchProtectedApi + fetchProtectedApi, + updateOptions }); }); return; } renderLoggedIn(root, response.value); + void runUpdateCheck(root, sendMessage, updateOptions); root.querySelector('[data-popup-sign-out="button"]')?.addEventListener("click", () => { void runAuthAction(root, popupConfig, sendMessage, { actionMessage: { type: "auth:sign-out" }, - fetchProtectedApi + fetchProtectedApi, + updateOptions }); }); if (popupConfig.enableDevAuthPanel) { @@ -195,12 +336,74 @@ root, popupConfig, sendMessage, - options.fetchProtectedApi + options.fetchProtectedApi, + options.updateOptions ); } function isActionError(response) { return isAuthResponseMessage(response) && !response.ok && response.type === "auth:error"; } + async function runUpdateCheck(root, sendMessage, options) { + renderUpdateStatus(root, { + currentVersion: options.currentVersion, + status: "checking" + }); + try { + const manifest = await options.fetchUpdateManifest(); + if (compareExtensionVersions(manifest.latestVersion, options.currentVersion) <= 0) { + renderUpdateStatus(root, { + currentVersion: options.currentVersion, + status: "latest" + }); + return; + } + renderUpdateStatus(root, { + currentVersion: options.currentVersion, + manifest, + status: "available" + }); + bindUpdateDownloadButtons(root, sendMessage, manifest); + } catch { + renderUpdateStatus(root, { + currentVersion: options.currentVersion, + status: "error" + }); + } + } + function bindUpdateDownloadButtons(root, sendMessage, manifest) { + root.querySelector('[data-popup-download-update="button"]')?.addEventListener("click", () => { + void downloadUpdateAsset(root, sendMessage, { + filename: "star-chart-search-enhancer-internal.zip", + url: manifest.zipUrl + }); + }); + root.querySelector('[data-popup-download-guide="button"]')?.addEventListener("click", () => { + void downloadUpdateAsset(root, sendMessage, { + filename: "\u661F\u56FE\u589E\u5F3A\u63D2\u4EF6-\u8D85\u7B80\u5355\u5B89\u88C5\u4F7F\u7528\u6307\u5357.pdf", + url: manifest.guideUrl + }); + }); + } + async function downloadUpdateAsset(root, sendMessage, options) { + setUpdateDownloadStatus(root, "\u6B63\u5728\u4E0B\u8F7D..."); + try { + await sendMessage({ + filename: options.filename, + type: "update:download", + url: options.url + }); + setUpdateDownloadStatus(root, "\u5DF2\u89E6\u53D1\u4E0B\u8F7D\u3002\u4E0B\u8F7D\u540E\u8BF7\u89E3\u538B\u65B0\u7248 zip\uFF0C\u5E76\u5728 chrome://extensions \u91CC\u91CD\u65B0\u52A0\u8F7D\u63D2\u4EF6\u3002"); + } catch (error) { + setUpdateDownloadStatus( + root, + error instanceof Error ? error.message : "\u4E0B\u8F7D\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5" + ); + } + } + function readCurrentVersion() { + const runtime = globalThis.chrome?.runtime; + return runtime?.getManifest?.().version ?? "0.0.0"; + } async function runProtectedApiProbe(root, fetchProtectedApi) { setProtectedApiResult(root, "\u8BF7\u6C42\u4E2D..."); try { diff --git a/docs/aigc-user-guide.md b/docs/aigc-user-guide.md index 5f5abef..572092a 100644 --- a/docs/aigc-user-guide.md +++ b/docs/aigc-user-guide.md @@ -369,12 +369,31 @@ CSV 会包含: ## 十三、如何更新插件 +插件弹窗会检查是否有新版本。 + +### 方法一:从插件弹窗下载新版本 + +1. 点击浏览器右上角的插件图标 +2. 查看 `版本更新` 区域 +3. 如果提示发现新版本,点击: + - `下载更新包` + - `下载使用说明` +4. 解压下载到的新版本 zip +5. 打开: + - `chrome://extensions` +6. 找到: + - `Star Chart Search Enhancer` +7. 点击: + - `重新加载` + +如果没有看到新版本提示,可能是网络暂时无法访问更新清单,也可能当前已经是最新版本。 + +### 方法二:收到压缩包后手动更新 + 当你收到新的插件压缩包时,不需要重新从零安装。 按照下面做: -### 方法一:替换文件夹后重新加载 - 1. 删除旧的解压文件夹,或用新的内容覆盖旧文件夹 2. 打开: - `chrome://extensions` @@ -383,7 +402,7 @@ CSV 会包含: 4. 点击: - `重新加载` -### 方法二:重新解压到新文件夹再重新加载 +### 方法三:重新解压到新文件夹再重新加载 1. 解压新的压缩包 2. 打开: diff --git a/docs/internal-extension-distribution.md b/docs/internal-extension-distribution.md index 6d1cf4f..65b3042 100644 --- a/docs/internal-extension-distribution.md +++ b/docs/internal-extension-distribution.md @@ -13,7 +13,28 @@ 1. Run `npm test`. 2. Run `npm run package:internal`. -3. Send `release/star-chart-search-enhancer-internal.zip` to coworkers. +3. Run `npm run write:latest`. +4. Send `release/star-chart-search-enhancer-internal.zip` to coworkers. + +## COS Update Manifest + +The popup checks `src/shared/update-config.ts` for the update manifest URL. + +Before publishing the COS-based update flow: + +1. Upload these files to COS: + - `release/latest.json` + - `release/star-chart-search-enhancer-internal.zip` + - `release/星图增强插件-超简单安装使用指南.pdf` +2. Make the COS path publicly readable. +3. Replace the placeholder `UPDATE_MANIFEST_URL` in `src/shared/update-config.ts`. +4. Rebuild and package the extension. + +The release manifest can be generated with a real public base URL: + +```bash +UPDATE_PUBLIC_BASE_URL="https:///star-chart-search-enhancer/releases/" npm run write:latest +``` ## Coworker Install Steps diff --git a/docs/【超简单版】插件安装使用指南.md b/docs/【超简单版】插件安装使用指南.md index e908eae..c4de7ce 100644 --- a/docs/【超简单版】插件安装使用指南.md +++ b/docs/【超简单版】插件安装使用指南.md @@ -114,6 +114,16 @@ https://xingtu.cn/ad/creator/market - 输入批次名称(例如:`5月母婴达人第一批`) - 点击确认 +### 4️⃣ 更新插件 + +- 点击浏览器右上角的插件图标 +- 在 **"版本更新"** 区域查看是否有新版本 +- 如果提示发现新版本,点击 **"下载更新包"** 和 **"下载使用说明"** +- 解压下载到的新版本 zip +- 打开 `chrome://extensions` +- 找到 `Star Chart Search Enhancer` +- 点击 **"重新加载"**,或重新选择解压后的新插件文件夹 + --- ## 🔄 如何更新插件 diff --git a/package.json b/package.json index 6f709ba..ea3c13d 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "mock:protected-api": "node scripts/mock-protected-api.mjs", "package:internal": "npm run build:release && node scripts/package-release.mjs", "package:release": "npm run build:release && node scripts/package-release.mjs", + "write:latest": "node scripts/write-latest-manifest.mjs", "test": "vitest run --passWithNoTests", "test:watch": "vitest --passWithNoTests" }, diff --git a/release/latest.json b/release/latest.json new file mode 100644 index 0000000..5f155bf --- /dev/null +++ b/release/latest.json @@ -0,0 +1,11 @@ +{ + "guideUrl": "https://example.com/star-chart-search-enhancer/releases/0.2.0421.2/星图增强插件-超简单安装使用指南.pdf", + "latestVersion": "0.2.0421.2", + "minSupportedVersion": "0.2.0421.2", + "publishedAt": "2026-05-19", + "releaseNotes": [ + "支持在插件弹窗中检查新版本", + "支持一键下载最新版插件压缩包和使用说明" + ], + "zipUrl": "https://example.com/star-chart-search-enhancer/releases/0.2.0421.2/star-chart-search-enhancer-internal.zip" +} diff --git a/release/star-chart-search-enhancer-internal.zip b/release/star-chart-search-enhancer-internal.zip index be82975..56da855 100644 Binary files a/release/star-chart-search-enhancer-internal.zip and b/release/star-chart-search-enhancer-internal.zip differ diff --git a/release/星图增强插件-超简单安装使用指南.pdf b/release/星图增强插件-超简单安装使用指南.pdf index 8cae8a7..ad8d5e1 100644 Binary files a/release/星图增强插件-超简单安装使用指南.pdf and b/release/星图增强插件-超简单安装使用指南.pdf differ diff --git a/scripts/manifest.mjs b/scripts/manifest.mjs index 1de02bf..db32060 100644 --- a/scripts/manifest.mjs +++ b/scripts/manifest.mjs @@ -58,7 +58,8 @@ const hostPermissionsByTarget = { "https://*.xingtu.cn/ad/creator/market*", "https://login-api.intelligrow.cn/*", "https://talent-search.intelligrow.cn/*", - "http://192.168.31.21:8083/*" + "http://192.168.31.21:8083/*", + "https://*/*" ] }; diff --git a/scripts/write-latest-manifest.mjs b/scripts/write-latest-manifest.mjs new file mode 100644 index 0000000..dc68d31 --- /dev/null +++ b/scripts/write-latest-manifest.mjs @@ -0,0 +1,35 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createManifest } from "./manifest.mjs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, ".."); +const releaseDir = path.join(projectRoot, "release"); +const releaseManifest = createManifest({ target: "release" }); +const latestVersion = process.env.LATEST_VERSION ?? releaseManifest.version; +const publicBaseUrl = + process.env.UPDATE_PUBLIC_BASE_URL ?? + `https://example.com/star-chart-search-enhancer/releases/${latestVersion}`; + +const latestManifest = { + guideUrl: `${publicBaseUrl}/星图增强插件-超简单安装使用指南.pdf`, + latestVersion, + minSupportedVersion: releaseManifest.version, + publishedAt: new Date().toISOString().slice(0, 10), + releaseNotes: [ + "支持在插件弹窗中检查新版本", + "支持一键下载最新版插件压缩包和使用说明" + ], + zipUrl: `${publicBaseUrl}/star-chart-search-enhancer-internal.zip` +}; + +await mkdir(releaseDir, { recursive: true }); +await writeFile( + path.join(releaseDir, "latest.json"), + `${JSON.stringify(latestManifest, null, 2)}\n`, + "utf8" +); + +console.log(`Update manifest written to ${path.join(releaseDir, "latest.json")}`); diff --git a/src/background/index.ts b/src/background/index.ts index 56f4160..aa8d602 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -49,6 +49,12 @@ type BatchSubmitMessage = { type: "batch:submit"; }; +type DownloadUpdateMessage = { + filename: string; + type: "update:download"; + url: string; +}; + export function registerBackgroundMessageHandler( chromeLike: ChromeLike = readChromeLike(), dependencies: { @@ -77,6 +83,22 @@ export function registerBackgroundMessageHandler( return true; } + if (isDownloadUpdateMessage(message)) { + void triggerUpdateDownload(chromeLike, message) + .then(() => { + sendResponse({ ok: true, type: "update:download-ack" }); + }) + .catch((error) => { + sendResponse({ + error: error instanceof Error ? error.message : String(error), + ok: false, + type: "update:download-error" + }); + }); + + return true; + } + if (isBatchSubmitMessage(message)) { authController ??= createAuthController({ authClient: createLogtoAuthClient() @@ -161,6 +183,23 @@ export function registerBackgroundMessageHandler( }); } +async function triggerUpdateDownload( + chromeLike: ChromeLike, + message: DownloadUpdateMessage +): Promise { + if (!chromeLike.downloads?.download) { + throw new Error("chrome.downloads.download is unavailable"); + } + + await Promise.resolve( + chromeLike.downloads.download({ + filename: message.filename, + saveAs: true, + url: message.url + }) + ); +} + async function handleAuthMessage( authController: AuthController, message: Parameters[0] & { type: string } @@ -239,6 +278,22 @@ function isDownloadMarketCsvMessage( ); } +function isDownloadUpdateMessage( + message: unknown +): message is DownloadUpdateMessage { + if (!message || typeof message !== "object") { + return false; + } + + const candidate = message as Partial; + return ( + candidate.type === "update:download" && + typeof candidate.filename === "string" && + typeof candidate.url === "string" && + candidate.url.startsWith("https://") + ); +} + function isBatchSubmitMessage(message: unknown): message is BatchSubmitMessage { if (!message || typeof message !== "object") { return false; diff --git a/src/popup/index.ts b/src/popup/index.ts index c00292a..5a870d6 100644 --- a/src/popup/index.ts +++ b/src/popup/index.ts @@ -2,6 +2,8 @@ import { renderDevPanel, renderLoggedIn, renderLoggedOut, + renderUpdateStatus, + setUpdateDownloadStatus, setProtectedApiResult } from "./view"; import { readAuthConfig, type AuthConfig } from "../shared/auth-config"; @@ -10,17 +12,27 @@ import { type AuthResponseMessage } from "../shared/auth-messages"; import { createProtectedApiClient } from "../shared/protected-api-client"; +import { + compareExtensionVersions, + fetchUpdateManifest as fetchUpdateManifestFromUrl, + type UpdateManifest +} from "../shared/update-check"; +import { UPDATE_MANIFEST_URL } from "../shared/update-config"; interface BootPopupOptions { config?: Partial; + currentVersion?: string; document?: Document; fetchProtectedApi?: () => Promise; + fetchUpdateManifest?: () => Promise; sendMessage?: (message: unknown) => Promise; + updateManifestUrl?: string; } export async function bootPopup(options: BootPopupOptions = {}): Promise { const currentDocument = options.document ?? document; const popupConfig = readAuthConfig(options.config); + const currentVersion = options.currentVersion ?? readCurrentVersion(); const root = currentDocument.querySelector("#app"); const HTMLElementCtor = currentDocument.defaultView?.HTMLElement; @@ -48,15 +60,28 @@ export async function bootPopup(options: BootPopupOptions = {}): Promise { baseUrl: "http://127.0.0.1:4319", sendMessage }).loadProtectedMockData; + const fetchUpdateManifest = + options.fetchUpdateManifest ?? + (() => + fetchUpdateManifestFromUrl( + options.updateManifestUrl ?? UPDATE_MANIFEST_URL + )); - await renderCurrentAuthState(root, popupConfig, sendMessage, fetchProtectedApi); + await renderCurrentAuthState(root, popupConfig, sendMessage, fetchProtectedApi, { + currentVersion, + fetchUpdateManifest + }); } async function renderCurrentAuthState( root: HTMLElement, popupConfig: AuthConfig, sendMessage: (message: unknown) => Promise, - fetchProtectedApi: () => Promise + fetchProtectedApi: () => Promise, + updateOptions: { + currentVersion: string; + fetchUpdateManifest: () => Promise; + } ): Promise { const response = await sendMessage({ type: "auth:get-state" }); if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") { @@ -71,19 +96,22 @@ async function renderCurrentAuthState( ?.addEventListener("click", () => { void runAuthAction(root, popupConfig, sendMessage, { actionMessage: { type: "auth:sign-in" }, - fetchProtectedApi + fetchProtectedApi, + updateOptions }); }); return; } renderLoggedIn(root, response.value); + void runUpdateCheck(root, sendMessage, updateOptions); root .querySelector('[data-popup-sign-out="button"]') ?.addEventListener("click", () => { void runAuthAction(root, popupConfig, sendMessage, { actionMessage: { type: "auth:sign-out" }, - fetchProtectedApi + fetchProtectedApi, + updateOptions }); }); if (popupConfig.enableDevAuthPanel) { @@ -103,6 +131,10 @@ async function runAuthAction( options: { actionMessage: { type: "auth:sign-in" } | { type: "auth:sign-out" }; fetchProtectedApi: () => Promise; + updateOptions: { + currentVersion: string; + fetchUpdateManifest: () => Promise; + }; } ): Promise { const response = await sendMessage(options.actionMessage); @@ -121,7 +153,8 @@ async function runAuthAction( root, popupConfig, sendMessage, - options.fetchProtectedApi + options.fetchProtectedApi, + options.updateOptions ); } @@ -133,6 +166,105 @@ function isActionError(response: unknown): response is Extract Promise, + options: { + currentVersion: string; + fetchUpdateManifest: () => Promise; + } +): Promise { + renderUpdateStatus(root, { + currentVersion: options.currentVersion, + status: "checking" + }); + + try { + const manifest = await options.fetchUpdateManifest(); + if (compareExtensionVersions(manifest.latestVersion, options.currentVersion) <= 0) { + renderUpdateStatus(root, { + currentVersion: options.currentVersion, + status: "latest" + }); + return; + } + + renderUpdateStatus(root, { + currentVersion: options.currentVersion, + manifest, + status: "available" + }); + bindUpdateDownloadButtons(root, sendMessage, manifest); + } catch { + renderUpdateStatus(root, { + currentVersion: options.currentVersion, + status: "error" + }); + } +} + +function bindUpdateDownloadButtons( + root: HTMLElement, + sendMessage: (message: unknown) => Promise, + manifest: UpdateManifest +): void { + root + .querySelector('[data-popup-download-update="button"]') + ?.addEventListener("click", () => { + void downloadUpdateAsset(root, sendMessage, { + filename: "star-chart-search-enhancer-internal.zip", + url: manifest.zipUrl + }); + }); + + root + .querySelector('[data-popup-download-guide="button"]') + ?.addEventListener("click", () => { + void downloadUpdateAsset(root, sendMessage, { + filename: "星图增强插件-超简单安装使用指南.pdf", + url: manifest.guideUrl + }); + }); +} + +async function downloadUpdateAsset( + root: HTMLElement, + sendMessage: (message: unknown) => Promise, + options: { + filename: string; + url: string; + } +): Promise { + setUpdateDownloadStatus(root, "正在下载..."); + try { + await sendMessage({ + filename: options.filename, + type: "update:download", + url: options.url + }); + setUpdateDownloadStatus(root, "已触发下载。下载后请解压新版 zip,并在 chrome://extensions 里重新加载插件。"); + } catch (error) { + setUpdateDownloadStatus( + root, + error instanceof Error ? error.message : "下载失败,请稍后重试" + ); + } +} + +function readCurrentVersion(): string { + const runtime = ( + globalThis as typeof globalThis & { + chrome?: { + runtime?: { + getManifest?: () => { version?: string }; + }; + }; + } + ).chrome?.runtime; + + return runtime?.getManifest?.().version ?? "0.0.0"; +} + async function runProtectedApiProbe( root: HTMLElement, fetchProtectedApi: () => Promise diff --git a/src/popup/view.ts b/src/popup/view.ts index eb51477..e5fce55 100644 --- a/src/popup/view.ts +++ b/src/popup/view.ts @@ -1,4 +1,5 @@ import type { AuthStateValue } from "../shared/auth-messages"; +import type { UpdateManifest } from "../shared/update-check"; export function renderLoggedOut(root: HTMLElement, error?: string | null): void { root.innerHTML = ` @@ -23,11 +24,99 @@ export function renderLoggedIn(

已登录

${userInfo?.name ?? userInfo?.username ?? "未知用户"}

${userInfo?.email ?? ""}

+
+

版本更新

+

正在检查更新...

+
`; } +export function renderUpdateStatus( + root: HTMLElement, + options: { + currentVersion: string; + manifest?: UpdateManifest; + status: "checking" | "error" | "latest" | "available"; + } +): void { + const container = root.querySelector('[data-popup-update="root"]'); + if (!container) { + return; + } + + if (options.status === "checking") { + container.innerHTML = ` +

版本更新

+

当前版本:${options.currentVersion}

+

正在检查更新...

+ `; + return; + } + + if (options.status === "error") { + container.innerHTML = ` +

版本更新

+

当前版本:${options.currentVersion}

+

暂时无法检查更新

+

如果需要新版,请联系维护同事获取更新包。

+ `; + return; + } + + if (options.status === "latest" || !options.manifest) { + container.innerHTML = ` +

版本更新

+

当前版本:${options.currentVersion}

+

当前已是最新版本

+ `; + return; + } + + container.innerHTML = ` +

版本更新

+

当前版本:${options.currentVersion}

+

发现新版本:${options.manifest.latestVersion}

+ ${renderReleaseNotes(options.manifest.releaseNotes)} + + +

下载后请解压新版 zip,并在 chrome://extensions 里重新加载插件。

+ `; +} + +export function setUpdateDownloadStatus( + root: HTMLElement, + value: string +): void { + const output = root.querySelector('[data-popup-update-download-status="text"]'); + if (!output) { + return; + } + + output.textContent = value; +} + +function renderReleaseNotes(releaseNotes: string[]): string { + if (releaseNotes.length === 0) { + return ""; + } + + return ` +
    + ${releaseNotes.map((note) => `
  • ${escapeHtml(note)}
  • `).join("")} +
+ `; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + export function renderDevPanel( root: HTMLElement, authState: AuthStateValue diff --git a/src/shared/update-check.ts b/src/shared/update-check.ts new file mode 100644 index 0000000..9909e29 --- /dev/null +++ b/src/shared/update-check.ts @@ -0,0 +1,94 @@ +export interface UpdateManifest { + guideUrl: string; + latestVersion: string; + minSupportedVersion: string; + publishedAt: string; + releaseNotes: string[]; + zipUrl: string; +} + +export function compareExtensionVersions(left: string, right: string): number { + const leftParts = parseVersionParts(left); + const rightParts = parseVersionParts(right); + const maxLength = Math.max(leftParts.length, rightParts.length); + + for (let index = 0; index < maxLength; index += 1) { + const leftValue = leftParts[index] ?? 0; + const rightValue = rightParts[index] ?? 0; + if (leftValue !== rightValue) { + return leftValue - rightValue; + } + } + + return 0; +} + +export function parseUpdateManifest(value: unknown): UpdateManifest | null { + if (!value || typeof value !== "object") { + return null; + } + + const candidate = value as Partial; + if ( + !isVersionString(candidate.latestVersion) || + !isVersionString(candidate.minSupportedVersion) || + !isHttpsUrl(candidate.zipUrl) || + !isHttpsUrl(candidate.guideUrl) || + typeof candidate.publishedAt !== "string" || + !Array.isArray(candidate.releaseNotes) || + !candidate.releaseNotes.every((note) => typeof note === "string") + ) { + return null; + } + + return { + guideUrl: candidate.guideUrl, + latestVersion: candidate.latestVersion, + minSupportedVersion: candidate.minSupportedVersion, + publishedAt: candidate.publishedAt, + releaseNotes: candidate.releaseNotes, + zipUrl: candidate.zipUrl + }; +} + +export async function fetchUpdateManifest( + manifestUrl: string, + fetchImpl: typeof fetch = fetch +): Promise { + const response = await fetchImpl(manifestUrl, { + cache: "no-store" + }); + if (!response.ok) { + throw new Error(`update manifest request failed: ${response.status}`); + } + + const manifest = parseUpdateManifest(await response.json()); + if (!manifest) { + throw new Error("update manifest is invalid"); + } + + return manifest; +} + +function parseVersionParts(value: string): number[] { + return value.split(".").map((part) => { + const parsed = Number.parseInt(part, 10); + return Number.isFinite(parsed) ? parsed : 0; + }); +} + +function isVersionString(value: unknown): value is string { + return typeof value === "string" && /^\d+(?:\.\d+)*$/.test(value); +} + +function isHttpsUrl(value: unknown): value is string { + if (typeof value !== "string") { + return false; + } + + try { + return new URL(value).protocol === "https:"; + } catch { + return false; + } +} diff --git a/src/shared/update-config.ts b/src/shared/update-config.ts new file mode 100644 index 0000000..360e599 --- /dev/null +++ b/src/shared/update-config.ts @@ -0,0 +1,2 @@ +export const UPDATE_MANIFEST_URL = + "https://example.com/star-chart-search-enhancer/latest.json"; diff --git a/tests/background-index.test.ts b/tests/background-index.test.ts index 64ab09f..5a59ce2 100644 --- a/tests/background-index.test.ts +++ b/tests/background-index.test.ts @@ -48,6 +48,50 @@ describe("background-index", () => { expect(sendResponse).toHaveBeenCalledWith({ ok: true }); }); + test("downloads extension update assets", async () => { + const listeners: Array< + (message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => boolean | void + > = []; + const download = vi.fn(async () => undefined); + const sendResponse = vi.fn(); + + registerBackgroundMessageHandler({ + downloads: { + download + }, + runtime: { + onMessage: { + addListener(listener) { + listeners.push(listener); + } + } + } + }); + + const result = listeners[0]( + { + filename: "star-chart-search-enhancer-internal.zip", + type: "update:download", + url: "https://cos.example.com/star-chart-search-enhancer/releases/0.2.0421.3/star-chart-search-enhancer-internal.zip" + }, + {}, + sendResponse + ); + + expect(result).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(download).toHaveBeenCalledWith({ + filename: "star-chart-search-enhancer-internal.zip", + saveAs: true, + url: "https://cos.example.com/star-chart-search-enhancer/releases/0.2.0421.3/star-chart-search-enhancer-internal.zip" + }); + expect(sendResponse).toHaveBeenCalledWith({ + ok: true, + type: "update:download-ack" + }); + }); + test("responds to auth:get-state with auth status", async () => { const listeners: Array< (message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => boolean | void diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index 80f8694..a0d67fc 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -43,10 +43,11 @@ describe("manifest", () => { "https://*.xingtu.cn/ad/creator/market*", "https://login-api.intelligrow.cn/*", "https://talent-search.intelligrow.cn/*", - "http://192.168.31.21:8083/*" + "http://192.168.31.21:8083/*", + "https://*/*" ]); expect(releaseManifest.host_permissions).not.toEqual( - expect.arrayContaining(["http://*/*", "https://*/*", "http://127.0.0.1:4319/*"]) + expect.arrayContaining(["http://*/*", "http://127.0.0.1:4319/*"]) ); }); diff --git a/tests/popup-entry.test.ts b/tests/popup-entry.test.ts index 42289a1..1bc2ef4 100644 --- a/tests/popup-entry.test.ts +++ b/tests/popup-entry.test.ts @@ -51,6 +51,101 @@ describe("popup-entry", () => { expect(dom.window.document.body.textContent).toContain("token"); }); + test("shows available extension updates in the popup", async () => { + const fetchUpdateManifest = vi.fn(async () => ({ + guideUrl: "https://cos.example.com/guide.pdf", + latestVersion: "0.2.0421.3", + minSupportedVersion: "0.2.0421.2", + publishedAt: "2026-05-19", + releaseNotes: ["支持检查更新"], + zipUrl: "https://cos.example.com/plugin.zip" + })); + + dom.window.document.body.innerHTML = "
"; + + await bootPopup({ + currentVersion: "0.2.0421.2", + document: dom.window.document, + fetchUpdateManifest, + sendMessage: vi.fn(async () => ({ + ok: true, + type: "auth:state", + value: { + isAuthenticated: true, + userInfo: { name: "Dev" } + } + })) + }); + await Promise.resolve(); + + expect(fetchUpdateManifest).toHaveBeenCalledTimes(1); + expect(dom.window.document.body.textContent).toContain("当前版本:0.2.0421.2"); + expect(dom.window.document.body.textContent).toContain("发现新版本:0.2.0421.3"); + expect(dom.window.document.body.textContent).toContain("支持检查更新"); + expect( + dom.window.document.querySelector('[data-popup-download-update="button"]') + ).not.toBeNull(); + expect( + dom.window.document.querySelector('[data-popup-download-guide="button"]') + ).not.toBeNull(); + }); + + test("downloads update assets from popup buttons", async () => { + const sendMessage = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + type: "auth:state", + value: { + isAuthenticated: true, + userInfo: { name: "Dev" } + } + }) + .mockResolvedValue({ ok: true, type: "update:download-ack" }); + + dom.window.document.body.innerHTML = "
"; + + await bootPopup({ + currentVersion: "0.2.0421.2", + document: dom.window.document, + fetchUpdateManifest: vi.fn(async () => ({ + guideUrl: "https://cos.example.com/guide.pdf", + latestVersion: "0.2.0421.3", + minSupportedVersion: "0.2.0421.2", + publishedAt: "2026-05-19", + releaseNotes: [], + zipUrl: "https://cos.example.com/plugin.zip" + })), + sendMessage + }); + await Promise.resolve(); + + ( + dom.window.document.querySelector( + '[data-popup-download-update="button"]' + ) as HTMLButtonElement | null + )?.click(); + ( + dom.window.document.querySelector( + '[data-popup-download-guide="button"]' + ) as HTMLButtonElement | null + )?.click(); + + await Promise.resolve(); + await Promise.resolve(); + + expect(sendMessage).toHaveBeenCalledWith({ + filename: "star-chart-search-enhancer-internal.zip", + type: "update:download", + url: "https://cos.example.com/plugin.zip" + }); + expect(sendMessage).toHaveBeenCalledWith({ + filename: "星图增强插件-超简单安装使用指南.pdf", + type: "update:download", + url: "https://cos.example.com/guide.pdf" + }); + }); + test("renders a protected api test button in the dev panel", async () => { dom.window.document.body.innerHTML = "
"; diff --git a/tests/update-check.test.ts b/tests/update-check.test.ts new file mode 100644 index 0000000..2c468e1 --- /dev/null +++ b/tests/update-check.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "vitest"; + +import { + compareExtensionVersions, + parseUpdateManifest +} from "../src/shared/update-check"; + +describe("update-check", () => { + test("compares dotted extension versions numerically", () => { + expect(compareExtensionVersions("0.2.0421.3", "0.2.0421.2")).toBeGreaterThan(0); + expect(compareExtensionVersions("0.2.10.0", "0.2.9.9")).toBeGreaterThan(0); + expect(compareExtensionVersions("0.2.0421.2", "0.2.0421.2")).toBe(0); + expect(compareExtensionVersions("0.2.0421.1", "0.2.0421.2")).toBeLessThan(0); + }); + + test("parses a valid update manifest", () => { + expect( + parseUpdateManifest({ + guideUrl: "https://cos.example.com/guide.pdf", + latestVersion: "0.2.0421.3", + minSupportedVersion: "0.2.0421.2", + publishedAt: "2026-05-19", + releaseNotes: ["支持检查更新"], + zipUrl: "https://cos.example.com/plugin.zip" + }) + ).toEqual({ + guideUrl: "https://cos.example.com/guide.pdf", + latestVersion: "0.2.0421.3", + minSupportedVersion: "0.2.0421.2", + publishedAt: "2026-05-19", + releaseNotes: ["支持检查更新"], + zipUrl: "https://cos.example.com/plugin.zip" + }); + }); + + test("rejects invalid update manifests", () => { + expect(parseUpdateManifest({ latestVersion: "0.2.0421.3" })).toBeNull(); + expect( + parseUpdateManifest({ + guideUrl: "javascript:alert(1)", + latestVersion: "0.2.0421.3", + minSupportedVersion: "0.2.0421.2", + publishedAt: "2026-05-19", + releaseNotes: [], + zipUrl: "https://cos.example.com/plugin.zip" + }) + ).toBeNull(); + }); +});