diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index b627d9d..5a0c33a 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -102,21 +102,77 @@ header { display: flex; align-items: center; gap: 8px; + min-width: 0; + flex: 1; } .header-left svg { color: var(--blue); } -.header-left h1 { - font-size: 16px; +.header-version-block { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.header-version-main { + min-width: 0; + display: flex; + align-items: baseline; + gap: 8px; +} + +.header-version-title { + font-size: 15px; font-weight: 700; letter-spacing: -0.02em; color: var(--text-primary); + min-width: 0; + flex: 0 1 auto; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.header-version-title.is-version-label { color: var(--blue); } +.header-version-title.is-update-available { color: var(--orange); } +.header-version-title.is-check-failed { color: var(--text-primary); } + +.header-version-meta { + min-width: 0; + font-size: 12px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.header-version-meta[hidden] { + display: none !important; +} + +.header-link-btn { + padding: 0; + border: none; + background: transparent; + color: var(--blue); + font: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; + flex-shrink: 0; +} + +.header-link-btn:hover { + color: var(--text-primary); + text-decoration: underline; } .header-btns { display: flex; align-items: center; gap: 4px; + flex-shrink: 0; } /* ============================================================ @@ -282,6 +338,118 @@ header { .btn-sm { padding: 5px 12px; font-size: 12px; } .btn-xs { padding: 4px 10px; font-size: 11px; } +/* ============================================================ + Extension Updates + ============================================================ */ + +#update-section { margin-bottom: 14px; } + +.update-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 12px; + box-shadow: var(--shadow-sm); +} + +.update-card-header { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.update-card-copy { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.update-card-version { + font-size: 14px; + font-weight: 700; + color: var(--orange); +} + +.update-card-summary { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.6; +} + +.update-release-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.update-release-item { + padding-top: 10px; + border-top: 1px solid var(--border-subtle); +} + +.update-release-item:first-child { + padding-top: 0; + border-top: none; +} + +.update-release-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; + margin-bottom: 6px; +} + +.update-release-title-row { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; +} + +.update-release-version { + font-size: 13px; + font-weight: 700; + color: var(--text-primary); +} + +.update-release-name { + min-width: 0; + font-size: 12px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.update-release-date { + flex-shrink: 0; + font-family: 'JetBrains Mono', 'Consolas', monospace; + font-size: 11px; + color: var(--text-muted); +} + +.update-release-notes { + margin: 0; + padding-left: 18px; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.55; +} + +.update-release-notes li + li { margin-top: 4px; } + +.update-release-empty { + font-size: 12px; + color: var(--text-muted); +} + /* ============================================================ Data Card ============================================================ */ diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index e698e2d..a5d9db6 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -20,7 +20,13 @@ stroke-linecap="round" stroke-linejoin="round"> -

多页面

+
+
+
v0.0.0
+ +
+ +
@@ -69,6 +75,20 @@
+ +
@@ -374,6 +394,7 @@
+ diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index d7c6ee5..bf68f08 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -11,6 +11,14 @@ const STATUS_ICONS = { }; const logArea = document.getElementById('log-area'); +const updateSection = document.getElementById('update-section'); +const extensionUpdateStatus = document.getElementById('extension-update-status'); +const extensionVersionMeta = document.getElementById('extension-version-meta'); +const btnReleaseLog = document.getElementById('btn-release-log'); +const updateCardVersion = document.getElementById('update-card-version'); +const updateCardSummary = document.getElementById('update-card-summary'); +const updateReleaseList = document.getElementById('update-release-list'); +const btnOpenRelease = document.getElementById('btn-open-release'); const settingsCard = document.getElementById('settings-card'); const displayOauthUrl = document.getElementById('display-oauth-url'); const displayLocalhostUrl = document.getElementById('display-localhost-url'); @@ -139,6 +147,7 @@ let hotmailActionInFlight = false; let hotmailListExpanded = false; let configMenuOpen = false; let configActionInFlight = false; +let currentReleaseSnapshot = null; const EYE_OPEN_ICON = ''; const EYE_CLOSED_ICON = ''; @@ -150,6 +159,7 @@ const filterHotmailAccountsByUsage = window.HotmailUtils?.filterHotmailAccountsB const getHotmailBulkActionLabel = window.HotmailUtils?.getHotmailBulkActionLabel; const getHotmailListToggleLabel = window.HotmailUtils?.getHotmailListToggleLabel; const HOTMAIL_LIST_EXPANDED_STORAGE_KEY = 'multipage-hotmail-list-expanded'; +const sidepanelUpdateService = window.SidepanelUpdateService; const MAIL_PROVIDER_LOGIN_CONFIGS = { '163': { label: '163 邮箱', @@ -950,6 +960,212 @@ async function restoreState() { } } +function openExternalUrl(url) { + const targetUrl = String(url || '').trim(); + if (!targetUrl) { + return; + } + + if (chrome?.tabs?.create) { + chrome.tabs.create({ url: targetUrl, active: true }).catch(() => { + window.open(targetUrl, '_blank', 'noopener'); + }); + return; + } + + window.open(targetUrl, '_blank', 'noopener'); +} + +function createUpdateNoteList(notes = []) { + if (!Array.isArray(notes) || notes.length === 0) { + const empty = document.createElement('p'); + empty.className = 'update-release-empty'; + empty.textContent = '该版本未提供可解析的更新说明,请查看完整更新日志。'; + return empty; + } + + const list = document.createElement('ul'); + list.className = 'update-release-notes'; + + notes.forEach((note) => { + const item = document.createElement('li'); + item.textContent = note; + list.appendChild(item); + }); + + return list; +} + +function renderUpdateReleaseList(releases = []) { + if (!updateReleaseList) { + return; + } + + updateReleaseList.innerHTML = ''; + + releases.forEach((release) => { + const item = document.createElement('article'); + item.className = 'update-release-item'; + + const head = document.createElement('div'); + head.className = 'update-release-head'; + + const titleRow = document.createElement('div'); + titleRow.className = 'update-release-title-row'; + + const version = document.createElement('span'); + version.className = 'update-release-version'; + version.textContent = `v${release.version}`; + titleRow.appendChild(version); + + if (release.title) { + const name = document.createElement('span'); + name.className = 'update-release-name'; + name.textContent = release.title; + titleRow.appendChild(name); + } + + head.appendChild(titleRow); + + const publishedAt = sidepanelUpdateService?.formatReleaseDate?.(release.publishedAt) || ''; + if (publishedAt) { + const date = document.createElement('span'); + date.className = 'update-release-date'; + date.textContent = publishedAt; + head.appendChild(date); + } + + item.appendChild(head); + item.appendChild(createUpdateNoteList(release.notes)); + updateReleaseList.appendChild(item); + }); +} + +function resetUpdateCard() { + if (updateSection) { + updateSection.hidden = true; + } + if (updateCardVersion) { + updateCardVersion.textContent = ''; + } + if (updateCardSummary) { + updateCardSummary.textContent = ''; + } + if (updateReleaseList) { + updateReleaseList.innerHTML = ''; + } + if (btnOpenRelease) { + btnOpenRelease.hidden = true; + btnOpenRelease.onclick = null; + } +} + +function renderReleaseSnapshot(snapshot) { + currentReleaseSnapshot = snapshot; + + if (!extensionUpdateStatus || !extensionVersionMeta) { + return; + } + + extensionUpdateStatus.classList.remove('is-update-available', 'is-check-failed', 'is-version-label'); + + const localVersionText = snapshot?.localVersion ? `v${snapshot.localVersion}` : ''; + const logUrl = snapshot?.logUrl || snapshot?.releasesPageUrl || sidepanelUpdateService?.releasesPageUrl || ''; + + if (btnReleaseLog) { + btnReleaseLog.onclick = () => openExternalUrl(logUrl); + btnReleaseLog.hidden = true; + } + extensionVersionMeta.hidden = true; + extensionVersionMeta.textContent = ''; + + switch (snapshot?.status) { + case 'update-available': { + extensionUpdateStatus.textContent = '有更新'; + extensionUpdateStatus.classList.add('is-update-available'); + if (btnReleaseLog) { + btnReleaseLog.hidden = false; + } + + if (updateSection) { + updateSection.hidden = false; + } + if (updateCardVersion) { + updateCardVersion.textContent = `最新版本 v${snapshot.latestVersion}`; + } + if (updateCardSummary) { + const updateCount = Array.isArray(snapshot.newerReleases) ? snapshot.newerReleases.length : 0; + updateCardSummary.textContent = updateCount > 1 + ? `当前 ${localVersionText},共有 ${updateCount} 个新版本可更新。` + : `当前 ${localVersionText},可更新到 v${snapshot.latestVersion}。`; + } + renderUpdateReleaseList(snapshot.newerReleases || []); + if (btnOpenRelease) { + btnOpenRelease.hidden = false; + btnOpenRelease.textContent = '前往更新'; + btnOpenRelease.onclick = () => openExternalUrl(logUrl); + } + break; + } + + case 'latest': { + extensionUpdateStatus.textContent = localVersionText || 'v0.0.0'; + extensionUpdateStatus.classList.add('is-version-label'); + resetUpdateCard(); + break; + } + + case 'empty': { + extensionUpdateStatus.textContent = localVersionText || 'v0.0.0'; + extensionUpdateStatus.classList.add('is-version-label'); + resetUpdateCard(); + break; + } + + case 'error': + default: { + extensionUpdateStatus.textContent = localVersionText || 'v0.0.0'; + extensionUpdateStatus.classList.add('is-version-label', 'is-check-failed'); + extensionVersionMeta.textContent = snapshot?.errorMessage || 'GitHub Releases 检查失败'; + extensionVersionMeta.hidden = false; + resetUpdateCard(); + break; + } + } +} + +async function initializeReleaseInfo() { + const fallbackReleaseUrl = sidepanelUpdateService?.releasesPageUrl || 'https://github.com/QLHazyCoder/codex-oauth-automation-extension/releases'; + + if (btnReleaseLog) { + btnReleaseLog.onclick = () => openExternalUrl(currentReleaseSnapshot?.logUrl || fallbackReleaseUrl); + } + + if (!extensionUpdateStatus || !extensionVersionMeta) { + return; + } + + const localVersion = sidepanelUpdateService?.stripVersionPrefix?.(chrome.runtime.getManifest()?.version || '') || ''; + extensionUpdateStatus.textContent = localVersion ? `v${localVersion}` : 'v0.0.0'; + extensionUpdateStatus.classList.remove('is-update-available', 'is-check-failed'); + extensionUpdateStatus.classList.add('is-version-label'); + extensionVersionMeta.hidden = true; + extensionVersionMeta.textContent = ''; + if (btnReleaseLog) { + btnReleaseLog.hidden = true; + } + resetUpdateCard(); + + if (!sidepanelUpdateService) { + extensionVersionMeta.textContent = '更新检查服务不可用'; + extensionVersionMeta.hidden = false; + return; + } + + const snapshot = await sidepanelUpdateService.getReleaseSnapshot(); + renderReleaseSnapshot(snapshot); +} + function syncPasswordField(state) { inputPassword.value = state.customPassword || state.password || ''; } @@ -2679,6 +2895,9 @@ initHotmailListExpandedState(); updateSaveButtonState(); updateConfigMenuControls(); setLocalCpaStep9Mode(DEFAULT_LOCAL_CPA_STEP9_MODE); +initializeReleaseInfo().catch((err) => { + console.error('Failed to initialize release info:', err); +}); restoreState().then(() => { syncPasswordToggleLabel(); syncVpsUrlToggleLabel(); diff --git a/sidepanel/update-service.js b/sidepanel/update-service.js new file mode 100644 index 0000000..3b264dc --- /dev/null +++ b/sidepanel/update-service.js @@ -0,0 +1,288 @@ +(() => { + const GITHUB_OWNER = 'QLHazyCoder'; + const GITHUB_REPO = 'codex-oauth-automation-extension'; + const RELEASES_PAGE_URL = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/releases`; + const RELEASES_API_URL = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases?per_page=10`; + const CACHE_KEY = 'multipage-release-snapshot-v1'; + const CACHE_TTL_MS = 60 * 60 * 1000; + const FETCH_TIMEOUT_MS = 8000; + const MAX_RELEASES = 10; + const MAX_NOTES_PER_RELEASE = 5; + + function stripVersionPrefix(version) { + return String(version || '').trim().replace(/^v/i, ''); + } + + function parseVersionParts(version) { + const core = stripVersionPrefix(version).split('-')[0]; + if (!core) { + return [0]; + } + + return core.split('.').map((part) => { + const numeric = Number.parseInt(part, 10); + return Number.isFinite(numeric) ? numeric : 0; + }); + } + + function compareVersions(left, right) { + const leftParts = parseVersionParts(left); + const rightParts = parseVersionParts(right); + const maxLength = Math.max(leftParts.length, rightParts.length, 3); + + for (let index = 0; index < maxLength; index += 1) { + const leftPart = leftParts[index] || 0; + const rightPart = rightParts[index] || 0; + if (leftPart > rightPart) { + return 1; + } + if (leftPart < rightPart) { + return -1; + } + } + + return 0; + } + + function sanitizeInlineMarkdown(text) { + return String(text || '') + .replace(/!\[[^\]]*]\(([^)]+)\)/g, '') + .replace(/\[([^\]]+)]\(([^)]+)\)/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/[*_~>#]/g, '') + .replace(/\s+/g, ' ') + .trim(); + } + + function parseReleaseNotes(body) { + const lines = String(body || '') + .replace(/\r\n/g, '\n') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + + const bulletLines = []; + const plainLines = []; + + for (const line of lines) { + if (/^#{1,6}\s*/.test(line)) { + continue; + } + + if (/^```/.test(line) || /^---+$/.test(line)) { + continue; + } + + if (/^[-*+]\s+/.test(line)) { + bulletLines.push(line.replace(/^[-*+]\s+/, '')); + continue; + } + + if (/^\d+\.\s+/.test(line)) { + bulletLines.push(line.replace(/^\d+\.\s+/, '')); + continue; + } + + plainLines.push(line); + } + + const noteLines = bulletLines.length > 0 ? bulletLines : plainLines; + return noteLines + .map(sanitizeInlineMarkdown) + .filter(Boolean) + .slice(0, MAX_NOTES_PER_RELEASE); + } + + function normalizeReleaseVersion(release) { + const candidates = [ + release?.tag_name, + release?.name, + ]; + + for (const candidate of candidates) { + const normalized = stripVersionPrefix(candidate); + if (normalized) { + return normalized; + } + } + + return ''; + } + + function sanitizeRelease(release) { + const version = normalizeReleaseVersion(release); + if (!version) { + return null; + } + + const rawTitle = sanitizeInlineMarkdown(release?.name || ''); + const normalizedTitle = stripVersionPrefix(rawTitle) === version ? '' : rawTitle; + + return { + version, + title: normalizedTitle, + url: String(release?.html_url || RELEASES_PAGE_URL), + publishedAt: String(release?.published_at || release?.created_at || ''), + notes: parseReleaseNotes(release?.body || ''), + }; + } + + function readCache() { + try { + const raw = localStorage.getItem(CACHE_KEY); + if (!raw) { + return null; + } + + const parsed = JSON.parse(raw); + if (!parsed || !Array.isArray(parsed.releases) || !Number.isFinite(parsed.fetchedAt)) { + return null; + } + + if ((Date.now() - parsed.fetchedAt) > CACHE_TTL_MS) { + return null; + } + + return parsed.releases; + } catch (error) { + return null; + } + } + + function writeCache(releases) { + try { + localStorage.setItem(CACHE_KEY, JSON.stringify({ + fetchedAt: Date.now(), + releases, + })); + } catch (error) { + // Ignore cache write failures. + } + } + + async function fetchReleases() { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const response = await fetch(RELEASES_API_URL, { + method: 'GET', + headers: { + Accept: 'application/vnd.github+json', + }, + cache: 'no-store', + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`GitHub Releases 请求失败(${response.status})`); + } + + const payload = await response.json(); + if (!Array.isArray(payload)) { + throw new Error('GitHub Releases 返回格式异常'); + } + + const releases = payload + .filter((release) => release && !release.draft && !release.prerelease) + .map(sanitizeRelease) + .filter(Boolean) + .sort((left, right) => compareVersions(right.version, left.version)) + .slice(0, MAX_RELEASES); + + writeCache(releases); + return releases; + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error('GitHub Releases 请求超时'); + } + throw error; + } finally { + clearTimeout(timeoutId); + } + } + + async function loadReleases(options = {}) { + if (!options.force) { + const cached = readCache(); + if (cached) { + return cached; + } + } + + return fetchReleases(); + } + + function buildReleaseSnapshot(releases, localVersion) { + const latestRelease = releases[0] || null; + if (!latestRelease) { + return { + status: 'empty', + localVersion, + latestVersion: null, + latestRelease: null, + newerReleases: [], + logUrl: RELEASES_PAGE_URL, + releasesPageUrl: RELEASES_PAGE_URL, + checkedAt: Date.now(), + }; + } + + const newerReleases = releases.filter((release) => compareVersions(release.version, localVersion) > 0); + return { + status: newerReleases.length > 0 ? 'update-available' : 'latest', + localVersion, + latestVersion: latestRelease.version, + latestRelease, + newerReleases, + logUrl: latestRelease.url || RELEASES_PAGE_URL, + releasesPageUrl: RELEASES_PAGE_URL, + checkedAt: Date.now(), + }; + } + + async function getReleaseSnapshot(options = {}) { + const localVersion = stripVersionPrefix(chrome.runtime.getManifest()?.version || '0.0.0'); + + try { + const releases = await loadReleases(options); + return buildReleaseSnapshot(releases, localVersion); + } catch (error) { + return { + status: 'error', + localVersion, + latestVersion: null, + latestRelease: null, + newerReleases: [], + logUrl: RELEASES_PAGE_URL, + releasesPageUrl: RELEASES_PAGE_URL, + checkedAt: Date.now(), + errorMessage: error?.message || '更新检查失败', + }; + } + } + + function formatReleaseDate(value) { + if (!value) { + return ''; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return ''; + } + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + + window.SidepanelUpdateService = { + compareVersions, + formatReleaseDate, + getReleaseSnapshot, + releasesPageUrl: RELEASES_PAGE_URL, + stripVersionPrefix, + }; +})();