all settings sync

This commit is contained in:
2026-06-10 01:17:13 +09:30
parent c9672b4d85
commit 9166bebef7
13 changed files with 1672 additions and 181 deletions
+1 -1
View File
@@ -190,7 +190,7 @@ export async function setKnownRemoteUpdatedAt(iso: string | undefined): Promise<
* Only applies migrations for keys present in the data; does not overwrite
* existing plugin settings if the legacy key is absent.
*/
function migrateLegacyToPluginSettings(data: Record<string, unknown>): Record<string, unknown> {
export function migrateLegacyToPluginSettings(data: Record<string, unknown>): Record<string, unknown> {
const result = { ...data };
function ensurePluginSettings(pluginId: string): Record<string, unknown> {
+73
View File
@@ -0,0 +1,73 @@
import type { SettingsState } from "@/types/storage";
function detectLowEndDevice(): boolean {
const lowCoreCount =
navigator.hardwareConcurrency && navigator.hardwareConcurrency < 4;
const lowMemory =
(navigator as Navigator & { deviceMemory?: number }).deviceMemory != null &&
(navigator as Navigator & { deviceMemory?: number }).deviceMemory! <= 2;
return !!(lowCoreCount || lowMemory);
}
/** Default core settings for a fresh profile (`SettingsState` shape). */
export function getDefaultSettingsState(): SettingsState {
const isLowEndDevice = detectLowEndDevice();
return {
onoff: true,
animatedbk: true,
bksliderinput: "50",
transparencyEffects: false,
lessonalert: true,
defaultmenuorder: [],
menuitems: {
assessments: { toggle: true },
courses: { toggle: true },
dashboard: { toggle: true },
documents: { toggle: true },
forums: { toggle: true },
goals: { toggle: true },
home: { toggle: true },
messages: { toggle: true },
myed: { toggle: true },
news: { toggle: true },
notices: { toggle: true },
portals: { toggle: true },
reports: { toggle: true },
settings: { toggle: true },
timetable: { toggle: true },
welcome: { toggle: true },
},
menuorder: [],
subjectfilters: {},
selectedTheme: "",
selectedColor:
"linear-gradient(40deg, rgba(201,61,0,1) 0%, RGBA(170, 5, 58, 1) 100%)",
originalSelectedColor: "",
DarkMode: true,
animations: !isLowEndDevice,
assessmentsAverage: false,
defaultPage: "home",
shortcuts: [
{ name: "Outlook", enabled: true },
{ name: "Office", enabled: true },
{ name: "Google", enabled: true },
],
customshortcuts: [],
lettergrade: false,
notificationCollector: false,
newsSource: "australia",
iconOnlySidebar: false,
adaptiveThemeColour: false,
adaptiveThemeGradient: false,
adaptiveThemeColourTransition: true,
themeOfTheMonthDisabled: false,
autoCloudSettingsSync: true,
selectedFont: "rubik",
timeFormat: "24",
privacyStatementShown: false,
engageParentsAnnouncementShown: false,
bsCloudAutoSyncAnnouncementShown: false,
};
}
@@ -0,0 +1,116 @@
import browser from "webextension-polyfill";
import { getAllPluginSettings } from "@/plugins";
import { getDefaultSettingsState } from "@/seqta/utils/defaultSettings";
import {
isKeyIncludedInCloudUploadPayload,
migrateLegacyToPluginSettings,
} from "@/seqta/utils/cloudSettingsSync";
/** Legacy top-level keys — never backfill; use `migrateLegacyToPluginSettings` instead. */
const LEGACY_STORAGE_KEYS = [
"animatedbk",
"bksliderinput",
"assessmentsAverage",
"lettergrade",
"notificationCollector",
] as const;
/**
* Keys where `undefined` in storage is intentional and must not be replaced by a
* default (differs from the value we would write).
*/
const OPTIONAL_UNSET_MEANS_DEFAULT_KEYS = [
"timeFormat",
"selectedFont",
"privacyStatementShown",
"privacyStatementLastUpdated",
"engageParentsAnnouncementShown",
"bsCloudAutoSyncAnnouncementShown",
"themeOfTheMonthDismissedMonth",
"themeOfTheMonthLastSeenId",
"justupdated",
"devMode",
"hideSensitiveContent",
"mockNotices",
"devGhReleaseVersionOverride",
"lastSeenNightlyPublishedAt",
"originalDarkMode",
"profile_picture_revision",
] as const;
function buildDefaultPluginSettings(
plugin: ReturnType<typeof getAllPluginSettings>[number],
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, setting] of Object.entries(plugin.settings)) {
const meta = setting as { type?: string; default?: unknown };
if (meta.type === "component" || meta.type === "button") continue;
out[key] = meta.default;
}
return out;
}
/**
* Flat default map in upload shape (plugin-format only; no legacy keys).
*/
export function getSyncableStorageDefaults(): Record<string, unknown> {
const flat: Record<string, unknown> = {
...getDefaultSettingsState(),
};
for (const key of LEGACY_STORAGE_KEYS) {
delete flat[key];
}
for (const key of OPTIONAL_UNSET_MEANS_DEFAULT_KEYS) {
delete flat[key];
}
for (const plugin of getAllPluginSettings()) {
flat[`plugin.${plugin.pluginId}.settings`] =
buildDefaultPluginSettings(plugin);
}
return flat;
}
function mergePluginSettingsDefaults(
defaults: Record<string, unknown>,
fromLegacy: unknown,
): Record<string, unknown> {
if (!fromLegacy || typeof fromLegacy !== "object" || Array.isArray(fromLegacy)) {
return defaults;
}
return { ...defaults, ...(fromLegacy as Record<string, unknown>) };
}
/**
* Writes any missing cloud-syncable keys so uploads contain a full schema.
* Never overwrites existing values. Missing plugin settings respect legacy keys.
*/
export async function ensureSyncableStorageDefaults(): Promise<void> {
const existing = await browser.storage.local.get();
const migratedFromExisting = migrateLegacyToPluginSettings({
...existing,
});
const defaults = getSyncableStorageDefaults();
const patch: Record<string, unknown> = {};
for (const [key, value] of Object.entries(defaults)) {
if (!isKeyIncludedInCloudUploadPayload(key)) continue;
if (Object.prototype.hasOwnProperty.call(existing, key)) continue;
if (key.startsWith("plugin.") && key.endsWith(".settings")) {
patch[key] = mergePluginSettingsDefaults(
value as Record<string, unknown>,
migratedFromExisting[key],
);
continue;
}
patch[key] = value;
}
if (Object.keys(patch).length > 0) {
await browser.storage.local.set(patch);
}
}
+90 -42
View File
@@ -13,6 +13,7 @@ class StorageManager {
private subscribers: Set<Subscriber<SettingsState>> = new Set();
private saveTimeout: NodeJS.Timeout | null = null;
private initialized = false;
private bootstrapping = false;
private constructor() {
this.data = {} as SettingsState;
@@ -33,7 +34,8 @@ class StorageManager {
// Only save if the reference actually changed
if (oldValue !== value) {
Reflect.set(target.data, prop, value);
target.saveToStorage();
void target.saveToStorage([prop as string]);
target.notifySettingChange(prop as string, value, oldValue);
}
return true;
},
@@ -68,8 +70,23 @@ class StorageManager {
public static async initialize(): Promise<StorageManager & SettingsState> {
const instance = StorageManager.getInstance();
if (!instance.initialized) {
await instance.loadFromStorage();
instance.initialized = true;
instance.bootstrapping = true;
try {
// Must run in the service worker — dynamic import() in content scripts
// resolves chunk URLs against the SEQTA page origin on Firefox.
try {
await browser.runtime.sendMessage({ type: "ensureStorageDefaults" });
} catch (e) {
console.warn(
"[BetterSEQTA+] ensureStorageDefaults message failed:",
e,
);
}
await instance.loadFromStorage();
instance.initialized = true;
} finally {
instance.bootstrapping = false;
}
}
return instance;
}
@@ -81,16 +98,24 @@ class StorageManager {
const oldValue = this.data[key];
if (oldValue !== value) {
this.data[key] = value;
this.saveToStorage();
void this.saveToStorage([key as string]);
this.notifySettingChange(key as string, value, oldValue);
}
}
// Notify listeners
const listeners = this.listeners.get(key as string);
if (listeners) {
for (const listener of listeners) {
listener(value, oldValue);
}
private notifySettingChange(
key: string,
newValue: unknown,
oldValue: unknown,
): void {
if (this.bootstrapping) return;
const listeners = this.listeners.get(key);
if (listeners) {
for (const listener of listeners) {
listener(newValue, oldValue);
}
}
this.notifySubscribers();
}
private async loadFromStorage(): Promise<void> {
@@ -100,14 +125,30 @@ class StorageManager {
});
}
public async saveToStorage(): Promise<void> {
public async saveToStorage(changedKeys?: string[]): Promise<void> {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
this.saveTimeout = null;
}
// @ts-expect-error
await browser.storage.local.set(this.data);
this.notifySubscribers();
const payload: Record<string, unknown> = {};
const keys =
changedKeys && changedKeys.length > 0
? changedKeys
: Object.keys(this.data);
for (const key of keys) {
const value = (this.data as Record<string, unknown>)[key];
if (value !== undefined) {
payload[key] = value;
}
}
if (Object.keys(payload).length === 0) return;
await browser.storage.local.set(payload);
if (!this.bootstrapping) {
this.notifySubscribers();
}
}
private async removeFromStorage(key: string): Promise<void> {
@@ -116,39 +157,46 @@ class StorageManager {
private initStorageListener(): void {
browser.storage.onChanged.addListener((changes, areaName) => {
if (areaName === "local") {
const actualChanges: string[] = [];
for (const [key, { oldValue, newValue }] of Object.entries(changes)) {
// Only process if value actually changed
if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
if (newValue !== undefined) {
(this.data as any)[key] = newValue;
} else {
delete (this.data as any)[key];
}
actualChanges.push(key);
// Notify specific listeners
const listeners = this.listeners.get(key);
if (listeners) {
for (const listener of listeners) {
listener(newValue, oldValue);
}
}
if (areaName !== "local") return;
const actualChanges: string[] = [];
for (const [key, { oldValue, newValue }] of Object.entries(changes)) {
if (JSON.stringify(oldValue) === JSON.stringify(newValue)) continue;
if (newValue !== undefined) {
(this.data as Record<string, unknown>)[key] = newValue;
} else {
delete (this.data as Record<string, unknown>)[key];
}
actualChanges.push(key);
if (this.bootstrapping) continue;
const listeners = this.listeners.get(key);
if (listeners) {
for (const listener of listeners) {
listener(newValue, oldValue);
}
}
// Only notify global listeners if there were actual changes
if (actualChanges.length > 0 && this.globalListeners.size > 0) {
for (const listener of this.globalListeners) {
for (const key of actualChanges) {
const { oldValue, newValue } = changes[key];
listener(newValue, oldValue, key);
}
}
if (
!this.bootstrapping &&
actualChanges.length > 0 &&
this.globalListeners.size > 0
) {
for (const listener of this.globalListeners) {
for (const key of actualChanges) {
const { oldValue, newValue } = changes[key];
listener(newValue, oldValue, key);
}
}
}
if (!this.bootstrapping && actualChanges.length > 0) {
this.notifySubscribers();
}
});
}
+4 -8
View File
@@ -62,19 +62,15 @@ export class StorageChangeHandler {
browser.runtime.sendMessage({ type: "reloadTabs" });
}
private handleCustomShortcutsChange(
newValue: CustomShortcut[],
oldValue: CustomShortcut[],
) {
if (!newValue || !oldValue) return;
private handleCustomShortcutsChange(newValue: CustomShortcut[] | undefined) {
if (!Array.isArray(newValue)) return;
renderShortcuts();
}
private handleShortcutsChange(
newValue: { enabled: boolean; name: string }[],
oldValue: { enabled: boolean; name: string }[],
newValue: { enabled: boolean; name: string }[] | undefined,
) {
if (!newValue || !oldValue) return;
if (!Array.isArray(newValue)) return;
renderShortcuts();
}