diff --git a/extension/background/background.js b/extension/background/background.js index e3e3bc7..9fc8f59 100644 --- a/extension/background/background.js +++ b/extension/background/background.js @@ -60,9 +60,8 @@ async function checkForUpdateInfo() { } const currentVersion = browser.runtime.getManifest().version; const latestVersion = info && info.version ? info.version : null; - // latest_url is served from the web root; strip the /api suffix off baseUrl - // (same transform as OPEN_ARTIST_PAGE). - const base = (api.baseUrl || '').replace(/\/+$/, '').replace(/\/api$/, ''); + // latest_url is served from the web root, not the JSON API. + const base = api.webRoot(); return { updateAvailable: !!latestVersion && versionIsNewer(latestVersion, currentVersion), currentVersion, @@ -211,6 +210,21 @@ browser.webRequest.onBeforeRedirect.addListener( { urls: ['https://app-api.pixiv.net/web/v1/users/auth/pixiv/callback*'] }, ); +// Extract → verify → upload one cookie-auth platform. Returns a structured +// outcome so the two callers (EXPORT_COOKIES single, EXPORT_ALL_COOKIES) shape +// their own response + skip semantics. Verifies the captured cookies are +// actually live BEFORE uploading, so a confirmed-stale session doesn't overwrite +// good FC-side credentials; platforms with no verify config (v.ok === null) fall +// through to upload. +async function exportPlatformCookies(key) { + const cookies = await extractCookiesForPlatform(key); + if (cookies.length === 0) return { status: 'empty' }; + const v = await verifyCookiesForPlatform(key); + if (v.ok === false) return { status: 'stale', reason: v.reason, cookieCount: cookies.length }; + await api.uploadCredentials(key, 'cookies', toNetscapeFormat(cookies)); + return { status: 'ok', cookieCount: cookies.length, verified: v.ok === true }; +} + // ---- Message router ---- browser.runtime.onMessage.addListener(async (msg) => { @@ -255,22 +269,14 @@ browser.runtime.onMessage.addListener(async (msg) => { if (!platform) return { error: `Unknown platform: ${key}` }; try { if (platform.authType === 'cookies') { - const cookies = await extractCookiesForPlatform(key); - if (cookies.length === 0) return { error: 'No cookies found — log in first.' }; - // Verify the captured cookies are actually live BEFORE - // uploading. Skips upload on confirmed-stale sessions so we - // don't overwrite FC-side credentials with garbage. Platforms - // without a verify config (verify.ok === null) fall through - // to upload as before. - const v = await verifyCookiesForPlatform(key); - if (v.ok === false) { + const r = await exportPlatformCookies(key); + if (r.status === 'empty') return { error: 'No cookies found — log in first.' }; + if (r.status === 'stale') { return { - error: `Captured ${cookies.length} ${platform.name} cookies but they don't appear authenticated (${v.reason}). Log in again in this browser, then retry.`, + error: `Captured ${r.cookieCount} ${platform.name} cookies but they don't appear authenticated (${r.reason}). Log in again in this browser, then retry.`, }; } - const data = toNetscapeFormat(cookies); - await api.uploadCredentials(key, 'cookies', data); - return { success: true, cookieCount: cookies.length, verified: v.ok === true }; + return { success: true, cookieCount: r.cookieCount, verified: r.verified }; } if (key === 'discord') { if (!discordToken) return { error: 'Open discord.com to capture a token first.' }; @@ -298,18 +304,10 @@ browser.runtime.onMessage.addListener(async (msg) => { continue; } try { - const cookies = await extractCookiesForPlatform(key); - if (cookies.length === 0) { - results[key] = { skipped: true, reason: 'no cookies' }; - continue; - } - const v = await verifyCookiesForPlatform(key); - if (v.ok === false) { - results[key] = { error: `verify failed: ${v.reason}` }; - continue; - } - await api.uploadCredentials(key, 'cookies', toNetscapeFormat(cookies)); - results[key] = { success: true, cookieCount: cookies.length, verified: v.ok === true }; + const r = await exportPlatformCookies(key); + if (r.status === 'empty') results[key] = { skipped: true, reason: 'no cookies' }; + else if (r.status === 'stale') results[key] = { error: `verify failed: ${r.reason}` }; + else results[key] = { success: true, cookieCount: r.cookieCount, verified: r.verified }; } catch (e) { results[key] = { error: e.message }; } @@ -346,11 +344,9 @@ browser.runtime.onMessage.addListener(async (msg) => { } case 'OPEN_ARTIST_PAGE': { - // apiUrl is configured with the /api suffix (see - // options/options.html placeholder); the SPA artist route is - // /artist/:slug, served from the same origin. Strip /api so the - // browser-level URL hits the Vue router, not the JSON API. - const base = (api.baseUrl || '').replace(/\/+$/, '').replace(/\/api$/, ''); + // The SPA artist route (/artist/:slug) is served from the web root, not + // the JSON API — see api.webRoot(). + const base = api.webRoot(); const slug = encodeURIComponent(msg.slug || ''); if (!base || !slug) return { error: 'apiUrl or slug missing' }; try { diff --git a/extension/lib/api.js b/extension/lib/api.js index 2554a97..a6b71b2 100644 --- a/extension/lib/api.js +++ b/extension/lib/api.js @@ -96,6 +96,13 @@ class FabledCuratorAPI { return this.request('GET', '/extension/manifest'); } + // The web/SPA root: baseUrl with the trailing slash + `/api` suffix stripped. + // Where the Vue router (artist pages) and the served XPI live, NOT the JSON + // API. Used by OPEN_ARTIST_PAGE + the self-update check. + webRoot() { + return (this.baseUrl || '').replace(/\/+$/, '').replace(/\/api$/, ''); + } + // Connection test = the cheapest read with auth. testConnection() { return this.request('GET', '/credentials'); diff --git a/extension/popup/popup.js b/extension/popup/popup.js index 8983d8b..4ffcd8e 100644 --- a/extension/popup/popup.js +++ b/extension/popup/popup.js @@ -2,6 +2,15 @@ document.addEventListener('DOMContentLoaded', init); const CONNECTION_TEST_INTERVAL = 2 * 60 * 1000; +// A centered muted note div — the loading / empty state shared by the platform +// and sources lists. +function mutedNote(text) { + const d = document.createElement('div'); + d.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);'; + d.textContent = text; + return d; +} + async function init() { try { const cfg = await browser.runtime.sendMessage({ type: 'GET_CONFIG' }); @@ -38,10 +47,7 @@ function showSetupRequired() { function showPlatformsLoading() { const c = document.getElementById('platforms-list'); c.textContent = ''; - const d = document.createElement('div'); - d.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);'; - d.textContent = 'Loading platforms…'; - c.appendChild(d); + c.appendChild(mutedNote('Loading platforms…')); } async function testConnectionIfNeeded() { @@ -183,10 +189,7 @@ async function exportAllCookies() { async function loadSources() { const c = document.getElementById('sources-list'); c.textContent = ''; - const d = document.createElement('div'); - d.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);'; - d.textContent = 'Loading sources…'; - c.appendChild(d); + c.appendChild(mutedNote('Loading sources…')); const r = await browser.runtime.sendMessage({ type: 'LIST_SOURCES' }); c.textContent = ''; if (r.error) { @@ -197,10 +200,7 @@ async function loadSources() { return; } if (!r.sources || r.sources.length === 0) { - const empty = document.createElement('div'); - empty.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);'; - empty.textContent = 'No sources yet.'; - c.appendChild(empty); + c.appendChild(mutedNote('No sources yet.')); return; } for (const src of r.sources) c.appendChild(createSourceRow(src));