d80a5255ed
CI / lint (push) Successful in 3s
extension / lint (push) Successful in 10s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 39s
CI / integration (push) Successful in 3m51s
extension / lint (pull_request) Successful in 10s
The extension is installed per-instance from the operator's FC host, so Firefox's
static update_url can't apply (each instance has a different host) and updates
were fully manual. Add a self-hosted-friendly update surface that reuses the
existing public GET /api/extension/manifest ({version, latest_url, sha256}):
- lib/api.js: getExtensionManifest().
- background.js: checkForUpdateInfo() compares the instance's latest published
version against runtime.getManifest().version (dotted-numeric compare so
1.0.10 > 1.0.9); CHECK_UPDATE message handler; refreshUpdateBadge() sets a
toolbar badge via browser.action; a daily browser.alarms check plus on
startup/installed. New 'alarms' permission (non-prompting).
- popup: an 'Update available — vX' banner with an Update button that opens the
signed XPI (web root, /api stripped like OPEN_ARTIST_PAGE) → Firefox's native
install prompt. Never blocks the popup on a failed check.
No backend changes (endpoint already exists). Bump 1.0.8→1.0.9 so this ships;
from here on updates surface themselves instead of needing a manual reinstall.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
371 lines
13 KiB
JavaScript
371 lines
13 KiB
JavaScript
/**
|
|
* Background script — message router + Discord token capture
|
|
* (webRequest) + Pixiv PKCE OAuth. Direct port of GS background.js;
|
|
* api.js client points at FC instead of GS.
|
|
*/
|
|
|
|
let discordToken = null;
|
|
let discordTokenCapturedAt = null;
|
|
|
|
let pixivRefreshToken = null;
|
|
let pixivTokenCapturedAt = null;
|
|
let pixivOAuthPending = null;
|
|
|
|
const PIXIV_CLIENT_ID = 'MOBrBDS8blbauoSck0ZfDbtuzpyT';
|
|
const PIXIV_CLIENT_SECRET = 'lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj';
|
|
const PIXIV_OAUTH_URL = 'https://app-api.pixiv.net/web/v1/login';
|
|
const PIXIV_TOKEN_URL = 'https://oauth.secure.pixiv.net/auth/token';
|
|
const PIXIV_REDIRECT_URI = 'https://app-api.pixiv.net/web/v1/users/auth/pixiv/callback';
|
|
|
|
let initialized = false;
|
|
|
|
async function ensureInitialized() {
|
|
if (initialized) return;
|
|
await api.init();
|
|
await loadDiscordToken();
|
|
await loadPixivToken();
|
|
initialized = true;
|
|
}
|
|
|
|
browser.runtime.onInstalled.addListener(() => ensureInitialized());
|
|
browser.runtime.onStartup.addListener(() => ensureInitialized());
|
|
ensureInitialized().catch(e => console.error('init failed:', e));
|
|
|
|
// ---- Extension self-update check (#1489) ----
|
|
// Installed per-instance from the operator's FC host, so Firefox's static
|
|
// update_url can't apply (each instance has a different host). Instead ask the
|
|
// configured backend for the latest published version and nudge the operator to
|
|
// reinstall the freshly-signed XPI — surfaced as a popup banner (on demand) and
|
|
// a toolbar badge (daily). /api/extension/manifest is public and returns
|
|
// {version, latest_url, sha256}; the XPI is served from the web root (not /api).
|
|
|
|
function versionIsNewer(candidate, current) {
|
|
// Dotted numeric compare so 1.0.10 > 1.0.9 (a plain string compare wouldn't).
|
|
const a = String(candidate).split('.').map(n => parseInt(n, 10) || 0);
|
|
const b = String(current).split('.').map(n => parseInt(n, 10) || 0);
|
|
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) > (b[i] || 0);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function checkForUpdateInfo() {
|
|
await ensureInitialized();
|
|
if (!api.isConfigured()) return { updateAvailable: false, configured: false };
|
|
let info;
|
|
try {
|
|
info = await api.getExtensionManifest();
|
|
} catch (e) {
|
|
return { updateAvailable: false, error: e.message };
|
|
}
|
|
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$/, '');
|
|
return {
|
|
updateAvailable: !!latestVersion && versionIsNewer(latestVersion, currentVersion),
|
|
currentVersion,
|
|
latestVersion,
|
|
xpiUrl: info && info.latest_url ? `${base}${info.latest_url}` : null,
|
|
};
|
|
}
|
|
|
|
async function refreshUpdateBadge() {
|
|
let r;
|
|
try { r = await checkForUpdateInfo(); } catch { return; }
|
|
try {
|
|
await browser.action.setBadgeText({ text: r.updateAvailable ? '↑' : '' });
|
|
if (r.updateAvailable) {
|
|
await browser.action.setBadgeBackgroundColor({ color: '#F4BA7A' });
|
|
await browser.action.setTitle({ title: `FabledCurator — update available (v${r.latestVersion})` });
|
|
} else {
|
|
await browser.action.setTitle({ title: 'FabledCurator' });
|
|
}
|
|
} catch { /* action API unavailable — non-fatal */ }
|
|
}
|
|
|
|
// Daily proactive check (needs the "alarms" permission). create() is idempotent
|
|
// by name, so re-running it on each event-page load is safe.
|
|
browser.alarms.create('fc-update-check', { periodInMinutes: 24 * 60, delayInMinutes: 1 });
|
|
browser.alarms.onAlarm.addListener((alarm) => {
|
|
if (alarm.name === 'fc-update-check') refreshUpdateBadge();
|
|
});
|
|
browser.runtime.onStartup.addListener(() => refreshUpdateBadge());
|
|
browser.runtime.onInstalled.addListener(() => refreshUpdateBadge());
|
|
|
|
// ---- Discord token capture via webRequest ----
|
|
|
|
browser.webRequest.onBeforeSendHeaders.addListener(
|
|
(details) => {
|
|
const auth = details.requestHeaders?.find(h => h.name.toLowerCase() === 'authorization');
|
|
if (auth?.value && auth.value !== discordToken) {
|
|
saveDiscordToken(auth.value);
|
|
}
|
|
},
|
|
{ urls: ['https://discord.com/api/*'] },
|
|
['requestHeaders'],
|
|
);
|
|
|
|
async function loadDiscordToken() {
|
|
const s = await browser.storage.local.get(['discordToken', 'discordTokenCapturedAt']);
|
|
discordToken = s.discordToken || null;
|
|
discordTokenCapturedAt = s.discordTokenCapturedAt || null;
|
|
}
|
|
|
|
async function saveDiscordToken(token) {
|
|
discordToken = token;
|
|
discordTokenCapturedAt = new Date().toISOString();
|
|
await browser.storage.local.set({ discordToken: token, discordTokenCapturedAt });
|
|
}
|
|
|
|
// ---- Pixiv PKCE OAuth ----
|
|
|
|
async function loadPixivToken() {
|
|
const s = await browser.storage.local.get(['pixivRefreshToken', 'pixivTokenCapturedAt']);
|
|
pixivRefreshToken = s.pixivRefreshToken || null;
|
|
pixivTokenCapturedAt = s.pixivTokenCapturedAt || null;
|
|
}
|
|
|
|
async function savePixivToken(token) {
|
|
pixivRefreshToken = token;
|
|
pixivTokenCapturedAt = new Date().toISOString();
|
|
await browser.storage.local.set({ pixivRefreshToken: token, pixivTokenCapturedAt });
|
|
}
|
|
|
|
function generateCodeVerifier() {
|
|
const a = new Uint8Array(32);
|
|
crypto.getRandomValues(a);
|
|
return base64UrlEncode(a);
|
|
}
|
|
|
|
async function generateCodeChallenge(verifier) {
|
|
const data = new TextEncoder().encode(verifier);
|
|
const hash = await crypto.subtle.digest('SHA-256', data);
|
|
return base64UrlEncode(new Uint8Array(hash));
|
|
}
|
|
|
|
function base64UrlEncode(buf) {
|
|
return btoa(String.fromCharCode(...buf)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
}
|
|
|
|
async function initiatePixivOAuth() {
|
|
const codeVerifier = generateCodeVerifier();
|
|
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
|
|
const params = new URLSearchParams({
|
|
code_challenge: codeChallenge,
|
|
code_challenge_method: 'S256',
|
|
client: 'pixiv-android',
|
|
});
|
|
const tab = await browser.tabs.create({ url: `${PIXIV_OAUTH_URL}?${params}` });
|
|
|
|
return new Promise((resolve, reject) => {
|
|
pixivOAuthPending = { codeVerifier, tabId: tab.id, resolve, reject };
|
|
setTimeout(() => {
|
|
if (pixivOAuthPending && pixivOAuthPending.tabId === tab.id) {
|
|
pixivOAuthPending = null;
|
|
reject(new Error('Pixiv OAuth timed out (5 min)'));
|
|
}
|
|
}, 5 * 60 * 1000);
|
|
});
|
|
}
|
|
|
|
browser.webRequest.onBeforeRedirect.addListener(
|
|
async (details) => {
|
|
if (!pixivOAuthPending) return;
|
|
if (details.tabId !== pixivOAuthPending.tabId) return;
|
|
const url = new URL(details.redirectUrl);
|
|
const code = url.searchParams.get('code');
|
|
if (!code) return;
|
|
const verifier = pixivOAuthPending.codeVerifier;
|
|
const resolve = pixivOAuthPending.resolve;
|
|
const reject = pixivOAuthPending.reject;
|
|
pixivOAuthPending = null;
|
|
try {
|
|
const tokenResp = await fetch(PIXIV_TOKEN_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({
|
|
client_id: PIXIV_CLIENT_ID,
|
|
client_secret: PIXIV_CLIENT_SECRET,
|
|
code,
|
|
code_verifier: verifier,
|
|
grant_type: 'authorization_code',
|
|
include_policy: 'true',
|
|
redirect_uri: PIXIV_REDIRECT_URI,
|
|
}),
|
|
});
|
|
const body = await tokenResp.json();
|
|
if (!body.refresh_token) {
|
|
reject(new Error(`Pixiv token exchange failed: ${JSON.stringify(body)}`));
|
|
return;
|
|
}
|
|
await savePixivToken(body.refresh_token);
|
|
try { await browser.tabs.remove(details.tabId); } catch {}
|
|
resolve(body.refresh_token);
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
},
|
|
{ urls: ['https://app-api.pixiv.net/web/v1/users/auth/pixiv/callback*'] },
|
|
);
|
|
|
|
// ---- Message router ----
|
|
|
|
browser.runtime.onMessage.addListener(async (msg) => {
|
|
await ensureInitialized();
|
|
switch (msg.type) {
|
|
case 'GET_CONFIG':
|
|
return { apiUrl: api.baseUrl, apiKey: api.apiKey };
|
|
|
|
case 'TEST_CONNECTION':
|
|
try {
|
|
await api.testConnection();
|
|
return { connected: true };
|
|
} catch (e) {
|
|
return { connected: false, error: e.message };
|
|
}
|
|
|
|
case 'GET_PLATFORM_STATUS': {
|
|
const status = {};
|
|
for (const key of Object.keys(PLATFORMS)) {
|
|
if (PLATFORMS[key].authType === 'cookies') {
|
|
status[key] = { hasCookies: false, cookieCount: 0 };
|
|
try {
|
|
const n = await getCookieCount(key);
|
|
status[key] = { hasCookies: n > 0, cookieCount: n };
|
|
} catch (e) {
|
|
status[key] = { error: e.message };
|
|
}
|
|
} else if (key === 'discord') {
|
|
status[key] = { hasToken: !!discordToken, capturedAt: discordTokenCapturedAt };
|
|
} else if (key === 'pixiv') {
|
|
status[key] = { hasToken: !!pixivRefreshToken, capturedAt: pixivTokenCapturedAt };
|
|
} else {
|
|
status[key] = {};
|
|
}
|
|
}
|
|
return status;
|
|
}
|
|
|
|
case 'EXPORT_COOKIES': {
|
|
const key = msg.platform;
|
|
const platform = PLATFORMS[key];
|
|
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) {
|
|
return {
|
|
error: `Captured ${cookies.length} ${platform.name} cookies but they don't appear authenticated (${v.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 };
|
|
}
|
|
if (key === 'discord') {
|
|
if (!discordToken) return { error: 'Open discord.com to capture a token first.' };
|
|
await api.uploadCredentials('discord', 'token', discordToken);
|
|
return { success: true };
|
|
}
|
|
if (key === 'pixiv') {
|
|
if (!pixivRefreshToken) {
|
|
await initiatePixivOAuth();
|
|
}
|
|
await api.uploadCredentials('pixiv', 'token', pixivRefreshToken);
|
|
return { success: true };
|
|
}
|
|
return { error: 'Unsupported platform.' };
|
|
} catch (e) {
|
|
return { error: e.message };
|
|
}
|
|
}
|
|
|
|
case 'EXPORT_ALL_COOKIES': {
|
|
const results = {};
|
|
for (const key of Object.keys(PLATFORMS)) {
|
|
if (PLATFORMS[key].authType !== 'cookies') {
|
|
results[key] = { skipped: true };
|
|
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 };
|
|
} catch (e) {
|
|
results[key] = { error: e.message };
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
case 'LIST_SOURCES':
|
|
try {
|
|
return { sources: await api.listSources() };
|
|
} catch (e) {
|
|
return { error: e.message };
|
|
}
|
|
|
|
case 'CHECK_SOURCE':
|
|
try {
|
|
return await api.triggerSourceCheck(msg.sourceId);
|
|
} catch (e) {
|
|
return { error: e.message };
|
|
}
|
|
|
|
case 'ADD_AS_SOURCE':
|
|
try {
|
|
return await api.quickAddSource(msg.url);
|
|
} catch (e) {
|
|
return { error: e.message };
|
|
}
|
|
|
|
case 'PROBE_SOURCE':
|
|
try {
|
|
return await api.probeSource(msg.url);
|
|
} catch (e) {
|
|
return { error: e.message };
|
|
}
|
|
|
|
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$/, '');
|
|
const slug = encodeURIComponent(msg.slug || '');
|
|
if (!base || !slug) return { error: 'apiUrl or slug missing' };
|
|
try {
|
|
await browser.tabs.create({ url: `${base}/artist/${slug}` });
|
|
return { success: true };
|
|
} catch (e) {
|
|
return { error: e.message };
|
|
}
|
|
}
|
|
|
|
case 'CHECK_UPDATE':
|
|
return await checkForUpdateInfo();
|
|
|
|
default:
|
|
return { error: `Unknown message type: ${msg.type}` };
|
|
}
|
|
});
|