CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 5s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 32s
extension / lint (push) Successful in 28s
CI / integration (push) Successful in 3m52s
Build images / sign-extension (push) Successful in 4s
Build images / build-ml (push) Failing after 5s
Build images / build-agent (push) Successful in 13s
Build images / build-web (push) Successful in 2m4s
Closes the half of the ask the signing work didn't: a way to tell a dev
build from a main one. FC_CHANNEL is baked into the web image at build
time and /api/extension/manifest reports it as its own key, next to
version — the popup banner, the toolbar tooltip and the Settings card all
name it.
Beside the version, never inside it. A `1.0.3499884-dev` suffix is the
obvious shortcut and it is the exact failure this design comes from:
versionIsNewer parses each dotted segment with parseInt, so a suffixed
segment reads as 0, every dev build compares equal to every other, and
"no update available" stops being distinguishable from "I cannot read this
version". The comparator already degrades rather than discarding (rule
150), which is a reason not to NEED the suffix, not a licence to add one.
Two tests hold the line — one backend, asserting version and channel are
separate keys; one frontend, asserting the rendered version text stays the
bare derived number.
Optional on the read side, and absent rather than defaulted. An image
built before this field says nothing by not having the key; an image built
without a channel now says nothing the same way, so there is one absence
to handle instead of a second spelling of "unknown". Every reader drops
the label entirely when it is missing and reads exactly as it did before.
Reported verbatim rather than validated against {dev, main}: if an image
declares something else, showing what it claims helps whoever is debugging
more than dropping it would.
FC_CHANNEL is declared LAST in the Dockerfile. An ARG invalidates every
layer below it, and this is the one value that differs between the dev and
main builds of identical source — earlier, and the two channels could
never share a cached pip install. A tag push counts as main: a vYY.MM.DD
tag is cut from main, so that image is a main-channel artifact wearing an
immutable name.
No channel switcher, deliberately. background.js:34 already records that
Firefox's static update_url cannot apply, because every FC instance is a
different host — so the extension asks its configured backend, and the
channel IS the instance it points at. Switching is repointing apiUrl and
reinstalling from that host. A separate setting would contradict each
server build shipping its own extension.
This commit touches packaged extension files, so it moves the derived
version and will sign a new one via AMO — the first push to exercise the
extension-changed path from dev end to end.
266 lines
10 KiB
JavaScript
266 lines
10 KiB
JavaScript
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' });
|
|
if (!cfg || !cfg.apiUrl || !cfg.apiKey) {
|
|
showSetupRequired();
|
|
return;
|
|
}
|
|
document.getElementById('setup-required').classList.add('hidden');
|
|
document.getElementById('main-content').classList.remove('hidden');
|
|
setupEventListeners();
|
|
showPlatformsLoading();
|
|
testConnectionIfNeeded();
|
|
checkForUpdate();
|
|
loadPlatformStatus().catch(e => showError(`Failed to load platforms: ${e.message}`));
|
|
} catch (e) {
|
|
showSetupRequired();
|
|
const alert = document.querySelector('#setup-required .alert');
|
|
alert.textContent = '';
|
|
const s = document.createElement('strong'); s.textContent = 'Error';
|
|
const p = document.createElement('p'); p.textContent = e.message;
|
|
alert.appendChild(s); alert.appendChild(p);
|
|
alert.classList.add('alert-error');
|
|
}
|
|
}
|
|
|
|
function showSetupRequired() {
|
|
document.getElementById('setup-required').classList.remove('hidden');
|
|
document.getElementById('main-content').classList.add('hidden');
|
|
document.getElementById('open-settings-btn').addEventListener('click', () => {
|
|
browser.runtime.openOptionsPage();
|
|
});
|
|
}
|
|
|
|
function showPlatformsLoading() {
|
|
const c = document.getElementById('platforms-list');
|
|
c.textContent = '';
|
|
c.appendChild(mutedNote('Loading platforms…'));
|
|
}
|
|
|
|
async function testConnectionIfNeeded() {
|
|
const stored = await browser.storage.local.get(['lastConnectionTest', 'lastConnectionStatus']);
|
|
const now = Date.now();
|
|
if (now - (stored.lastConnectionTest || 0) < CONNECTION_TEST_INTERVAL && stored.lastConnectionStatus !== undefined) {
|
|
updateConnectionDot(stored.lastConnectionStatus);
|
|
if (!stored.lastConnectionStatus) showError('Cannot connect to backend (cached).');
|
|
return;
|
|
}
|
|
const r = await browser.runtime.sendMessage({ type: 'TEST_CONNECTION' });
|
|
await browser.storage.local.set({ lastConnectionTest: now, lastConnectionStatus: r.connected });
|
|
updateConnectionDot(r.connected);
|
|
if (!r.connected) showError(`Cannot connect to backend: ${r.error}`);
|
|
}
|
|
|
|
function updateConnectionDot(connected) {
|
|
const d = document.getElementById('connection-status');
|
|
d.classList.toggle('connected', connected);
|
|
d.title = connected ? 'Connected to FabledCurator' : 'Disconnected';
|
|
}
|
|
|
|
// Nudge to reinstall when the configured instance publishes a newer signed XPI
|
|
// (the extension is self-hosted, so there's no Firefox auto-update). Never
|
|
// blocks the popup — a failed check just leaves the banner hidden.
|
|
async function checkForUpdate() {
|
|
try {
|
|
const r = await browser.runtime.sendMessage({ type: 'CHECK_UPDATE' });
|
|
if (r && r.updateAvailable && r.xpiUrl) showUpdateBanner(r);
|
|
} catch { /* non-fatal */ }
|
|
}
|
|
|
|
function showUpdateBanner(r) {
|
|
// The channel names itself beside the version, never inside it (#3113).
|
|
// Absent when the instance doesn't report one, and the banner then reads
|
|
// exactly as it did before the field existed.
|
|
const channel = r.channel ? ` (${r.channel})` : '';
|
|
document.getElementById('update-text').textContent =
|
|
`Update available${channel} — v${r.latestVersion} (installed v${r.currentVersion})`;
|
|
// Opening the signed XPI triggers Firefox's native install prompt.
|
|
document.getElementById('update-btn').addEventListener('click', () => {
|
|
browser.tabs.create({ url: r.xpiUrl });
|
|
});
|
|
document.getElementById('update-banner').classList.remove('hidden');
|
|
}
|
|
|
|
async function loadPlatformStatus() {
|
|
const status = await browser.runtime.sendMessage({ type: 'GET_PLATFORM_STATUS' });
|
|
const c = document.getElementById('platforms-list');
|
|
c.textContent = '';
|
|
for (const [key, platform] of Object.entries(PLATFORMS)) {
|
|
c.appendChild(createPlatformCard(key, platform, status[key] || {}));
|
|
}
|
|
}
|
|
|
|
function createPlatformCard(key, platform, status) {
|
|
const card = document.createElement('div');
|
|
card.className = 'platform-card';
|
|
card.dataset.platform = key;
|
|
|
|
const isTokenOnly = platform.authType === 'token' && !['discord', 'pixiv'].includes(key);
|
|
const discordNeedsToken = key === 'discord' && !status.hasToken;
|
|
if (isTokenOnly || discordNeedsToken) card.classList.add('disabled');
|
|
|
|
const icon = document.createElement('div');
|
|
icon.className = 'platform-icon';
|
|
icon.style.background = platform.color;
|
|
icon.textContent = platform.name[0];
|
|
|
|
const info = document.createElement('div');
|
|
info.className = 'info';
|
|
const name = document.createElement('div');
|
|
name.className = 'name';
|
|
name.textContent = platform.name;
|
|
const st = document.createElement('div');
|
|
st.className = `status ${statusClass(status, platform, key)}`;
|
|
st.textContent = statusText(status, platform, key);
|
|
info.appendChild(name); info.appendChild(st);
|
|
|
|
const act = document.createElement('span');
|
|
act.className = 'action-icon';
|
|
act.textContent = '↥';
|
|
|
|
card.appendChild(icon); card.appendChild(info); card.appendChild(act);
|
|
|
|
if (!isTokenOnly && !discordNeedsToken) {
|
|
card.addEventListener('click', () => exportPlatformCookies(key, card));
|
|
}
|
|
return card;
|
|
}
|
|
|
|
function statusText(s, platform, key) {
|
|
if (key === 'discord') return s.hasToken ? 'Token captured — ready' : 'Open Discord to capture token';
|
|
if (key === 'pixiv') return s.hasToken ? 'Token captured — ready' : 'Click to authenticate via OAuth';
|
|
if (platform.authType === 'token') return 'Manual token entry required';
|
|
if (s.error) return 'Error checking cookies';
|
|
if (!s.hasCookies || !s.cookieCount) return 'No cookies — log in first';
|
|
return `${s.cookieCount} cookies ready`;
|
|
}
|
|
function statusClass(s, platform, key) {
|
|
if (key === 'discord') return s.hasToken ? 'ready' : 'no-cookies';
|
|
if (key === 'pixiv') return s.hasToken ? 'ready' : 'no-cookies';
|
|
if (platform.authType === 'token') return 'no-cookies';
|
|
if (s.error) return 'error';
|
|
if (!s.hasCookies || !s.cookieCount) return 'no-cookies';
|
|
return 'ready';
|
|
}
|
|
|
|
async function exportPlatformCookies(key, card) {
|
|
card.classList.add('loading'); hideStatusMessage();
|
|
try {
|
|
const r = await browser.runtime.sendMessage({ type: 'EXPORT_COOKIES', platform: key });
|
|
if (r.error) showError(r.error);
|
|
else {
|
|
const n = r.cookieCount ?? null;
|
|
const verifiedSuffix = r.verified ? ' (verified ✓)' : '';
|
|
const msg = n !== null
|
|
? `${PLATFORMS[key].name}: ${n} cookies exported${verifiedSuffix}`
|
|
: `${PLATFORMS[key].name}: token exported`;
|
|
showSuccess(msg);
|
|
await loadPlatformStatus();
|
|
}
|
|
} catch (e) { showError(e.message); }
|
|
finally { card.classList.remove('loading'); }
|
|
}
|
|
|
|
async function exportAllCookies() {
|
|
const btn = document.getElementById('export-all-btn');
|
|
btn.disabled = true; btn.textContent = 'Exporting…'; hideStatusMessage();
|
|
try {
|
|
const r = await browser.runtime.sendMessage({ type: 'EXPORT_ALL_COOKIES' });
|
|
const wins = Object.values(r).filter(x => x.success).length;
|
|
const fails = Object.values(r).filter(x => !x.success && !x.skipped).length;
|
|
if (wins && !fails) showSuccess(`Exported ${wins} platforms`);
|
|
else if (wins) showWarning(`${wins} succeeded, ${fails} failed`);
|
|
else if (fails) showError('All exports failed. Are you logged in?');
|
|
else showWarning('Nothing to export');
|
|
await loadPlatformStatus();
|
|
} catch (e) { showError(e.message); }
|
|
finally { btn.disabled = false; btn.textContent = 'Export all platforms'; }
|
|
}
|
|
|
|
async function loadSources() {
|
|
const c = document.getElementById('sources-list');
|
|
c.textContent = '';
|
|
c.appendChild(mutedNote('Loading sources…'));
|
|
const r = await browser.runtime.sendMessage({ type: 'LIST_SOURCES' });
|
|
c.textContent = '';
|
|
if (r.error) {
|
|
const e = document.createElement('div');
|
|
e.style.cssText = 'padding:12px;color:var(--error);';
|
|
e.textContent = r.error;
|
|
c.appendChild(e);
|
|
return;
|
|
}
|
|
if (!r.sources || r.sources.length === 0) {
|
|
c.appendChild(mutedNote('No sources yet.'));
|
|
return;
|
|
}
|
|
for (const src of r.sources) c.appendChild(createSourceRow(src));
|
|
}
|
|
|
|
function createSourceRow(src) {
|
|
const row = document.createElement('div');
|
|
row.className = 'source-row';
|
|
const info = document.createElement('div');
|
|
info.className = 'info';
|
|
const name = document.createElement('div');
|
|
name.className = 'name';
|
|
name.textContent = `${src.platform} · #${src.id}`;
|
|
const url = document.createElement('div');
|
|
url.className = 'url';
|
|
url.textContent = src.url;
|
|
info.appendChild(name); info.appendChild(url);
|
|
const play = document.createElement('button');
|
|
play.className = 'play';
|
|
play.textContent = '▶';
|
|
play.title = 'Check now';
|
|
play.addEventListener('click', async () => {
|
|
play.disabled = true;
|
|
const r = await browser.runtime.sendMessage({ type: 'CHECK_SOURCE', sourceId: src.id });
|
|
play.disabled = false;
|
|
if (r.error) showError(r.error);
|
|
else showSuccess(`Triggered check for source #${src.id}`);
|
|
});
|
|
row.appendChild(info); row.appendChild(play);
|
|
return row;
|
|
}
|
|
|
|
function setupEventListeners() {
|
|
document.getElementById('export-all-btn').addEventListener('click', exportAllCookies);
|
|
document.getElementById('settings-btn').addEventListener('click', () => browser.runtime.openOptionsPage());
|
|
for (const tab of document.querySelectorAll('.tab')) {
|
|
tab.addEventListener('click', () => {
|
|
for (const t of document.querySelectorAll('.tab')) t.classList.remove('active');
|
|
tab.classList.add('active');
|
|
for (const p of document.querySelectorAll('.tab-panel')) p.classList.add('hidden');
|
|
document.getElementById(`tab-${tab.dataset.tab}`).classList.remove('hidden');
|
|
if (tab.dataset.tab === 'sources') loadSources();
|
|
});
|
|
}
|
|
}
|
|
|
|
function showSuccess(m) { showStatusMessage(m, 'success'); }
|
|
function showError(m) { showStatusMessage(m, 'error'); }
|
|
function showWarning(m) { showStatusMessage(m, 'warning'); }
|
|
function showStatusMessage(text, kind) {
|
|
const el = document.getElementById('status-message');
|
|
el.textContent = text;
|
|
el.className = `status-message ${kind}`;
|
|
el.classList.remove('hidden');
|
|
}
|
|
function hideStatusMessage() {
|
|
document.getElementById('status-message').classList.add('hidden');
|
|
}
|