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
|
npm run package:internal
|
||||||
```
|
```
|
||||||
|
|
||||||
|
生成更新清单:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run write:latest
|
||||||
|
```
|
||||||
|
|
||||||
生成结果:
|
生成结果:
|
||||||
|
|
||||||
- 构建目录:`dist-release/`
|
- 构建目录:`dist-release/`
|
||||||
- 压缩包:`release/star-chart-search-enhancer-internal.zip`
|
- 压缩包:`release/star-chart-search-enhancer-internal.zip`
|
||||||
|
- 更新清单:`release/latest.json`
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
|
|||||||
@ -3203,6 +3203,18 @@
|
|||||||
});
|
});
|
||||||
return true;
|
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)) {
|
if (isBatchSubmitMessage(message2)) {
|
||||||
authController ??= createAuthController({
|
authController ??= createAuthController({
|
||||||
authClient: createLogtoAuthClient()
|
authClient: createLogtoAuthClient()
|
||||||
@ -3270,6 +3282,18 @@
|
|||||||
return true;
|
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) {
|
async function handleAuthMessage(authController, message2) {
|
||||||
if (message2.type === "auth:get-state") {
|
if (message2.type === "auth:get-state") {
|
||||||
return {
|
return {
|
||||||
@ -3323,6 +3347,13 @@
|
|||||||
const candidate = message2;
|
const candidate = message2;
|
||||||
return candidate.type === "download-market-csv" && typeof candidate.csv === "string" && typeof candidate.filename === "string";
|
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) {
|
function isBatchSubmitMessage(message2) {
|
||||||
if (!message2 || typeof message2 !== "object") {
|
if (!message2 || typeof message2 !== "object") {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@ -53,6 +53,7 @@
|
|||||||
"https://*.xingtu.cn/ad/creator/market*",
|
"https://*.xingtu.cn/ad/creator/market*",
|
||||||
"https://login-api.intelligrow.cn/*",
|
"https://login-api.intelligrow.cn/*",
|
||||||
"https://talent-search.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>\u5DF2\u767B\u5F55</p>
|
||||||
<p>${userInfo?.name ?? userInfo?.username ?? "\u672A\u77E5\u7528\u6237"}</p>
|
<p>${userInfo?.name ?? userInfo?.username ?? "\u672A\u77E5\u7528\u6237"}</p>
|
||||||
<p>${userInfo?.email ?? ""}</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>
|
<button type="button" data-popup-sign-out="button">\u9000\u51FA\u767B\u5F55</button>
|
||||||
</section>
|
</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) {
|
function renderDevPanel(root, authState) {
|
||||||
const panel = root.ownerDocument.createElement("section");
|
const panel = root.ownerDocument.createElement("section");
|
||||||
panel.dataset.popupDevPanel = "root";
|
panel.dataset.popupDevPanel = "root";
|
||||||
@ -134,10 +198,78 @@
|
|||||||
return response.value.accessToken;
|
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
|
// src/popup/index.ts
|
||||||
async function bootPopup(options = {}) {
|
async function bootPopup(options = {}) {
|
||||||
const currentDocument = options.document ?? document;
|
const currentDocument = options.document ?? document;
|
||||||
const popupConfig = readAuthConfig(options.config);
|
const popupConfig = readAuthConfig(options.config);
|
||||||
|
const currentVersion = options.currentVersion ?? readCurrentVersion();
|
||||||
const root = currentDocument.querySelector("#app");
|
const root = currentDocument.querySelector("#app");
|
||||||
const HTMLElementCtor = currentDocument.defaultView?.HTMLElement;
|
const HTMLElementCtor = currentDocument.defaultView?.HTMLElement;
|
||||||
if (!root || HTMLElementCtor && !(root instanceof HTMLElementCtor)) {
|
if (!root || HTMLElementCtor && !(root instanceof HTMLElementCtor)) {
|
||||||
@ -150,9 +282,15 @@
|
|||||||
baseUrl: "http://127.0.0.1:4319",
|
baseUrl: "http://127.0.0.1:4319",
|
||||||
sendMessage
|
sendMessage
|
||||||
}).loadProtectedMockData;
|
}).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" });
|
const response = await sendMessage({ type: "auth:get-state" });
|
||||||
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") {
|
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") {
|
||||||
renderLoggedOut(root, "\u8BA4\u8BC1\u72B6\u6001\u8BFB\u53D6\u5931\u8D25");
|
renderLoggedOut(root, "\u8BA4\u8BC1\u72B6\u6001\u8BFB\u53D6\u5931\u8D25");
|
||||||
@ -163,16 +301,19 @@
|
|||||||
root.querySelector('[data-popup-sign-in="button"]')?.addEventListener("click", () => {
|
root.querySelector('[data-popup-sign-in="button"]')?.addEventListener("click", () => {
|
||||||
void runAuthAction(root, popupConfig, sendMessage, {
|
void runAuthAction(root, popupConfig, sendMessage, {
|
||||||
actionMessage: { type: "auth:sign-in" },
|
actionMessage: { type: "auth:sign-in" },
|
||||||
fetchProtectedApi
|
fetchProtectedApi,
|
||||||
|
updateOptions
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderLoggedIn(root, response.value);
|
renderLoggedIn(root, response.value);
|
||||||
|
void runUpdateCheck(root, sendMessage, updateOptions);
|
||||||
root.querySelector('[data-popup-sign-out="button"]')?.addEventListener("click", () => {
|
root.querySelector('[data-popup-sign-out="button"]')?.addEventListener("click", () => {
|
||||||
void runAuthAction(root, popupConfig, sendMessage, {
|
void runAuthAction(root, popupConfig, sendMessage, {
|
||||||
actionMessage: { type: "auth:sign-out" },
|
actionMessage: { type: "auth:sign-out" },
|
||||||
fetchProtectedApi
|
fetchProtectedApi,
|
||||||
|
updateOptions
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
if (popupConfig.enableDevAuthPanel) {
|
if (popupConfig.enableDevAuthPanel) {
|
||||||
@ -195,12 +336,74 @@
|
|||||||
root,
|
root,
|
||||||
popupConfig,
|
popupConfig,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
options.fetchProtectedApi
|
options.fetchProtectedApi,
|
||||||
|
options.updateOptions
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function isActionError(response) {
|
function isActionError(response) {
|
||||||
return isAuthResponseMessage(response) && !response.ok && response.type === "auth:error";
|
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) {
|
async function runProtectedApiProbe(root, fetchProtectedApi) {
|
||||||
setProtectedApiResult(root, "\u8BF7\u6C42\u4E2D...");
|
setProtectedApiResult(root, "\u8BF7\u6C42\u4E2D...");
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -369,12 +369,31 @@ CSV 会包含:
|
|||||||
|
|
||||||
## 十三、如何更新插件
|
## 十三、如何更新插件
|
||||||
|
|
||||||
|
插件弹窗会检查是否有新版本。
|
||||||
|
|
||||||
|
### 方法一:从插件弹窗下载新版本
|
||||||
|
|
||||||
|
1. 点击浏览器右上角的插件图标
|
||||||
|
2. 查看 `版本更新` 区域
|
||||||
|
3. 如果提示发现新版本,点击:
|
||||||
|
- `下载更新包`
|
||||||
|
- `下载使用说明`
|
||||||
|
4. 解压下载到的新版本 zip
|
||||||
|
5. 打开:
|
||||||
|
- `chrome://extensions`
|
||||||
|
6. 找到:
|
||||||
|
- `Star Chart Search Enhancer`
|
||||||
|
7. 点击:
|
||||||
|
- `重新加载`
|
||||||
|
|
||||||
|
如果没有看到新版本提示,可能是网络暂时无法访问更新清单,也可能当前已经是最新版本。
|
||||||
|
|
||||||
|
### 方法二:收到压缩包后手动更新
|
||||||
|
|
||||||
当你收到新的插件压缩包时,不需要重新从零安装。
|
当你收到新的插件压缩包时,不需要重新从零安装。
|
||||||
|
|
||||||
按照下面做:
|
按照下面做:
|
||||||
|
|
||||||
### 方法一:替换文件夹后重新加载
|
|
||||||
|
|
||||||
1. 删除旧的解压文件夹,或用新的内容覆盖旧文件夹
|
1. 删除旧的解压文件夹,或用新的内容覆盖旧文件夹
|
||||||
2. 打开:
|
2. 打开:
|
||||||
- `chrome://extensions`
|
- `chrome://extensions`
|
||||||
@ -383,7 +402,7 @@ CSV 会包含:
|
|||||||
4. 点击:
|
4. 点击:
|
||||||
- `重新加载`
|
- `重新加载`
|
||||||
|
|
||||||
### 方法二:重新解压到新文件夹再重新加载
|
### 方法三:重新解压到新文件夹再重新加载
|
||||||
|
|
||||||
1. 解压新的压缩包
|
1. 解压新的压缩包
|
||||||
2. 打开:
|
2. 打开:
|
||||||
|
|||||||
@ -13,7 +13,28 @@
|
|||||||
|
|
||||||
1. Run `npm test`.
|
1. Run `npm test`.
|
||||||
2. Run `npm run package:internal`.
|
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
|
## Coworker Install Steps
|
||||||
|
|
||||||
|
|||||||
@ -114,6 +114,16 @@ https://xingtu.cn/ad/creator/market
|
|||||||
- 输入批次名称(例如:`5月母婴达人第一批`)
|
- 输入批次名称(例如:`5月母婴达人第一批`)
|
||||||
- 点击确认
|
- 点击确认
|
||||||
|
|
||||||
|
### 4️⃣ 更新插件
|
||||||
|
|
||||||
|
- 点击浏览器右上角的插件图标
|
||||||
|
- 在 **"版本更新"** 区域查看是否有新版本
|
||||||
|
- 如果提示发现新版本,点击 **"下载更新包"** 和 **"下载使用说明"**
|
||||||
|
- 解压下载到的新版本 zip
|
||||||
|
- 打开 `chrome://extensions`
|
||||||
|
- 找到 `Star Chart Search Enhancer`
|
||||||
|
- 点击 **"重新加载"**,或重新选择解压后的新插件文件夹
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔄 如何更新插件
|
## 🔄 如何更新插件
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
"mock:protected-api": "node scripts/mock-protected-api.mjs",
|
"mock:protected-api": "node scripts/mock-protected-api.mjs",
|
||||||
"package:internal": "npm run build:release && node scripts/package-release.mjs",
|
"package:internal": "npm run build:release && node scripts/package-release.mjs",
|
||||||
"package:release": "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": "vitest run --passWithNoTests",
|
||||||
"test:watch": "vitest --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://*.xingtu.cn/ad/creator/market*",
|
||||||
"https://login-api.intelligrow.cn/*",
|
"https://login-api.intelligrow.cn/*",
|
||||||
"https://talent-search.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: "batch:submit";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DownloadUpdateMessage = {
|
||||||
|
filename: string;
|
||||||
|
type: "update:download";
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function registerBackgroundMessageHandler(
|
export function registerBackgroundMessageHandler(
|
||||||
chromeLike: ChromeLike = readChromeLike(),
|
chromeLike: ChromeLike = readChromeLike(),
|
||||||
dependencies: {
|
dependencies: {
|
||||||
@ -77,6 +83,22 @@ export function registerBackgroundMessageHandler(
|
|||||||
return true;
|
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)) {
|
if (isBatchSubmitMessage(message)) {
|
||||||
authController ??= createAuthController({
|
authController ??= createAuthController({
|
||||||
authClient: createLogtoAuthClient()
|
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(
|
async function handleAuthMessage(
|
||||||
authController: AuthController,
|
authController: AuthController,
|
||||||
message: Parameters<typeof isAuthRequestMessage>[0] & { type: string }
|
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 {
|
function isBatchSubmitMessage(message: unknown): message is BatchSubmitMessage {
|
||||||
if (!message || typeof message !== "object") {
|
if (!message || typeof message !== "object") {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import {
|
|||||||
renderDevPanel,
|
renderDevPanel,
|
||||||
renderLoggedIn,
|
renderLoggedIn,
|
||||||
renderLoggedOut,
|
renderLoggedOut,
|
||||||
|
renderUpdateStatus,
|
||||||
|
setUpdateDownloadStatus,
|
||||||
setProtectedApiResult
|
setProtectedApiResult
|
||||||
} from "./view";
|
} from "./view";
|
||||||
import { readAuthConfig, type AuthConfig } from "../shared/auth-config";
|
import { readAuthConfig, type AuthConfig } from "../shared/auth-config";
|
||||||
@ -10,17 +12,27 @@ import {
|
|||||||
type AuthResponseMessage
|
type AuthResponseMessage
|
||||||
} from "../shared/auth-messages";
|
} from "../shared/auth-messages";
|
||||||
import { createProtectedApiClient } from "../shared/protected-api-client";
|
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 {
|
interface BootPopupOptions {
|
||||||
config?: Partial<AuthConfig>;
|
config?: Partial<AuthConfig>;
|
||||||
|
currentVersion?: string;
|
||||||
document?: Document;
|
document?: Document;
|
||||||
fetchProtectedApi?: () => Promise<unknown>;
|
fetchProtectedApi?: () => Promise<unknown>;
|
||||||
|
fetchUpdateManifest?: () => Promise<UpdateManifest>;
|
||||||
sendMessage?: (message: unknown) => Promise<unknown>;
|
sendMessage?: (message: unknown) => Promise<unknown>;
|
||||||
|
updateManifestUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bootPopup(options: BootPopupOptions = {}): Promise<void> {
|
export async function bootPopup(options: BootPopupOptions = {}): Promise<void> {
|
||||||
const currentDocument = options.document ?? document;
|
const currentDocument = options.document ?? document;
|
||||||
const popupConfig = readAuthConfig(options.config);
|
const popupConfig = readAuthConfig(options.config);
|
||||||
|
const currentVersion = options.currentVersion ?? readCurrentVersion();
|
||||||
const root = currentDocument.querySelector("#app");
|
const root = currentDocument.querySelector("#app");
|
||||||
const HTMLElementCtor = currentDocument.defaultView?.HTMLElement;
|
const HTMLElementCtor = currentDocument.defaultView?.HTMLElement;
|
||||||
|
|
||||||
@ -48,15 +60,28 @@ export async function bootPopup(options: BootPopupOptions = {}): Promise<void> {
|
|||||||
baseUrl: "http://127.0.0.1:4319",
|
baseUrl: "http://127.0.0.1:4319",
|
||||||
sendMessage
|
sendMessage
|
||||||
}).loadProtectedMockData;
|
}).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(
|
async function renderCurrentAuthState(
|
||||||
root: HTMLElement,
|
root: HTMLElement,
|
||||||
popupConfig: AuthConfig,
|
popupConfig: AuthConfig,
|
||||||
sendMessage: (message: unknown) => Promise<unknown>,
|
sendMessage: (message: unknown) => Promise<unknown>,
|
||||||
fetchProtectedApi: () => Promise<unknown>
|
fetchProtectedApi: () => Promise<unknown>,
|
||||||
|
updateOptions: {
|
||||||
|
currentVersion: string;
|
||||||
|
fetchUpdateManifest: () => Promise<UpdateManifest>;
|
||||||
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const response = await sendMessage({ type: "auth:get-state" });
|
const response = await sendMessage({ type: "auth:get-state" });
|
||||||
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") {
|
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") {
|
||||||
@ -71,19 +96,22 @@ async function renderCurrentAuthState(
|
|||||||
?.addEventListener("click", () => {
|
?.addEventListener("click", () => {
|
||||||
void runAuthAction(root, popupConfig, sendMessage, {
|
void runAuthAction(root, popupConfig, sendMessage, {
|
||||||
actionMessage: { type: "auth:sign-in" },
|
actionMessage: { type: "auth:sign-in" },
|
||||||
fetchProtectedApi
|
fetchProtectedApi,
|
||||||
|
updateOptions
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderLoggedIn(root, response.value);
|
renderLoggedIn(root, response.value);
|
||||||
|
void runUpdateCheck(root, sendMessage, updateOptions);
|
||||||
root
|
root
|
||||||
.querySelector('[data-popup-sign-out="button"]')
|
.querySelector('[data-popup-sign-out="button"]')
|
||||||
?.addEventListener("click", () => {
|
?.addEventListener("click", () => {
|
||||||
void runAuthAction(root, popupConfig, sendMessage, {
|
void runAuthAction(root, popupConfig, sendMessage, {
|
||||||
actionMessage: { type: "auth:sign-out" },
|
actionMessage: { type: "auth:sign-out" },
|
||||||
fetchProtectedApi
|
fetchProtectedApi,
|
||||||
|
updateOptions
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
if (popupConfig.enableDevAuthPanel) {
|
if (popupConfig.enableDevAuthPanel) {
|
||||||
@ -103,6 +131,10 @@ async function runAuthAction(
|
|||||||
options: {
|
options: {
|
||||||
actionMessage: { type: "auth:sign-in" } | { type: "auth:sign-out" };
|
actionMessage: { type: "auth:sign-in" } | { type: "auth:sign-out" };
|
||||||
fetchProtectedApi: () => Promise<unknown>;
|
fetchProtectedApi: () => Promise<unknown>;
|
||||||
|
updateOptions: {
|
||||||
|
currentVersion: string;
|
||||||
|
fetchUpdateManifest: () => Promise<UpdateManifest>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const response = await sendMessage(options.actionMessage);
|
const response = await sendMessage(options.actionMessage);
|
||||||
@ -121,7 +153,8 @@ async function runAuthAction(
|
|||||||
root,
|
root,
|
||||||
popupConfig,
|
popupConfig,
|
||||||
sendMessage,
|
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(
|
async function runProtectedApiProbe(
|
||||||
root: HTMLElement,
|
root: HTMLElement,
|
||||||
fetchProtectedApi: () => Promise<unknown>
|
fetchProtectedApi: () => Promise<unknown>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { AuthStateValue } from "../shared/auth-messages";
|
import type { AuthStateValue } from "../shared/auth-messages";
|
||||||
|
import type { UpdateManifest } from "../shared/update-check";
|
||||||
|
|
||||||
export function renderLoggedOut(root: HTMLElement, error?: string | null): void {
|
export function renderLoggedOut(root: HTMLElement, error?: string | null): void {
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
@ -23,11 +24,99 @@ export function renderLoggedIn(
|
|||||||
<p>已登录</p>
|
<p>已登录</p>
|
||||||
<p>${userInfo?.name ?? userInfo?.username ?? "未知用户"}</p>
|
<p>${userInfo?.name ?? userInfo?.username ?? "未知用户"}</p>
|
||||||
<p>${userInfo?.email ?? ""}</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>
|
<button type="button" data-popup-sign-out="button">退出登录</button>
|
||||||
</section>
|
</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(
|
export function renderDevPanel(
|
||||||
root: HTMLElement,
|
root: HTMLElement,
|
||||||
authState: AuthStateValue
|
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 });
|
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 () => {
|
test("responds to auth:get-state with auth status", async () => {
|
||||||
const listeners: Array<
|
const listeners: Array<
|
||||||
(message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => boolean | void
|
(message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => boolean | void
|
||||||
|
|||||||
@ -43,10 +43,11 @@ describe("manifest", () => {
|
|||||||
"https://*.xingtu.cn/ad/creator/market*",
|
"https://*.xingtu.cn/ad/creator/market*",
|
||||||
"https://login-api.intelligrow.cn/*",
|
"https://login-api.intelligrow.cn/*",
|
||||||
"https://talent-search.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(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");
|
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 () => {
|
test("renders a protected api test button in the dev panel", async () => {
|
||||||
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
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