feat: add extension update check
This commit is contained in:
parent
703a095c08
commit
02d9063a11
@ -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`
|
||||
|
||||
说明:
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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://*/*"
|
||||
]
|
||||
}
|
||||
|
||||
@ -19,10 +19,74 @@
|
||||
<p>\u5DF2\u767B\u5F55</p>
|
||||
<p>${userInfo?.name ?? userInfo?.username ?? "\u672A\u77E5\u7528\u6237"}</p>
|
||||
<p>${userInfo?.email ?? ""}</p>
|
||||
<section data-popup-update="root">
|
||||
<h2>\u7248\u672C\u66F4\u65B0</h2>
|
||||
<p data-popup-update-status="text">\u6B63\u5728\u68C0\u67E5\u66F4\u65B0...</p>
|
||||
</section>
|
||||
<button type="button" data-popup-sign-out="button">\u9000\u51FA\u767B\u5F55</button>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
function renderUpdateStatus(root, options) {
|
||||
const container = root.querySelector('[data-popup-update="root"]');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
if (options.status === "checking") {
|
||||
container.innerHTML = `
|
||||
<h2>\u7248\u672C\u66F4\u65B0</h2>
|
||||
<p data-popup-update-status="text">\u5F53\u524D\u7248\u672C\uFF1A${options.currentVersion}</p>
|
||||
<p>\u6B63\u5728\u68C0\u67E5\u66F4\u65B0...</p>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
if (options.status === "error") {
|
||||
container.innerHTML = `
|
||||
<h2>\u7248\u672C\u66F4\u65B0</h2>
|
||||
<p data-popup-update-status="text">\u5F53\u524D\u7248\u672C\uFF1A${options.currentVersion}</p>
|
||||
<p>\u6682\u65F6\u65E0\u6CD5\u68C0\u67E5\u66F4\u65B0</p>
|
||||
<p>\u5982\u679C\u9700\u8981\u65B0\u7248\uFF0C\u8BF7\u8054\u7CFB\u7EF4\u62A4\u540C\u4E8B\u83B7\u53D6\u66F4\u65B0\u5305\u3002</p>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
if (options.status === "latest" || !options.manifest) {
|
||||
container.innerHTML = `
|
||||
<h2>\u7248\u672C\u66F4\u65B0</h2>
|
||||
<p data-popup-update-status="text">\u5F53\u524D\u7248\u672C\uFF1A${options.currentVersion}</p>
|
||||
<p>\u5F53\u524D\u5DF2\u662F\u6700\u65B0\u7248\u672C</p>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
container.innerHTML = `
|
||||
<h2>\u7248\u672C\u66F4\u65B0</h2>
|
||||
<p data-popup-update-status="text">\u5F53\u524D\u7248\u672C\uFF1A${options.currentVersion}</p>
|
||||
<p>\u53D1\u73B0\u65B0\u7248\u672C\uFF1A${options.manifest.latestVersion}</p>
|
||||
${renderReleaseNotes(options.manifest.releaseNotes)}
|
||||
<button type="button" data-popup-download-update="button">\u4E0B\u8F7D\u66F4\u65B0\u5305</button>
|
||||
<button type="button" data-popup-download-guide="button">\u4E0B\u8F7D\u4F7F\u7528\u8BF4\u660E</button>
|
||||
<p data-popup-update-download-status="text">\u4E0B\u8F7D\u540E\u8BF7\u89E3\u538B\u65B0\u7248 zip\uFF0C\u5E76\u5728 chrome://extensions \u91CC\u91CD\u65B0\u52A0\u8F7D\u63D2\u4EF6\u3002</p>
|
||||
`;
|
||||
}
|
||||
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 `
|
||||
<ul>
|
||||
${releaseNotes.map((note) => `<li>${escapeHtml(note)}</li>`).join("")}
|
||||
</ul>
|
||||
`;
|
||||
}
|
||||
function escapeHtml(value) {
|
||||
return value.replace(/&/g, "&").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 {
|
||||
|
||||
@ -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. 打开:
|
||||
|
||||
@ -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://<your-cos-domain>/star-chart-search-enhancer/releases/<version>" npm run write:latest
|
||||
```
|
||||
|
||||
## Coworker Install Steps
|
||||
|
||||
|
||||
@ -114,6 +114,16 @@ https://xingtu.cn/ad/creator/market
|
||||
- 输入批次名称(例如:`5月母婴达人第一批`)
|
||||
- 点击确认
|
||||
|
||||
### 4️⃣ 更新插件
|
||||
|
||||
- 点击浏览器右上角的插件图标
|
||||
- 在 **"版本更新"** 区域查看是否有新版本
|
||||
- 如果提示发现新版本,点击 **"下载更新包"** 和 **"下载使用说明"**
|
||||
- 解压下载到的新版本 zip
|
||||
- 打开 `chrome://extensions`
|
||||
- 找到 `Star Chart Search Enhancer`
|
||||
- 点击 **"重新加载"**,或重新选择解压后的新插件文件夹
|
||||
|
||||
---
|
||||
|
||||
## 🔄 如何更新插件
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
11
release/latest.json
Normal file
11
release/latest.json
Normal file
@ -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"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@ -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://*/*"
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
35
scripts/write-latest-manifest.mjs
Normal file
35
scripts/write-latest-manifest.mjs
Normal file
@ -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")}`);
|
||||
@ -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<void> {
|
||||
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<typeof isAuthRequestMessage>[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<DownloadUpdateMessage>;
|
||||
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;
|
||||
|
||||
@ -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<AuthConfig>;
|
||||
currentVersion?: string;
|
||||
document?: Document;
|
||||
fetchProtectedApi?: () => Promise<unknown>;
|
||||
fetchUpdateManifest?: () => Promise<UpdateManifest>;
|
||||
sendMessage?: (message: unknown) => Promise<unknown>;
|
||||
updateManifestUrl?: string;
|
||||
}
|
||||
|
||||
export async function bootPopup(options: BootPopupOptions = {}): Promise<void> {
|
||||
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<void> {
|
||||
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<unknown>,
|
||||
fetchProtectedApi: () => Promise<unknown>
|
||||
fetchProtectedApi: () => Promise<unknown>,
|
||||
updateOptions: {
|
||||
currentVersion: string;
|
||||
fetchUpdateManifest: () => Promise<UpdateManifest>;
|
||||
}
|
||||
): Promise<void> {
|
||||
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<unknown>;
|
||||
updateOptions: {
|
||||
currentVersion: string;
|
||||
fetchUpdateManifest: () => Promise<UpdateManifest>;
|
||||
};
|
||||
}
|
||||
): Promise<void> {
|
||||
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<AuthResponseMessa
|
||||
);
|
||||
}
|
||||
|
||||
async function runUpdateCheck(
|
||||
root: HTMLElement,
|
||||
sendMessage: (message: unknown) => Promise<unknown>,
|
||||
options: {
|
||||
currentVersion: string;
|
||||
fetchUpdateManifest: () => Promise<UpdateManifest>;
|
||||
}
|
||||
): Promise<void> {
|
||||
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<unknown>,
|
||||
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<unknown>,
|
||||
options: {
|
||||
filename: string;
|
||||
url: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
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<unknown>
|
||||
|
||||
@ -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(
|
||||
<p>已登录</p>
|
||||
<p>${userInfo?.name ?? userInfo?.username ?? "未知用户"}</p>
|
||||
<p>${userInfo?.email ?? ""}</p>
|
||||
<section data-popup-update="root">
|
||||
<h2>版本更新</h2>
|
||||
<p data-popup-update-status="text">正在检查更新...</p>
|
||||
</section>
|
||||
<button type="button" data-popup-sign-out="button">退出登录</button>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
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 = `
|
||||
<h2>版本更新</h2>
|
||||
<p data-popup-update-status="text">当前版本:${options.currentVersion}</p>
|
||||
<p>正在检查更新...</p>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.status === "error") {
|
||||
container.innerHTML = `
|
||||
<h2>版本更新</h2>
|
||||
<p data-popup-update-status="text">当前版本:${options.currentVersion}</p>
|
||||
<p>暂时无法检查更新</p>
|
||||
<p>如果需要新版,请联系维护同事获取更新包。</p>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.status === "latest" || !options.manifest) {
|
||||
container.innerHTML = `
|
||||
<h2>版本更新</h2>
|
||||
<p data-popup-update-status="text">当前版本:${options.currentVersion}</p>
|
||||
<p>当前已是最新版本</p>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<h2>版本更新</h2>
|
||||
<p data-popup-update-status="text">当前版本:${options.currentVersion}</p>
|
||||
<p>发现新版本:${options.manifest.latestVersion}</p>
|
||||
${renderReleaseNotes(options.manifest.releaseNotes)}
|
||||
<button type="button" data-popup-download-update="button">下载更新包</button>
|
||||
<button type="button" data-popup-download-guide="button">下载使用说明</button>
|
||||
<p data-popup-update-download-status="text">下载后请解压新版 zip,并在 chrome://extensions 里重新加载插件。</p>
|
||||
`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<ul>
|
||||
${releaseNotes.map((note) => `<li>${escapeHtml(note)}</li>`).join("")}
|
||||
</ul>
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
export function renderDevPanel(
|
||||
root: HTMLElement,
|
||||
authState: AuthStateValue
|
||||
|
||||
94
src/shared/update-check.ts
Normal file
94
src/shared/update-check.ts
Normal file
@ -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<UpdateManifest>;
|
||||
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<UpdateManifest> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
2
src/shared/update-config.ts
Normal file
2
src/shared/update-config.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const UPDATE_MANIFEST_URL =
|
||||
"https://example.com/star-chart-search-enhancer/latest.json";
|
||||
@ -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
|
||||
|
||||
@ -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/*"])
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -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 = "<main id='app'></main>";
|
||||
|
||||
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 = "<main id='app'></main>";
|
||||
|
||||
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 = "<main id='app'></main>";
|
||||
|
||||
|
||||
49
tests/update-check.test.ts
Normal file
49
tests/update-check.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user