add aden's requested changes

This commit is contained in:
2026-06-10 11:00:09 +09:30
parent ec94376e1f
commit 57a1965a6d
13 changed files with 403 additions and 44 deletions
+44 -6
View File
@@ -1,12 +1,19 @@
import browser from "webextension-polyfill";
import {
ensureSyncableStorageDefaults,
getSyncableStorageDefaults,
} from "@/seqta/utils/ensureSyncableStorageDefaults";
import {
applyDownloadedEnvelope,
buildUploadPayload,
BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY,
BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY,
buildUploadPatch,
CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
isKeyIncludedInCloudUploadPayload,
normalizeStorageForSync,
resolveThemeIdForPostSyncDownload,
saveLastUploadedSnapshot,
getLastUploadedSnapshot,
setKnownRemoteUpdatedAt,
} from "@/seqta/utils/cloudSettingsSync";
@@ -138,13 +145,38 @@ async function fetchCloudSummaryWithAuthRetry(
}
type PutResult =
| { ok: true; updated_at?: string }
| { ok: true; updated_at?: string; skipped?: boolean }
| { ok: false; unauthorized: boolean; error?: string };
async function resolveUploadBaseline(
normalized: Record<string, unknown>,
watermark: string | undefined,
): Promise<Record<string, unknown>> {
const lastSnapshot = await getLastUploadedSnapshot();
if (lastSnapshot) return lastSnapshot;
if (watermark) {
await saveLastUploadedSnapshot(normalized);
return normalized;
}
return getSyncableStorageDefaults();
}
async function putSettingsOnce(token: string): Promise<PutResult> {
try {
const all = await browser.storage.local.get();
const payload = buildUploadPayload(all as Record<string, unknown>);
await ensureSyncableStorageDefaults();
const all = (await browser.storage.local.get()) as Record<string, unknown>;
const normalized = normalizeStorageForSync(all);
const watermark = all[BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY] as string | undefined;
const baseline = await resolveUploadBaseline(normalized, watermark);
const payload = buildUploadPatch(all, baseline);
if (!payload) {
return { ok: true, skipped: true };
}
const r = await fetch(CLOUD_SETTINGS_SYNC_URL, {
method: "PUT",
headers: {
@@ -163,6 +195,7 @@ async function putSettingsOnce(token: string): Promise<PutResult> {
};
}
const updated_at = data?.updated_at as string | undefined;
await saveLastUploadedSnapshot(normalized);
await setKnownRemoteUpdatedAt(updated_at);
return { ok: true, updated_at };
} catch (e) {
@@ -176,11 +209,13 @@ async function putSettingsOnce(token: string): Promise<PutResult> {
export async function performCloudSettingsUploadWithRetry(
token: string,
): Promise<{ success: boolean; error?: string; updated_at?: string }> {
): Promise<{ success: boolean; error?: string; updated_at?: string; skipped?: boolean }> {
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.ok) {
return { success: true, updated_at: res.updated_at, skipped: res.skipped };
}
if (res.unauthorized && attempt === 0) {
const refreshed = await tryRefreshTokens();
if (!refreshed) return { success: false, error: "Not authenticated" };
@@ -234,6 +269,9 @@ async function getSettingsAndApplyOnce(token: string): Promise<GetResult> {
reloadSeqtaPagesFn?.();
const updated_at = data?.updated_at as string | undefined;
await setKnownRemoteUpdatedAt(updated_at);
await saveLastUploadedSnapshot(
normalizeStorageForSync((await browser.storage.local.get()) as Record<string, unknown>),
);
return { ok: true, updated_at };
} catch (e) {
return {
+2
View File
@@ -1,5 +1,6 @@
import browser from "webextension-polyfill";
import { clearCloudPfpCache } from "@/seqta/utils/cloudPfpCache";
import { clearLastUploadedSnapshot } from "@/seqta/utils/cloudSettingsSync";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
const REDIRECT_URI = "https://accounts.betterseqta.org/auth/bsplus/callback";
@@ -205,6 +206,7 @@ class CloudAuthService {
public async logout(): Promise<void> {
const userId = this._state.user?.id;
if (userId) await clearCloudPfpCache(userId);
await clearLastUploadedSnapshot();
await browser.storage.local.remove([
STORAGE_KEYS.accessToken,
STORAGE_KEYS.refreshToken,
@@ -0,0 +1,75 @@
import {
isKeyIncludedInCloudUploadPayload,
migrateLegacyToPluginSettings,
normalizeThemeIdForSync,
resolveThemeIdForPostSyncDownload,
} from "./cloudSettingsSync";
describe("migrateLegacyToPluginSettings", () => {
it("maps animatedbk without overwriting existing plugin fields", () => {
const result = migrateLegacyToPluginSettings({
animatedbk: true,
"plugin.animated-background.settings": { speed: 1.5 },
});
expect(result["plugin.animated-background.settings"]).toEqual({
speed: 1.5,
enabled: true,
});
expect(result).not.toHaveProperty("animatedbk");
});
it("does not set enabled when legacy key is absent", () => {
const result = migrateLegacyToPluginSettings({
"plugin.animated-background.settings": { speed: 1.0 },
});
expect(result["plugin.animated-background.settings"]).toEqual({ speed: 1.0 });
});
});
describe("isKeyIncludedInCloudUploadPayload", () => {
it("excludes auth and device cache prefixes", () => {
expect(isKeyIncludedInCloudUploadPayload("bsplus_token")).toBe(false);
expect(isKeyIncludedInCloudUploadPayload("plugin.global-search.storage.index")).toBe(
false,
);
expect(isKeyIncludedInCloudUploadPayload("bsplus.analytics.v2.school.1")).toBe(false);
});
it("includes core and plugin settings keys", () => {
expect(isKeyIncludedInCloudUploadPayload("DarkMode")).toBe(true);
expect(isKeyIncludedInCloudUploadPayload("plugin.profile-picture.settings")).toBe(true);
});
});
describe("resolveThemeIdForPostSyncDownload", () => {
it("prefers top-level themeId over data.selectedTheme", () => {
expect(
resolveThemeIdForPostSyncDownload({
themeId: " top-id ",
data: { selectedTheme: "other-id" },
}),
).toBe("top-id");
});
it("falls back to data.selectedTheme", () => {
expect(
resolveThemeIdForPostSyncDownload({
data: { selectedTheme: "from-data" },
}),
).toBe("from-data");
});
it("returns undefined when theme id is empty", () => {
expect(
resolveThemeIdForPostSyncDownload({
data: { selectedTheme: " " },
}),
).toBeUndefined();
});
});
describe("normalizeThemeIdForSync", () => {
it("normalizes envelope theme ids consistently", () => {
expect(normalizeThemeIdForSync(" uuid ")).toBe("uuid");
});
});
@@ -0,0 +1,88 @@
import {
buildUploadPatch,
CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
diffSyncableStorage,
normalizeStorageForSync,
normalizeThemeIdForSync,
} from "./cloudSettingsSync";
describe("normalizeStorageForSync", () => {
it("strips omitted auth and client-only keys", () => {
const normalized = normalizeStorageForSync({
DarkMode: true,
bsplus_token: "secret",
bsplus_cloud_settings_known_remote_updated_at: "2026-01-01T00:00:00.000Z",
"bsplus.analytics.v2.school.1": { cached: true },
});
expect(normalized).toEqual({ DarkMode: true });
});
it("migrates legacy animatedbk to plugin settings", () => {
const normalized = normalizeStorageForSync({ animatedbk: true });
expect(normalized).toEqual({
"plugin.animated-background.settings": { enabled: true },
});
});
});
describe("diffSyncableStorage", () => {
it("returns empty object when maps are identical", () => {
const map = { DarkMode: true, onoff: true };
expect(diffSyncableStorage(map, { ...map })).toEqual({});
});
it("includes only changed scalar keys", () => {
const current = { DarkMode: false, onoff: true };
const baseline = { DarkMode: true, onoff: true };
expect(diffSyncableStorage(current, baseline)).toEqual({ DarkMode: false });
});
it("includes whole plugin object when nested value changes", () => {
const current = {
"plugin.global-search.settings": { enabled: true, searchHotkey: "ctrl+k" },
};
const baseline = {
"plugin.global-search.settings": { enabled: false, searchHotkey: "ctrl+k" },
};
expect(diffSyncableStorage(current, baseline)).toEqual({
"plugin.global-search.settings": { enabled: true, searchHotkey: "ctrl+k" },
});
});
it("does not emit keys removed locally (absent from current)", () => {
const current = { DarkMode: true };
const baseline = { DarkMode: true, onoff: true };
expect(diffSyncableStorage(current, baseline)).toEqual({});
});
});
describe("buildUploadPatch", () => {
it("returns null when current matches baseline", () => {
const all = { DarkMode: true, selectedTheme: "" };
const baseline = { DarkMode: true, selectedTheme: "" };
expect(buildUploadPatch(all, baseline)).toBeNull();
});
it("returns sparse envelope when values differ", () => {
const all = {
DarkMode: false,
selectedTheme: "theme-uuid",
bsplus_token: "ignore-me",
};
const baseline = { DarkMode: true, selectedTheme: "theme-uuid" };
const patch = buildUploadPatch(all, baseline);
expect(patch).toEqual({
schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
themeId: "theme-uuid",
data: { DarkMode: false },
});
});
});
describe("normalizeThemeIdForSync", () => {
it("trims whitespace and returns empty for non-strings", () => {
expect(normalizeThemeIdForSync(" abc ")).toBe("abc");
expect(normalizeThemeIdForSync(undefined)).toBe("");
expect(normalizeThemeIdForSync(" ")).toBe("");
});
});
+71 -13
View File
@@ -1,4 +1,5 @@
import browser from "webextension-polyfill";
import isEqual from "lodash/isEqual";
/** Matches the contract in docs/CLOUD_SETTINGS_SYNC_SERVER.md */
export const CLOUD_SETTINGS_SYNC_SCHEMA_VERSION = 1;
@@ -17,6 +18,13 @@ export const BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY =
export const BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY =
"bsplus_pending_theme_ensure_after_cloud";
/**
* Client-only: normalized syncable storage last acked by a successful PUT.
* Never uploaded; used to compute sparse upload patches.
*/
export const BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY =
"bsplus_cloud_settings_last_uploaded_snapshot";
/**
* Never uploaded to the cloud backup (OAuth and legacy keys).
* IndexedDB (e.g. Global Searchs `betterseqta-index` database) is not part of
@@ -48,6 +56,7 @@ export const SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES = [
const CLIENT_ONLY_CLOUD_KEYS_EXACT = [
BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY,
BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY,
"bsplus_lastCloudPoll",
BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY,
] as const;
@@ -118,30 +127,79 @@ export function normalizeThemeIdForSync(raw: unknown): string {
return raw.trim();
}
export function buildUploadPayload(all: Record<string, unknown>): {
schemaVersion: number;
themeId: string;
data: Record<string, unknown>;
} {
/** Filter omit lists and migrate legacy keys → full syncable map for diff/export. */
export function normalizeStorageForSync(all: Record<string, unknown>): Record<string, unknown> {
const filtered: Record<string, unknown> = {};
for (const [k, v] of Object.entries(all)) {
if (shouldOmitKeyFromCloudPayload(k)) continue;
filtered[k] = v;
}
const data = migrateLegacyToPluginSettings(filtered);
const themeId = normalizeThemeIdForSync(all.selectedTheme);
return migrateLegacyToPluginSettings(filtered);
}
/** Keys in `current` whose values differ from `baseline` (sparse PUT body). */
export function diffSyncableStorage(
current: Record<string, unknown>,
baseline: Record<string, unknown>,
): Record<string, unknown> {
const patch: Record<string, unknown> = {};
for (const [key, value] of Object.entries(current)) {
if (!isEqual(value, baseline[key])) {
patch[key] = value;
}
}
return patch;
}
export type CloudSettingsUploadEnvelope = {
schemaVersion: number;
themeId: string;
data: Record<string, unknown>;
};
/** Sparse upload envelope, or null when nothing changed vs baseline. */
export function buildUploadPatch(
all: Record<string, unknown>,
baseline: Record<string, unknown>,
): CloudSettingsUploadEnvelope | null {
const normalized = normalizeStorageForSync(all);
const data = diffSyncableStorage(normalized, baseline);
if (Object.keys(data).length === 0) return null;
return {
schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
themeId,
themeId: normalizeThemeIdForSync(all.selectedTheme),
data,
};
}
export async function getSnapshotForUpload(): Promise<{
schemaVersion: number;
themeId: string;
data: Record<string, unknown>;
}> {
/** Full normalized snapshot (dev export / debugging). */
export function buildUploadPayload(all: Record<string, unknown>): CloudSettingsUploadEnvelope {
const data = normalizeStorageForSync(all);
return {
schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
themeId: normalizeThemeIdForSync(all.selectedTheme),
data,
};
}
export async function getLastUploadedSnapshot(): Promise<Record<string, unknown> | null> {
const stored = await browser.storage.local.get(BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY);
const snapshot = stored[BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY];
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return null;
return snapshot as Record<string, unknown>;
}
export async function saveLastUploadedSnapshot(
snapshot: Record<string, unknown>,
): Promise<void> {
await browser.storage.local.set({ [BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY]: snapshot });
}
export async function clearLastUploadedSnapshot(): Promise<void> {
await browser.storage.local.remove(BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY);
}
export async function getSnapshotForUpload(): Promise<CloudSettingsUploadEnvelope> {
const all = await browser.storage.local.get();
return buildUploadPayload(all as Record<string, unknown>);
}
@@ -84,7 +84,7 @@ function mergePluginSettingsDefaults(
}
/**
* Writes any missing cloud-syncable keys so uploads contain a full schema.
* Writes any missing cloud-syncable keys locally for consistent diffing.
* Never overwrites existing values. Missing plugin settings respect legacy keys.
*/
export async function ensureSyncableStorageDefaults(): Promise<void> {
+35
View File
@@ -0,0 +1,35 @@
const storage = new Map<string, unknown>();
const local = {
get: jest.fn(async (keys?: string | string[] | null) => {
if (keys == null) {
return Object.fromEntries(storage);
}
if (typeof keys === "string") {
return keys in storage ? { [keys]: storage.get(keys) } : {};
}
const out: Record<string, unknown> = {};
for (const key of keys) {
if (storage.has(key)) out[key] = storage.get(key);
}
return out;
}),
set: jest.fn(async (items: Record<string, unknown>) => {
for (const [k, v] of Object.entries(items)) storage.set(k, v);
}),
remove: jest.fn(async (keys: string | string[]) => {
const list = Array.isArray(keys) ? keys : [keys];
for (const key of list) storage.delete(key);
}),
};
export default {
storage: { local },
};
export function __resetBrowserStorageMock() {
storage.clear();
local.get.mockClear();
local.set.mockClear();
local.remove.mockClear();
}