feat: auto sync for cloud and fix some firefox weirdness

This commit is contained in:
2026-04-08 08:29:25 +09:30
parent 71b7c9eb64
commit ea4a2c1ff0
10 changed files with 555 additions and 53 deletions
+13 -2
View File
@@ -113,10 +113,21 @@ The backup is a flat JSON map of **`chrome.storage.local`** keys. It does **not*
- **OAuth / session keys** — `bsplus_token`, `bsplus_refresh_token`, `bsplus_client_id`, `bsplus_user`, plus legacy `cloudAccessToken` / `cloudUsername`.
- **Assessment Averages caches** — `plugin.assessments-average.storage.assessments`, `plugin.assessments-average.storage.weightings` (school assessment data).
- **Keys under** `plugin.global-search.storage.*` — reserved so any future plugin storage cache there is not synced.
- **`bsplus_cloud_settings_known_remote_updated_at`** — client-only watermark for auto-sync (not part of the cloud backup blob).
On restore, those keys are **not** taken from the server; the device keeps its current local values.
## Related endpoint: `GET /api/user/cloud-summary`
The extension may call **`GET /api/user/cloud-summary`** (same host, `Authorization: Bearer`) for a **small** JSON summary (e.g. whether DesQTA / BetterSEQTA+ cloud settings exist and **`bsplus.updated_at`** / **`schemaVersion`**). It does **not** return the large settings `data` blob.
- **Auto-sync flow:** compare `bsplus.updated_at` to a **client-only** watermark stored in extension storage as **`bsplus_cloud_settings_known_remote_updated_at`** (never uploaded, never applied from the server payload; preserved on restore).
- If the server timestamp is newer (and `schemaVersion` is not ahead of the client), the client then calls **`GET /api/bsplus/settings/sync`** and applies the full envelope as usual.
This uses standard **WebExtension** APIs (`browser.alarms`, `runtime` messages, `storage`) and works on **Chromium and Firefox** builds (see `webextension-polyfill`).
## Client reference (extension)
- Upload / dev export: `buildUploadPayload` / `getSnapshotForUpload` in `src/seqta/utils/cloudSettingsSync.ts` (strips OAuth-related keys and sensitive device keys above).
- Download: `applyDownloadedEnvelope` after `GET`; local auth keys and sensitive device keys are merged back after `chrome.storage.local.clear()`.
- Upload / dev export: `buildUploadPayload` / `getSnapshotForUpload` in `src/seqta/utils/cloudSettingsSync.ts` (strips OAuth-related keys, sensitive device keys, and **`bsplus_cloud_settings_known_remote_updated_at`**).
- Download: `applyDownloadedEnvelope` after `GET`; local auth keys, sensitive device keys, and the client-only watermark key are merged back after `chrome.storage.local.clear()`.
- Auto sync (summary, debounced upload, alarms): `src/background/cloudSettingsAutoSync.ts`; content script triggers a poll on each verified SEQTA Learn/Engage page load (top frame) via `cloudSettingsPoll`.
+43
View File
@@ -0,0 +1,43 @@
import type { Plugin } from "vite";
/**
* Firefox extension pages forbid eval / `Function` constructor. Some deps still emit:
* - `Function(\`return this\`)()` (lodash-style global)
* - `try { return Function(\`\`) / new Function("") … }` (feature probes, e.g. PDF.js / ORT)
*/
export function firefoxStripFunctionProbe(): Plugin {
return {
name: "firefox-strip-function-probe",
apply: "build",
enforce: "post",
generateBundle(_options, bundle) {
if ((process.env.MODE || "chrome").toLowerCase() !== "firefox") return;
const literalReplacements: [string, string][] = [
['try{return new Function(""),!0}catch{return!1}', "return!1"],
["try{return new Function(''),!0}catch{return!1}", "return!1"],
['try{return new Function(""),true}catch{return false}', "return false"],
["try{return new Function(''),true}catch{return false}", "return false"],
// Empty template literal probe (minifier output)
["try{return Function(``),!0}catch{return!1}", "return!1"],
];
for (const chunk of Object.values(bundle)) {
if (chunk.type !== "chunk" || typeof chunk.code !== "string") continue;
let { code } = chunk;
code = code.replace(/Function\(`return this`\)\(\)/g, "(globalThis)");
code = code.replace(/Function\("return this"\)\(\)/g, "(globalThis)");
code = code.replace(/Function\('return this'\)\(\)/g, "(globalThis)");
for (const [from, to] of literalReplacements) {
if (code.includes(from)) {
code = code.split(from).join(to);
}
}
chunk.code = code;
}
},
};
}
+4
View File
@@ -59,6 +59,10 @@ async function init() {
IsSEQTAPage = true;
console.info("[BetterSEQTA+] Verified SEQTA Page");
if (typeof window !== "undefined" && window === window.top) {
void browser.runtime.sendMessage({ type: "cloudSettingsPoll" }).catch(() => {});
}
registerFetchSeqtaAppLinkListener();
const documentLoadStyle = document.createElement("style");
+23 -47
View File
@@ -2,12 +2,11 @@ import browser from "webextension-polyfill";
import type { SettingsState } from "@/types/storage";
import { fetchNews } from "./background/news";
import {
applyDownloadedEnvelope,
buildUploadPayload,
} from "@/seqta/utils/cloudSettingsSync";
const CLOUD_SETTINGS_SYNC_URL =
"https://accounts.betterseqta.org/api/bsplus/settings/sync";
initCloudSettingsAutoSync,
performCloudSettingsDownloadWithRetry,
performCloudSettingsUploadWithRetry,
runCloudSettingsPoll,
} from "./background/cloudSettingsAutoSync";
function reloadSeqtaPages() {
const result = browser.tabs.query({});
@@ -165,25 +164,12 @@ function handleCloudSettingsUpload(request: any, sendResponse: MessageSender): b
sendResponse({ success: false, error: "Not authenticated" });
return;
}
const all = await browser.storage.local.get();
const payload = buildUploadPayload(all as Record<string, unknown>);
const r = await fetch(CLOUD_SETTINGS_SYNC_URL, {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
const res = await performCloudSettingsUploadWithRetry(token);
sendResponse({
success: res.success,
error: res.error,
updated_at: res.updated_at,
});
const data = await parseJsonResponse(r);
if (!r.ok) {
sendResponse({
success: false,
error: data?.error ?? `Upload failed (${r.status})`,
});
return;
}
sendResponse({ success: true, updated_at: data?.updated_at });
} catch (err) {
console.error("[Background] cloudSettingsUpload error:", err);
sendResponse({
@@ -203,30 +189,13 @@ function handleCloudSettingsDownload(request: any, sendResponse: MessageSender):
sendResponse({ success: false, error: "Not authenticated" });
return;
}
const r = await fetch(CLOUD_SETTINGS_SYNC_URL, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
const res = await performCloudSettingsDownloadWithRetry(token);
sendResponse({
success: res.success,
notFound: res.notFound,
error: res.error,
updated_at: res.updated_at,
});
const data = await parseJsonResponse(r);
if (r.status === 404) {
sendResponse({
success: false,
notFound: true,
error: "No settings backup found in the cloud",
});
return;
}
if (!r.ok) {
sendResponse({
success: false,
error: data?.error ?? `Download failed (${r.status})`,
});
return;
}
await applyDownloadedEnvelope(data);
reloadSeqtaPages();
sendResponse({ success: true, updated_at: data?.updated_at });
} catch (err) {
console.error("[Background] cloudSettingsDownload error:", err);
sendResponse({
@@ -304,6 +273,10 @@ const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
cloudFavorite: handleCloudFavorite,
cloudSettingsUpload: handleCloudSettingsUpload,
cloudSettingsDownload: handleCloudSettingsDownload,
cloudSettingsPoll: () => {
void runCloudSettingsPoll();
return false;
},
getSeqtaSession: (req: { baseUrl?: string }, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender) => {
(async () => {
try {
@@ -422,6 +395,7 @@ function getDefaultValues(): SettingsState {
adaptiveThemeColour: false,
adaptiveThemeGradient: false,
adaptiveThemeColourTransition: true,
autoCloudSettingsSync: true,
};
}
@@ -439,3 +413,5 @@ browser.runtime.onInstalled.addListener(function (event) {
browser.storage.local.set({ justupdated: true });
}
});
initCloudSettingsAutoSync({ reloadSeqtaPages });
+406
View File
@@ -0,0 +1,406 @@
import browser from "webextension-polyfill";
import {
applyDownloadedEnvelope,
buildUploadPayload,
BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY,
CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
isKeyIncludedInCloudUploadPayload,
setKnownRemoteUpdatedAt,
} from "@/seqta/utils/cloudSettingsSync";
const ACCOUNTS_BASE = "https://accounts.betterseqta.org";
export const CLOUD_SUMMARY_URL = `${ACCOUNTS_BASE}/api/user/cloud-summary`;
const CLOUD_SETTINGS_SYNC_URL = `${ACCOUNTS_BASE}/api/bsplus/settings/sync`;
const REFRESH_URL = `${ACCOUNTS_BASE}/api/bsplus/refresh`;
const ALARM_NAME = "bsplus_cloud_settings_auto_sync";
const PERIOD_MINUTES = 60;
const UPLOAD_DEBOUNCE_MS = 2000;
type CloudSummaryResponse = {
desqta?: unknown;
bsplus?: { updated_at: string; schemaVersion: number } | null;
};
let reloadSeqtaPagesFn: (() => void) | null = null;
let suppressAutoUploadDuringRestore = false;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let pollInFlight: Promise<void> | null = null;
function isAutoCloudSyncEnabled(all: Record<string, unknown>): boolean {
return all.autoCloudSettingsSync !== false;
}
async function parseJsonResponse(r: Response): Promise<any> {
const text = await r.text();
try {
return text ? JSON.parse(text) : {};
} catch {
return {};
}
}
async function getAccessToken(): Promise<string | null> {
const { bsplus_token } = await browser.storage.local.get("bsplus_token");
return typeof bsplus_token === "string" && bsplus_token.length > 0 ? bsplus_token : null;
}
async function tryRefreshTokens(): Promise<boolean> {
const result = await browser.storage.local.get([
"bsplus_refresh_token",
"bsplus_client_id",
"bsplus_user",
]);
const refresh_token = result.bsplus_refresh_token as string | undefined;
const client_id = result.bsplus_client_id as string | undefined;
if (!refresh_token || !client_id) return false;
try {
const r = await fetch(REFRESH_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token, client_id }),
});
const data = await parseJsonResponse(r);
if (!r.ok || !data.access_token || !data.refresh_token) return false;
await browser.storage.local.set({
bsplus_token: data.access_token,
bsplus_refresh_token: data.refresh_token,
bsplus_user: data.user ?? result.bsplus_user,
});
return true;
} catch {
return false;
}
}
function isServerTimestampNewer(serverIso: string, localIso: string | undefined): boolean {
const a = Date.parse(serverIso);
if (Number.isNaN(a)) return false;
if (localIso === undefined || localIso === "") return true;
const b = Date.parse(localIso);
if (Number.isNaN(b)) return true;
return a > b;
}
async function fetchCloudSummaryOnce(
token: string,
): Promise<
| { ok: true; data: CloudSummaryResponse }
| { ok: false; unauthorized: boolean; error?: string }
> {
try {
const r = await fetch(CLOUD_SUMMARY_URL, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
const data = (await parseJsonResponse(r)) as CloudSummaryResponse;
if (r.status === 401) return { ok: false, unauthorized: true };
if (!r.ok) {
return {
ok: false,
unauthorized: false,
error: (data as { error?: string })?.error ?? `Summary failed (${r.status})`,
};
}
return { ok: true, data };
} catch (e) {
return {
ok: false,
unauthorized: false,
error: e instanceof Error ? e.message : "Network error",
};
}
}
async function fetchCloudSummaryWithAuthRetry(
token: string,
): Promise<CloudSummaryResponse | null> {
let t = token;
for (let attempt = 0; attempt < 2; attempt++) {
const res = await fetchCloudSummaryOnce(t);
if (res.ok) return res.data;
if (res.unauthorized && attempt === 0) {
const refreshed = await tryRefreshTokens();
if (!refreshed) break;
const next = await getAccessToken();
if (!next) break;
t = next;
continue;
}
if (res.error) console.warn("[BS+ cloud sync] cloud-summary:", res.error);
break;
}
return null;
}
type PutResult =
| { ok: true; updated_at?: string }
| { ok: false; unauthorized: boolean; error?: string };
async function putSettingsOnce(token: string): Promise<PutResult> {
try {
const all = await browser.storage.local.get();
const payload = buildUploadPayload(all as Record<string, unknown>);
const r = await fetch(CLOUD_SETTINGS_SYNC_URL, {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const data = await parseJsonResponse(r);
if (r.status === 401) return { ok: false, unauthorized: true };
if (!r.ok) {
return {
ok: false,
unauthorized: false,
error: data?.error ?? `Upload failed (${r.status})`,
};
}
const updated_at = data?.updated_at as string | undefined;
await setKnownRemoteUpdatedAt(updated_at);
return { ok: true, updated_at };
} catch (e) {
return {
ok: false,
unauthorized: false,
error: e instanceof Error ? e.message : "Upload failed",
};
}
}
export async function performCloudSettingsUploadWithRetry(
token: string,
): Promise<{ success: boolean; error?: string; updated_at?: string }> {
let t = token;
for (let attempt = 0; attempt < 2; attempt++) {
const res = await putSettingsOnce(t);
if (res.ok) return { success: true, updated_at: res.updated_at };
if (res.unauthorized && attempt === 0) {
const refreshed = await tryRefreshTokens();
if (!refreshed) return { success: false, error: "Not authenticated" };
const next = await getAccessToken();
if (!next) return { success: false, error: "Not authenticated" };
t = next;
continue;
}
return { success: false, error: res.error ?? "Upload failed" };
}
return { success: false, error: "Upload failed" };
}
type GetResult =
| { ok: true; updated_at?: string }
| { ok: false; notFound?: boolean; unauthorized: boolean; error?: string };
async function getSettingsAndApplyOnce(token: string): Promise<GetResult> {
try {
const r = await fetch(CLOUD_SETTINGS_SYNC_URL, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
const data = await parseJsonResponse(r);
if (r.status === 401) return { ok: false, unauthorized: true };
if (r.status === 404) {
return {
ok: false,
notFound: true,
unauthorized: false,
error: "No settings backup found in the cloud",
};
}
if (!r.ok) {
return {
ok: false,
unauthorized: false,
error: data?.error ?? `Download failed (${r.status})`,
};
}
await applyDownloadedEnvelope(data);
reloadSeqtaPagesFn?.();
const updated_at = data?.updated_at as string | undefined;
await setKnownRemoteUpdatedAt(updated_at);
return { ok: true, updated_at };
} catch (e) {
return {
ok: false,
unauthorized: false,
error: e instanceof Error ? e.message : "Download failed",
};
}
}
export async function performCloudSettingsDownloadWithRetry(
token: string,
): Promise<{ success: boolean; notFound?: boolean; error?: string; updated_at?: string }> {
suppressAutoUploadDuringRestore = true;
try {
let t = token;
for (let attempt = 0; attempt < 2; attempt++) {
const res = await getSettingsAndApplyOnce(t);
if (res.ok) return { success: true, updated_at: res.updated_at };
if (res.unauthorized && attempt === 0) {
const refreshed = await tryRefreshTokens();
if (!refreshed) return { success: false, error: "Not authenticated" };
const next = await getAccessToken();
if (!next) return { success: false, error: "Not authenticated" };
t = next;
continue;
}
return {
success: false,
notFound: res.notFound,
error: res.error ?? "Download failed",
};
}
return { success: false, error: "Download failed" };
} finally {
suppressAutoUploadDuringRestore = false;
}
}
async function maybeUploadBaseline(token: string): Promise<void> {
const res = await performCloudSettingsUploadWithRetry(token);
if (!res.success) {
console.warn("[BS+ cloud sync] Baseline upload failed:", res.error);
}
}
async function downloadIfNeeded(token: string): Promise<void> {
const res = await performCloudSettingsDownloadWithRetry(token);
if (!res.success && !res.notFound) {
console.warn("[BS+ cloud sync] Auto-download failed:", res.error);
}
}
async function runCloudSettingsPollInner(): Promise<void> {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) return;
let token = await getAccessToken();
if (!token) return;
const summary = await fetchCloudSummaryWithAuthRetry(token);
if (!summary) return;
const bsplus = summary.bsplus;
const watermark = all[BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY] as string | undefined;
if (
bsplus &&
typeof bsplus.schemaVersion === "number" &&
bsplus.schemaVersion > CLOUD_SETTINGS_SYNC_SCHEMA_VERSION
) {
console.warn(
"[BS+ cloud sync] Server schemaVersion newer than client; skip auto-download",
);
return;
}
token = (await getAccessToken()) ?? token;
if (!watermark) {
if (!bsplus?.updated_at) {
await maybeUploadBaseline(token);
return;
}
await downloadIfNeeded(token);
return;
}
if (!bsplus?.updated_at) return;
if (isServerTimestampNewer(bsplus.updated_at, watermark)) {
await downloadIfNeeded(token);
}
}
export function runCloudSettingsPoll(): Promise<void> {
if (pollInFlight) return pollInFlight;
pollInFlight = (async () => {
try {
await runCloudSettingsPollInner();
} catch (e) {
console.error("[BS+ cloud sync] Poll error:", e);
} finally {
pollInFlight = null;
}
})();
return pollInFlight;
}
function clearUploadDebounce(): void {
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
}
function scheduleDebouncedUpload(): void {
if (suppressAutoUploadDuringRestore) return;
clearUploadDebounce();
debounceTimer = setTimeout(() => {
debounceTimer = null;
void runDebouncedUploadJob();
}, UPLOAD_DEBOUNCE_MS);
}
async function runDebouncedUploadJob(): Promise<void> {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) return;
const token = await getAccessToken();
if (!token) return;
const res = await performCloudSettingsUploadWithRetry(token);
if (!res.success) {
console.warn("[BS+ cloud sync] Auto-upload failed:", res.error);
}
}
async function syncAlarmWithStorage(): Promise<void> {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) {
await browser.alarms.clear(ALARM_NAME);
clearUploadDebounce();
return;
}
await browser.alarms.create(ALARM_NAME, { periodInMinutes: PERIOD_MINUTES });
}
function onStorageChanged(
changes: Record<string, browser.storage.StorageChange>,
area: string,
): void {
if (area !== "local") return;
if (Object.prototype.hasOwnProperty.call(changes, "autoCloudSettingsSync")) {
void syncAlarmWithStorage();
}
const keys = Object.keys(changes);
if (!keys.some((k) => isKeyIncludedInCloudUploadPayload(k))) return;
void (async () => {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) return;
if (suppressAutoUploadDuringRestore) return;
if (!(await getAccessToken())) return;
scheduleDebouncedUpload();
})();
}
function onAlarm(alarm: browser.Alarms.Alarm): void {
if (alarm.name !== ALARM_NAME) return;
void runCloudSettingsPoll();
}
export function initCloudSettingsAutoSync(deps: { reloadSeqtaPages: () => void }): void {
reloadSeqtaPagesFn = deps.reloadSeqtaPages;
browser.alarms.onAlarm.addListener(onAlarm);
browser.storage.onChanged.addListener(onStorageChanged);
void syncAlarmWithStorage();
}
@@ -1,8 +1,10 @@
<script lang="ts">
import browser from "webextension-polyfill";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import DisclaimerModal from "./DisclaimerModal.svelte";
import Button from "./Button.svelte";
import Switch from "./Switch.svelte";
let cloudState = $state(cloudAuth.state);
let busy = $state(false);
@@ -89,6 +91,37 @@
cloud copy (your sign-in stays on this device).
</p>
<div
class="mt-2 flex flex-col gap-2 rounded-lg border border-zinc-200/50 bg-white/60 px-3 py-2.5 dark:border-zinc-600/40 dark:bg-zinc-800/40"
>
<div class="flex items-start justify-between gap-3">
<p class="min-w-0 flex-1 pt-0.5 text-[11px] font-semibold leading-tight text-zinc-800 dark:text-zinc-100">
Automatic sync
</p>
<div class="shrink-0">
<Switch
state={$settingsState.autoCloudSettingsSync !== false}
onChange={(isOn: boolean) => (settingsState.autoCloudSettingsSync = isOn)}
/>
</div>
</div>
<p class="text-[10px] leading-snug text-zinc-500 dark:text-zinc-400">
When signed in, each time SEQTA loads and also hourly, if the cloud backup is newer it will replace local
settings. Settings you change will upload shortly after you adjust them.
</p>
<p class="text-[10px] leading-snug text-zinc-500 dark:text-zinc-400">
Passwords, tokens, and other sensitive data are not included in the backup.
<a
href="https://betterseqta.org/privacy"
target="_blank"
rel="noopener noreferrer"
class="ml-0.5 inline font-medium text-emerald-600 underline decoration-emerald-600/50 underline-offset-2 transition-all duration-200 hover:text-emerald-700 hover:decoration-emerald-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-emerald-400 dark:decoration-emerald-400/50 dark:hover:text-emerald-300 dark:focus-visible:ring-offset-zinc-800 rounded-sm"
>
Privacy policy
</a>
</p>
</div>
<div class="mt-2 flex flex-wrap gap-2">
<Button
text={busy ? "Please wait…" : "Upload to cloud"}
+1 -1
View File
@@ -15,7 +15,7 @@
"64": "resources/icons/icon-64.png"
}
},
"permissions": ["tabs", "notifications", "storage"],
"permissions": ["tabs", "notifications", "storage", "alarms"],
"host_permissions": ["https://newsapi.org/", "https://betterseqta.org/", "https://accounts.betterseqta.org/", "*://*/*"],
"background": {
"service_worker": "background.ts"
+24 -1
View File
@@ -3,6 +3,13 @@ import browser from "webextension-polyfill";
/** Matches the contract in docs/CLOUD_SETTINGS_SYNC_SERVER.md */
export const CLOUD_SETTINGS_SYNC_SCHEMA_VERSION = 1;
/**
* Client-only: last known remote `updated_at` for BS+ settings (from summary or sync responses).
* Never uploaded; preserved on restore; used to decide when to pull a newer cloud backup.
*/
export const BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY =
"bsplus_cloud_settings_known_remote_updated_at";
/**
* Never uploaded to the cloud backup (OAuth and legacy keys).
* IndexedDB (e.g. Global Searchs `betterseqta-index` database) is not part of
@@ -29,6 +36,8 @@ export const SENSITIVE_DEVICE_STORAGE_KEYS_EXACT = [
/** e.g. any future `plugin.global-search.storage.*` keys in chrome.storage */
export const SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES = ["plugin.global-search.storage."] as const;
const CLIENT_ONLY_CLOUD_KEYS_EXACT = [BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY] as const;
/** After restoring from cloud, keep local session so the user stays signed in. */
const AUTH_KEYS_TO_PRESERVE = [
"bsplus_token",
@@ -40,8 +49,14 @@ const AUTH_KEYS_TO_PRESERVE = [
const OMIT_FROM_UPLOAD_EXACT = new Set<string>([
...KEYS_OMITTED_FROM_CLOUD_UPLOAD,
...SENSITIVE_DEVICE_STORAGE_KEYS_EXACT,
...CLIENT_ONLY_CLOUD_KEYS_EXACT,
]);
/** True if a storage key is part of the upload payload (and should trigger auto-upload when changed). */
export function isKeyIncludedInCloudUploadPayload(key: string): boolean {
return !shouldOmitKeyFromCloudPayload(key);
}
function shouldOmitKeyFromCloudPayload(key: string): boolean {
if (OMIT_FROM_UPLOAD_EXACT.has(key)) return true;
for (const prefix of SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES) {
@@ -58,12 +73,15 @@ function isSensitiveDeviceKey(key: string): boolean {
return false;
}
/** Auth + device-only caches to keep when merging a downloaded snapshot. */
/** Auth + device-only caches + client-only cloud metadata to keep when merging a downloaded snapshot. */
function collectLocalKeysToPreserve(local: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const k of AUTH_KEYS_TO_PRESERVE) {
if (local[k] !== undefined) out[k] = local[k];
}
for (const k of CLIENT_ONLY_CLOUD_KEYS_EXACT) {
if (local[k] !== undefined) out[k] = local[k];
}
for (const [k, v] of Object.entries(local)) {
if (isSensitiveDeviceKey(k)) out[k] = v;
}
@@ -100,6 +118,11 @@ export async function getSnapshotForUpload(): Promise<{
return buildUploadPayload(all as Record<string, unknown>);
}
export async function setKnownRemoteUpdatedAt(iso: string | undefined): Promise<void> {
if (!iso || typeof iso !== "string") return;
await browser.storage.local.set({ [BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY]: iso });
}
/**
* Replace local extension storage with the downloaded snapshot, except auth keys
* and device-only sensitive caches, which are preserved from the current device.
+2
View File
@@ -57,6 +57,8 @@ export interface SettingsState {
bsplus_token?: string;
bsplus_refresh_token?: string;
bsplus_user?: { id: string; email?: string; username?: string; displayName?: string; pfpUrl?: string; admin_level?: number };
/** When not `false`, automatic cloud settings sync is enabled (default-on). */
autoCloudSettingsSync?: boolean;
}
interface ToggleItem {
+6 -2
View File
@@ -6,6 +6,7 @@ import InlineWorkerPlugin from "./lib/inlineWorker";
import { base64Loader } from "./lib/base64loader";
import type { BuildTarget } from "./lib/types";
import ClosePlugin from "./lib/closePlugin";
import { firefoxStripFunctionProbe } from "./lib/firefoxStripFunctionProbe";
import million from "million/compiler";
@@ -23,6 +24,9 @@ const targets: BuildTarget[] = [chrome, brave, edge, firefox, opera, safari];
const mode = process.env.MODE || "chrome"; // Check the environment variable to determine which build type to use.
//const sourcemap = (process.env.SOURCEMAP === "true") || false; // Check whether we want sourcemaps.
/** Million's compiler can emit `new Function()`, which Firefox extension pages block (strict CSP, no unsafe-eval). */
const useMillion = mode.toLowerCase() !== "firefox";
export default defineConfig(({ command }) => ({
plugins: [
base64Loader,
@@ -30,7 +34,7 @@ export default defineConfig(({ command }) => ({
svelte({
emitCss: false,
}),
million.vite({ auto: true }),
...(useMillion ? [million.vite({ auto: true })] : []),
crx({
manifest:
targets.find((t) => t.browser === mode.toLowerCase())?.manifest ??
@@ -38,7 +42,7 @@ export default defineConfig(({ command }) => ({
browser: mode.toLowerCase() === "firefox" ? "firefox" : "chrome",
}),
touchGlobalCSSPlugin(),
...(command === "build" ? [ClosePlugin()] : []),
...(command === "build" ? [ClosePlugin(), firefoxStripFunctionProbe()] : []),
],
root: resolve(__dirname, "./src"),
resolve: {