feat: add extension update check
This commit is contained in:
@@ -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;
|
||||
|
||||
+137
-5
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const UPDATE_MANIFEST_URL =
|
||||
"https://example.com/star-chart-search-enhancer/latest.json";
|
||||
Reference in New Issue
Block a user