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 `
+
+ ${releaseNotes.map((note) => `- ${escapeHtml(note)}
`).join("")}
+
+ `;
+ }
+ 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();
+ });
+});