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
File diff suppressed because it is too large Load Diff
+19 -69
View File
@@ -1,6 +1,8 @@
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import semver from "semver"; import semver from "semver";
import type { SettingsState } from "@/types/storage"; import type { SettingsState } from "@/types/storage";
import { getDefaultSettingsState } from "@/seqta/utils/defaultSettings";
import { ensureSyncableStorageDefaults } from "@/seqta/utils/ensureSyncableStorageDefaults";
import { fetchNews } from "./background/news"; import { fetchNews } from "./background/news";
import { import {
initCloudSettingsAutoSync, initCloudSettingsAutoSync,
@@ -404,6 +406,15 @@ const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
void browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" }); void browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" });
}, },
setDefaultStorage: () => SetStorageValue(getDefaultValues()), setDefaultStorage: () => SetStorageValue(getDefaultValues()),
ensureStorageDefaults: (_req, sendResponse) => {
void ensureSyncableStorageDefaults()
.then(() => sendResponse({ ok: true }))
.catch((e) => {
console.warn("[BetterSEQTA+] ensureStorageDefaults failed:", e);
sendResponse({ ok: false });
});
return true;
},
sendNews: (req, sendResponse) => { sendNews: (req, sendResponse) => {
fetchNews(req.source ?? "australia", sendResponse); fetchNews(req.source ?? "australia", sendResponse);
return true; return true;
@@ -477,76 +488,8 @@ browser.runtime.onMessage.addListener(
}, },
); );
function detectLowEndDevice(): boolean {
// Check for low-end hardware indicators
const lowCoreCount = navigator.hardwareConcurrency && navigator.hardwareConcurrency < 4;
const lowMemory = (navigator as any).deviceMemory && (navigator as any).deviceMemory <= 2;
return lowCoreCount || lowMemory;
}
function getDefaultValues(): SettingsState { function getDefaultValues(): SettingsState {
const isLowEndDevice = detectLowEndDevice(); return getDefaultSettingsState();
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,
newsSource: "australia",
iconOnlySidebar: false,
adaptiveThemeColour: false,
adaptiveThemeGradient: false,
adaptiveThemeColourTransition: true,
themeOfTheMonthDisabled: false,
autoCloudSettingsSync: true,
};
} }
function SetStorageValue(object: any) { function SetStorageValue(object: any) {
@@ -673,6 +616,8 @@ browser.runtime.onInstalled.addListener(function (event) {
browser.storage.local.remove(["justupdated"]); browser.storage.local.remove(["justupdated"]);
browser.storage.local.remove(["data"]); browser.storage.local.remove(["data"]);
void ensureSyncableStorageDefaults();
if (event.reason == "install" || event.reason == "update") { if (event.reason == "install" || event.reason == "update") {
browser.storage.local.set({ justupdated: true }); browser.storage.local.set({ justupdated: true });
} }
@@ -684,4 +629,9 @@ browser.runtime.onInstalled.addListener(function (event) {
} }
}); });
browser.runtime.onStartup.addListener(() => {
void ensureSyncableStorageDefaults();
});
initCloudSettingsAutoSync({ reloadSeqtaPages }); initCloudSettingsAutoSync({ reloadSeqtaPages });
void ensureSyncableStorageDefaults();
+1 -1
View File
@@ -18,7 +18,7 @@
right: 10px; right: 10px;
top: 80px; top: 80px;
height: 590px; height: 590px;
z-index: 20; z-index: 100;
transition-duration: 100ms; transition-duration: 100ms;
} }
+17 -11
View File
@@ -24,18 +24,18 @@
}); });
const switchChange = (shortcut: any) => { const switchChange = (shortcut: any) => {
const idx = $settingsState.shortcuts.findIndex(s => s.name === shortcut); const current = settingsState.shortcuts ?? [];
const idx = current.findIndex(s => s.name === shortcut);
if (idx !== -1) { if (idx !== -1) {
// Create a new array with the toggled value to ensure reactivity const updated = current.map(s =>
const updated = settingsState.shortcuts.map(s =>
s.name === shortcut ? { ...s, enabled: !s.enabled } : s s.name === shortcut ? { ...s, enabled: !s.enabled } : s
); );
settingsState.shortcuts = updated; settingsState.setKey("shortcuts", updated);
} else { } else {
settingsState.shortcuts = [ settingsState.setKey("shortcuts", [
...settingsState.shortcuts, ...current,
{ name: shortcut, enabled: true } { name: shortcut, enabled: true },
]; ]);
} }
} }
@@ -82,7 +82,10 @@
if (isValidTitle(newTitle) && isValidURL(newURL)) { if (isValidTitle(newTitle) && isValidURL(newURL)) {
const icon = newIcon || newTitle[0]; const icon = newIcon || newTitle[0];
const newShortcut = { name: newTitle.trim(), url: formatUrl(newURL).trim(), icon }; const newShortcut = { name: newTitle.trim(), url: formatUrl(newURL).trim(), icon };
settingsState.customshortcuts = [...settingsState.customshortcuts, newShortcut]; settingsState.setKey("customshortcuts", [
...(settingsState.customshortcuts ?? []),
newShortcut,
]);
newTitle = ""; newTitle = "";
newURL = ""; newURL = "";
@@ -94,7 +97,10 @@
}; };
const deleteCustomShortcut = (index: number) => { const deleteCustomShortcut = (index: number) => {
settingsState.customshortcuts = settingsState.customshortcuts.filter((_, i) => i !== index); settingsState.setKey(
"customshortcuts",
(settingsState.customshortcuts ?? []).filter((_, i) => i !== index),
);
}; };
</script> </script>
@@ -203,7 +209,7 @@
</div> </div>
<!-- Custom Shortcuts Section --> <!-- Custom Shortcuts Section -->
{#each $settingsState.customshortcuts as shortcut, index} {#each ($settingsState.customshortcuts ?? []) as shortcut, index}
<div class="flex justify-between items-center px-4 py-3"> <div class="flex justify-between items-center px-4 py-3">
{shortcut.name} {shortcut.name}
<button aria-label="Delete Shortcut" onclick={() => deleteCustomShortcut(index)}> <button aria-label="Delete Shortcut" onclick={() => deleteCustomShortcut(index)}>
@@ -156,7 +156,7 @@
<article class="bsplus-analytics-card"> <article class="bsplus-analytics-card">
<header class="bsplus-analytics-card-header"> <header class="bsplus-analytics-card-header bsplus-analytics-card-header-split">
<div> <div>
@@ -178,6 +178,12 @@
</div> </div>
<div class="bsplus-analytics-card-controls" aria-hidden="true">
<div class="bsplus-analytics-card-control bsplus-analytics-card-control-spacer"></div>
</div>
</header> </header>
@@ -346,31 +352,33 @@
<footer class="bsplus-analytics-card-footer"> <footer class="bsplus-analytics-card-footer">
{#if trend().direction === "up"} <p>
<span class="bsplus-analytics-trend-up" {#if trend().direction === "up"}
>Trending up · {trend().percentage}% vs previous period</span <span class="bsplus-analytics-trend-up"
> >Trending up · {trend().percentage}% vs previous period</span
{:else if trend().direction === "down"} >
<span class="bsplus-analytics-trend-down" {:else if trend().direction === "down"}
>Trending down · {trend().percentage}% vs previous period</span <span class="bsplus-analytics-trend-down"
> >Trending down · {trend().percentage}% vs previous period</span
{:else} >
<span>Grades remain stable across this period</span> {:else}
{/if} <span>Grades remain stable across this period</span>
<br /> {/if}
<span> </p>
<p>
{filteredData().length} data points · {getTimeRangeLabel(timeRange)} {filteredData().length} data points · {getTimeRangeLabel(timeRange)}
@@ -380,7 +388,7 @@
{/if} {/if}
</span> </p>
</footer> </footer>
@@ -356,12 +356,6 @@
</Chart.Container> </Chart.Container>
{#if distribution().modeUsed === "letter"}
<p class="bsplus-analytics-scale-hint">{distribution().scaleLabel}</p>
{/if}
{:else} {:else}
<div class="bsplus-analytics-card-empty"> <div class="bsplus-analytics-card-empty">
@@ -380,27 +374,37 @@
<footer class="bsplus-analytics-card-footer"> <footer class="bsplus-analytics-card-footer">
{#if distribution().averagePercent !== null} {#if distribution().modeUsed === "letter" && distribution().scaleLabel}
Average <strong>{distribution().averagePercent}%</strong> <p class="bsplus-analytics-scale-hint">{distribution().scaleLabel}</p>
{:else}
Average <strong></strong>
{/if} {/if}
across {totalAssessments} assessment{totalAssessments === 1 ? "" : "s"} <p>
{#if distributionMode === "auto" && distribution().modeUsed === "letter"} {#if distribution().averagePercent !== null}
<span class="bsplus-analytics-footer-muted"> · letter scale detected</span> Average <strong>{distribution().averagePercent}%</strong>
{:else if distributionMode !== "auto"} {:else}
<span class="bsplus-analytics-footer-muted"> · {modeOptionLabel} grouping</span> Average <strong></strong>
{/if} {/if}
across {totalAssessments} assessment{totalAssessments === 1 ? "" : "s"}
{#if distributionMode === "auto" && distribution().modeUsed === "letter"}
<span class="bsplus-analytics-footer-muted"> · letter scale detected</span>
{:else if distributionMode !== "auto"}
<span class="bsplus-analytics-footer-muted"> · {modeOptionLabel} grouping</span>
{/if}
</p>
</footer> </footer>
+55 -16
View File
@@ -33,6 +33,7 @@
--bsplus-analytics-shadow-hover: 0 8px 24px 8px rgba(0, 0, 0, 0.16); --bsplus-analytics-shadow-hover: 0 8px 24px 8px rgba(0, 0, 0, 0.16);
/* Set on host via ui.ts from --better-main / user selectedColor */ /* Set on host via ui.ts from --better-main / user selectedColor */
--bsplus-analytics-accent: var(--better-main, #007bff); --bsplus-analytics-accent: var(--better-main, #007bff);
--bsplus-analytics-chart-height: 300px;
width: 100%; width: 100%;
max-width: none; max-width: none;
@@ -342,13 +343,12 @@
backdrop-filter: var(--bsplus-theme-card-blur, none); backdrop-filter: var(--bsplus-theme-card-blur, none);
overflow: visible; overflow: visible;
position: relative; position: relative;
z-index: 40; z-index: 3;
isolation: isolate;
} }
.bsplus-analytics-toolbar-dropdown-field { .bsplus-analytics-toolbar-dropdown-field {
position: relative; position: relative;
z-index: 2; z-index: 4;
} }
.bsplus-analytics-field { .bsplus-analytics-field {
@@ -541,7 +541,7 @@
position: absolute; position: absolute;
left: 0; left: 0;
top: calc(100% + 0.35rem); top: calc(100% + 0.35rem);
z-index: 100; z-index: 5;
min-width: 14rem; min-width: 14rem;
max-height: 12rem; max-height: 12rem;
overflow-y: auto; overflow-y: auto;
@@ -601,11 +601,19 @@
width: 100%; width: 100%;
position: relative; position: relative;
z-index: 1; z-index: 1;
align-items: stretch;
} }
/* Fade-in animation must not paint above the filter toolbar / dropdown */ /* Fade-in animation must not paint above the filter toolbar / dropdown */
.bsplus-analytics-charts .bsplus-analytics-animate { .bsplus-analytics-charts > .bsplus-analytics-animate {
z-index: 1; z-index: 1;
display: flex;
min-width: 0;
}
.bsplus-analytics-charts > .bsplus-analytics-animate > .bsplus-analytics-card {
flex: 1;
width: 100%;
} }
@media (min-width: 960px) { @media (min-width: 960px) {
@@ -613,11 +621,18 @@
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 1.5rem; gap: 1.5rem;
} }
.bsplus-analytics-charts .bsplus-analytics-card-header {
min-height: 5.75rem;
box-sizing: border-box;
align-items: flex-end;
}
} }
.bsplus-analytics-card { .bsplus-analytics-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%;
border-radius: var( border-radius: var(
--bsplus-theme-card-radius, --bsplus-theme-card-radius,
var(--bsplus-analytics-radius) var(--bsplus-analytics-radius)
@@ -669,6 +684,12 @@
min-width: 9.5rem; min-width: 9.5rem;
} }
.bsplus-analytics-card-control-spacer {
visibility: hidden;
pointer-events: none;
min-height: 2.5rem;
}
.bsplus-analytics-select-compact { .bsplus-analytics-select-compact {
min-width: 9.5rem; min-width: 9.5rem;
min-height: 2.5rem; min-height: 2.5rem;
@@ -678,7 +699,7 @@
} }
.bsplus-analytics-scale-hint { .bsplus-analytics-scale-hint {
margin: 0.65rem 0 0; margin: 0;
font-size: 0.75rem; font-size: 0.75rem;
line-height: 1.4; line-height: 1.4;
color: var(--bsplus-analytics-muted); color: var(--bsplus-analytics-muted);
@@ -705,15 +726,35 @@
.bsplus-analytics-card-body { .bsplus-analytics-card-body {
padding: 1rem 1.15rem; padding: 1rem 1.15rem;
flex: 1; flex: 1;
display: flex;
flex-direction: column;
background: var(--bsplus-analytics-surface); background: var(--bsplus-analytics-surface);
} }
.bsplus-analytics-charts .bsplus-analytics-card-body {
justify-content: center;
}
.bsplus-analytics-card-footer { .bsplus-analytics-card-footer {
padding: 0.85rem 1.25rem 1.1rem; padding: 0.85rem 1.25rem 1.1rem;
border-top: 1px solid var(--bsplus-analytics-border); border-top: 1px solid var(--bsplus-analytics-border);
font-size: 0.8125rem; font-size: 0.8125rem;
color: var(--bsplus-analytics-muted); color: var(--bsplus-analytics-muted);
line-height: 1.5; line-height: 1.5;
margin-top: auto;
}
.bsplus-analytics-charts .bsplus-analytics-card-footer {
min-height: 4.25rem;
box-sizing: border-box;
}
.bsplus-analytics-card-footer p {
margin: 0;
}
.bsplus-analytics-card-footer p + p {
margin-top: 0.35rem;
} }
.bsplus-analytics-card-empty { .bsplus-analytics-card-empty {
@@ -721,10 +762,12 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 220px; min-height: var(--bsplus-analytics-chart-height, 300px);
height: var(--bsplus-analytics-chart-height, 300px);
text-align: center; text-align: center;
gap: 0.35rem; gap: 0.35rem;
color: var(--bsplus-analytics-muted); color: var(--bsplus-analytics-muted);
flex-shrink: 0;
} }
.bsplus-analytics-card-empty strong { .bsplus-analytics-card-empty strong {
@@ -741,16 +784,12 @@
color: var(--bsplus-analytics-muted); color: var(--bsplus-analytics-muted);
} }
.bsplus-analytics-root .bsplus-chart-surface { .bsplus-analytics-root .bsplus-chart-surface,
height: 280px;
min-height: 280px;
max-height: 280px;
}
.bsplus-analytics-root .bsplus-chart-surface-bar { .bsplus-analytics-root .bsplus-chart-surface-bar {
height: 320px; height: var(--bsplus-analytics-chart-height);
min-height: 320px; min-height: var(--bsplus-analytics-chart-height);
max-height: 320px; max-height: var(--bsplus-analytics-chart-height);
flex-shrink: 0;
} }
/* Bar chart: show axis spines and tick marks (area chart hides these) */ /* Bar chart: show axis spines and tick marks (area chart hides these) */
+21
View File
@@ -23,6 +23,25 @@ let darkModeObserver: MutationObserver | null = null;
let themeStyleObserver: MutationObserver | null = null; let themeStyleObserver: MutationObserver | null = null;
let themeListeners: ThemeListenerRegistration[] = []; let themeListeners: ThemeListenerRegistration[] = [];
const ANALYTICS_STACKING_STYLE_ID = "bsplus-analytics-stacking-styles";
/** Light-DOM stacking scope so toolbar/dropdown z-index cannot paint over ExtensionPopup. */
function ensureAnalyticsStackingScope() {
if (document.getElementById(ANALYTICS_STACKING_STYLE_ID)) return;
const style = document.createElement("style");
style.id = ANALYTICS_STACKING_STYLE_ID;
style.textContent = `
#analytics-view-container,
.bsplus-analytics-container,
.bsplus-analytics-host {
position: relative;
z-index: 0;
isolation: isolate;
}
`;
document.head.appendChild(style);
}
const THEME_CSS_VARS = [ const THEME_CSS_VARS = [
"--better-main", "--better-main",
"--better-pale", "--better-pale",
@@ -161,6 +180,8 @@ function teardown() {
export function renderAnalyticsPage(container: HTMLElement) { export function renderAnalyticsPage(container: HTMLElement) {
teardown(); teardown();
ensureAnalyticsStackingScope();
container.innerHTML = ""; container.innerHTML = "";
container.className = "bsplus-analytics-container"; container.className = "bsplus-analytics-container";
+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 * Only applies migrations for keys present in the data; does not overwrite
* existing plugin settings if the legacy key is absent. * 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 }; const result = { ...data };
function ensurePluginSettings(pluginId: string): Record<string, unknown> { 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 subscribers: Set<Subscriber<SettingsState>> = new Set();
private saveTimeout: NodeJS.Timeout | null = null; private saveTimeout: NodeJS.Timeout | null = null;
private initialized = false; private initialized = false;
private bootstrapping = false;
private constructor() { private constructor() {
this.data = {} as SettingsState; this.data = {} as SettingsState;
@@ -33,7 +34,8 @@ class StorageManager {
// Only save if the reference actually changed // Only save if the reference actually changed
if (oldValue !== value) { if (oldValue !== value) {
Reflect.set(target.data, prop, value); Reflect.set(target.data, prop, value);
target.saveToStorage(); void target.saveToStorage([prop as string]);
target.notifySettingChange(prop as string, value, oldValue);
} }
return true; return true;
}, },
@@ -68,8 +70,23 @@ class StorageManager {
public static async initialize(): Promise<StorageManager & SettingsState> { public static async initialize(): Promise<StorageManager & SettingsState> {
const instance = StorageManager.getInstance(); const instance = StorageManager.getInstance();
if (!instance.initialized) { if (!instance.initialized) {
await instance.loadFromStorage(); instance.bootstrapping = true;
instance.initialized = 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; return instance;
} }
@@ -81,16 +98,24 @@ class StorageManager {
const oldValue = this.data[key]; const oldValue = this.data[key];
if (oldValue !== value) { if (oldValue !== value) {
this.data[key] = value; this.data[key] = value;
this.saveToStorage(); void this.saveToStorage([key as string]);
this.notifySettingChange(key as string, value, oldValue);
}
}
// Notify listeners private notifySettingChange(
const listeners = this.listeners.get(key as string); key: string,
if (listeners) { newValue: unknown,
for (const listener of listeners) { oldValue: unknown,
listener(value, oldValue); ): 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> { 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) { if (this.saveTimeout) {
clearTimeout(this.saveTimeout); clearTimeout(this.saveTimeout);
this.saveTimeout = null; this.saveTimeout = null;
} }
// @ts-expect-error const payload: Record<string, unknown> = {};
await browser.storage.local.set(this.data); const keys =
this.notifySubscribers(); 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> { private async removeFromStorage(key: string): Promise<void> {
@@ -116,39 +157,46 @@ class StorageManager {
private initStorageListener(): void { private initStorageListener(): void {
browser.storage.onChanged.addListener((changes, areaName) => { browser.storage.onChanged.addListener((changes, areaName) => {
if (areaName === "local") { if (areaName !== "local") return;
const actualChanges: string[] = [];
const actualChanges: string[] = [];
for (const [key, { oldValue, newValue }] of Object.entries(changes)) {
// Only process if value actually changed for (const [key, { oldValue, newValue }] of Object.entries(changes)) {
if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) { if (JSON.stringify(oldValue) === JSON.stringify(newValue)) continue;
if (newValue !== undefined) {
(this.data as any)[key] = newValue; if (newValue !== undefined) {
} else { (this.data as Record<string, unknown>)[key] = newValue;
delete (this.data as any)[key]; } else {
} delete (this.data as Record<string, unknown>)[key];
actualChanges.push(key); }
actualChanges.push(key);
// Notify specific listeners
const listeners = this.listeners.get(key); if (this.bootstrapping) continue;
if (listeners) {
for (const listener of listeners) { const listeners = this.listeners.get(key);
listener(newValue, oldValue); 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) { if (
for (const listener of this.globalListeners) { !this.bootstrapping &&
for (const key of actualChanges) { actualChanges.length > 0 &&
const { oldValue, newValue } = changes[key]; this.globalListeners.size > 0
listener(newValue, oldValue, key); ) {
} 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" }); browser.runtime.sendMessage({ type: "reloadTabs" });
} }
private handleCustomShortcutsChange( private handleCustomShortcutsChange(newValue: CustomShortcut[] | undefined) {
newValue: CustomShortcut[], if (!Array.isArray(newValue)) return;
oldValue: CustomShortcut[],
) {
if (!newValue || !oldValue) return;
renderShortcuts(); renderShortcuts();
} }
private handleShortcutsChange( private handleShortcutsChange(
newValue: { enabled: boolean; name: string }[], newValue: { enabled: boolean; name: string }[] | undefined,
oldValue: { enabled: boolean; name: string }[],
) { ) {
if (!newValue || !oldValue) return; if (!Array.isArray(newValue)) return;
renderShortcuts(); renderShortcuts();
} }