Merge pull request #451 from StroepWafel/fixcloudsync

all settings sync
This commit is contained in:
Seth Burkart
2026-06-15 10:55:38 +10:00
committed by GitHub
27 changed files with 2079 additions and 198 deletions
+13 -5
View File
@@ -11,7 +11,7 @@ inputs:
required: false required: false
default: stable default: stable
build_label: build_label:
description: Optional build label for nightly display (e.g. run number). description: Optional build label for nightly display (e.g. UTC build date).
required: false required: false
default: "" default: ""
release_repo: release_repo:
@@ -59,9 +59,17 @@ runs:
- name: Package zips - name: Package zips
id: zip id: zip
shell: bash shell: bash
env:
UPDATE_CHANNEL: ${{ inputs.update_channel }}
BUILD_LABEL: ${{ inputs.build_label }}
run: | run: |
VERSION="${{ steps.version.outputs.version }}" VERSION="${{ steps.version.outputs.version }}"
(cd dist/chrome && zip -r "../betterseqtaplus-${VERSION}-chrome.zip" .) if [ "$UPDATE_CHANNEL" = "nightly" ] && [ -n "$BUILD_LABEL" ]; then
(cd dist/firefox && zip -r "../betterseqtaplus-${VERSION}-firefox.zip" .) BASE="betterseqtaplus-nightly-${BUILD_LABEL}"
echo "chrome_zip=dist/betterseqtaplus-${VERSION}-chrome.zip" >> "$GITHUB_OUTPUT" else
echo "firefox_zip=dist/betterseqtaplus-${VERSION}-firefox.zip" >> "$GITHUB_OUTPUT" BASE="betterseqtaplus-${VERSION}"
fi
(cd dist/chrome && zip -r "../${BASE}-chrome.zip" .)
(cd dist/firefox && zip -r "../${BASE}-firefox.zip" .)
echo "chrome_zip=dist/${BASE}-chrome.zip" >> "$GITHUB_OUTPUT"
echo "firefox_zip=dist/${BASE}-firefox.zip" >> "$GITHUB_OUTPUT"
+9 -2
View File
@@ -20,22 +20,29 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set build date
id: build_date
run: echo "date=$(date -u +'%Y-%m-%d')" >> "$GITHUB_OUTPUT"
- name: Build extension - name: Build extension
id: build id: build
uses: ./.github/actions/build-extension uses: ./.github/actions/build-extension
with: with:
gh_release_update_check: "true" gh_release_update_check: "true"
update_channel: nightly update_channel: nightly
build_label: ${{ github.run_number }} build_label: ${{ steps.build_date.outputs.date }}
release_repo: ${{ github.repository }} release_repo: ${{ github.repository }}
- name: Ensure nightly release exists - name: Ensure nightly release exists
run: | run: |
TITLE="Nightly (${{ steps.build_date.outputs.date }})"
if ! gh release view "${{ env.NIGHTLY_TAG }}" 2>/dev/null; then if ! gh release view "${{ env.NIGHTLY_TAG }}" 2>/dev/null; then
gh release create "${{ env.NIGHTLY_TAG }}" \ gh release create "${{ env.NIGHTLY_TAG }}" \
--prerelease \ --prerelease \
--title "Nightly" \ --title "$TITLE" \
--notes-file .github/nightly-release-notes.md --notes-file .github/nightly-release-notes.md
else
gh release edit "${{ env.NIGHTLY_TAG }}" --title "$TITLE"
fi fi
- name: Upload nightly assets - name: Upload nightly assets
+6
View File
@@ -23,7 +23,13 @@ jobs:
env: env:
ESLINT_USE_FLAT_CONFIG: "false" ESLINT_USE_FLAT_CONFIG: "false"
- name: Unit tests
run: npm test
- name: Build extension - name: Build extension
uses: ./.github/actions/build-extension uses: ./.github/actions/build-extension
with: with:
gh_release_update_check: "false" gh_release_update_check: "false"
- name: Smoke tests
run: npm run test:smoke
File diff suppressed because it is too large Load Diff
+11 -6
View File
@@ -28,7 +28,7 @@ Use the same **access tokens** issued by the existing BetterSEQTA+ OAuth flows (
### `PUT /api/bsplus/settings/sync` ### `PUT /api/bsplus/settings/sync`
Upserts the callers settings backup. Upserts the callers settings backup. The server **merges** `data` into the stored JSON document; keys omitted from the patch are **not** deleted.
**Request body (JSON):** **Request body (JSON):**
@@ -37,12 +37,14 @@ Upserts the callers settings backup.
"schemaVersion": 1, "schemaVersion": 1,
"themeId": "uuid-string-or-empty", "themeId": "uuid-string-or-empty",
"data": { "data": {
"...": "flat key-value map mirroring extension storage (see Payload shape)", "DarkMode": true,
"selectedTheme": "uuid-or-empty-string" "selectedTheme": "uuid-or-empty-string"
} }
} }
``` ```
The extension sends a **sparse patch**: only keys that changed since the last successful upload (or, on first upload, keys that differ from schema defaults). A full snapshot is not required.
- **`schemaVersion`**: integer. The extension currently sends `1`. The server may reject unknown major versions or store it for future migrations. - **`schemaVersion`**: integer. The extension currently sends `1`. The server may reject unknown major versions or store it for future migrations.
- **`themeId`**: optional but recommended duplicate of **`data.selectedTheme`**. Should be the UUID of the **installed** BetterSEQTA store theme (`selectedTheme`). This may be a normal theme id **or** a **flavour (slave) variant** id from themes with **`flavours[]`** — the extension uses it after restore to prefetch `theme.json` when missing locally (same **`GET …/themes/{id}/download`** as the store UI). Persist and return **`themeId`** in sync with **`data.selectedTheme`**. - **`themeId`**: optional but recommended duplicate of **`data.selectedTheme`**. Should be the UUID of the **installed** BetterSEQTA store theme (`selectedTheme`). This may be a normal theme id **or** a **flavour (slave) variant** id from themes with **`flavours[]`** — the extension uses it after restore to prefetch `theme.json` when missing locally (same **`GET …/themes/{id}/download`** as the store UI). Persist and return **`themeId`** in sync with **`data.selectedTheme`**.
- **`data`**: object whose keys are storage keys (strings) and values are JSON-serializable values (same types as stored in `chrome.storage.local`). - **`data`**: object whose keys are storage keys (strings) and values are JSON-serializable values (same types as stored in `chrome.storage.local`).
@@ -51,11 +53,12 @@ Upserts the callers settings backup.
```json ```json
{ {
"updated_at": "2026-04-07T12:00:00.000Z" "updated_at": "2026-04-07T12:00:00.000Z",
"patch": { "DarkMode": true }
} }
``` ```
`updated_at` should be an ISO 8601 timestamp of the save time. The extension displays success without requiring extra fields. `updated_at` should be an ISO 8601 timestamp of the save time. The extension displays success without requiring extra fields. Optional `patch` may echo the merged keys applied server-side.
**Error responses:** Standard JSON error body if applicable, e.g. `{ "error": "message" }`, with appropriate HTTP status (`401`, `413`, `422`, etc.). **Error responses:** Standard JSON error body if applicable, e.g. `{ "error": "message" }`, with appropriate HTTP status (`401`, `413`, `422`, etc.).
@@ -103,7 +106,8 @@ Unique constraint on `user_id`.
## Semantics ## Semantics
- **Last write wins:** each `PUT` replaces the stored backup for that user. - **Merge on PUT:** each `PUT` deep-merges `data` into the stored backup for that user. Keys not present in the request body remain unchanged on the server.
- **Full document on GET:** restore and poll download still receive the complete hydrated settings object.
- **Optional later:** `If-Unmodified-Since` or a `revision` field for conflict detection (not required for v1). - **Optional later:** `If-Unmodified-Since` or a `revision` field for conflict detection (not required for v1).
## Security and privacy ## Security and privacy
@@ -121,6 +125,7 @@ The backup is a flat JSON map of **`chrome.storage.local`** keys. It does **not*
- **Assessment Averages caches** — `plugin.assessments-average.storage.assessments`, `plugin.assessments-average.storage.weightings` (school assessment data). - **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. - **Keys under** `plugin.global-search.storage.*` — reserved so any future plugin storage cache there is not synced.
- **Grade Analytics** — keys under `bsplus.analytics.*` (synced assessment cache and per-school chart preferences). - **Grade Analytics** — keys under `bsplus.analytics.*` (synced assessment cache and per-school chart preferences).
- **`bsplus_cloud_settings_last_uploaded_snapshot`** — client-only normalized map last acked by PUT; used to compute sparse upload patches (not part of the cloud backup blob).
- **`bsplus_cloud_settings_known_remote_updated_at`** — client-only watermark for auto-sync (not part of the cloud backup blob). - **`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. On restore, those keys are **not** taken from the server; the device keeps its current local values.
@@ -136,6 +141,6 @@ This uses standard **WebExtension** APIs (`browser.alarms`, `runtime` messages,
## Client reference (extension) ## Client reference (extension)
- Upload / dev export: `buildUploadPayload` / `getSnapshotForUpload` in `src/seqta/utils/cloudSettingsSync.ts` (strips OAuth-related keys, sensitive device keys, **`bsplus_pending_theme_ensure_after_cloud`**, and **`bsplus_cloud_settings_known_remote_updated_at`** — includes **`themeId`** aligned with **`selectedTheme`**). - Upload (sparse patch): `buildUploadPatch` / `putSettingsOnce` in `src/background/cloudSettingsAutoSync.ts`; baseline from `bsplus_cloud_settings_last_uploaded_snapshot` or schema defaults; dev full export via `getSnapshotForUpload` / `buildUploadPayload` in `src/seqta/utils/cloudSettingsSync.ts` (strips OAuth-related keys, sensitive device keys, client-only metadata — includes **`themeId`** aligned with **`selectedTheme`**).
- Download: resolve id via **`resolveThemeIdForPostSyncDownload`** → **`applyDownloadedEnvelope`** after `GET` → prefetch theme blobs in page context if needed (**`prepareThemeAfterCloudSync`** in **`ThemeManager`**) → reload SEQTA tabs; local auth keys, sensitive device keys, client-only watermark, and **`bsplus_pending_theme_ensure_after_cloud`** semantics preserved as documented above. - Download: resolve id via **`resolveThemeIdForPostSyncDownload`** → **`applyDownloadedEnvelope`** after `GET` → prefetch theme blobs in page context if needed (**`prepareThemeAfterCloudSync`** in **`ThemeManager`**) → reload SEQTA tabs; local auth keys, sensitive device keys, client-only watermark, and **`bsplus_pending_theme_ensure_after_cloud`** semantics preserved as documented above.
- 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`. - 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`.
+4 -3
View File
@@ -71,7 +71,7 @@ Release and nightly workflows pass environment variables into Vite, which bakes
| `GH_RELEASE_UPDATE_CHECK` | `true` | `true` | `false` / unset | | `GH_RELEASE_UPDATE_CHECK` | `true` | `true` | `false` / unset |
| `UPDATE_CHANNEL` | `stable` | `nightly` | `stable` (unused) | | `UPDATE_CHANNEL` | `stable` | `nightly` | `stable` (unused) |
| `GH_RELEASE_REPO` | `BetterSEQTA/BetterSEQTA-Plus` | same | same | | `GH_RELEASE_REPO` | `BetterSEQTA/BetterSEQTA-Plus` | same | same |
| `BUILD_LABEL` | empty | GitHub run number | empty | | `BUILD_LABEL` | empty | UTC build date (`YYYY-MM-DD`) | empty |
When `GH_RELEASE_UPDATE_CHECK` is not `true`, the update-checker code is tree-shaken out of the bundle. PR CI builds and local `npm run build` do **not** include the update badge. When `GH_RELEASE_UPDATE_CHECK` is not `true`, the update-checker code is tree-shaken out of the bundle. PR CI builds and local `npm run build` do **not** include the update badge.
@@ -139,7 +139,8 @@ On first publish, edit the **title** and **release notes** on GitHub afterward.
1. Builds from the current `main` branch with the update detector enabled (nightly channel). 1. Builds from the current `main` branch with the update detector enabled (nightly channel).
2. Uses a fixed release tag: **`nightly`** (marked as prerelease). 2. Uses a fixed release tag: **`nightly`** (marked as prerelease).
3. On first run: creates the `nightly` release with the text in [`.github/nightly-release-notes.md`](../.github/nightly-release-notes.md). 3. On first run: creates the `nightly` release with the text in [`.github/nightly-release-notes.md`](../.github/nightly-release-notes.md).
4. On every subsequent run: **replaces** the zip assets on the same release (`--clobber`). The release title and body are not rewritten. 4. On every run: sets the release title to **`Nightly (YYYY-MM-DD)`** (UTC build date) and **replaces** the zip assets on the same release (`--clobber`). The release body is not rewritten.
5. Packages zips as `betterseqtaplus-nightly-{date}-chrome.zip` / `-firefox.zip`.
The nightly release body warns that builds are experimental and must not be uploaded to extension stores. The nightly release body warns that builds are experimental and must not be uploaded to extension stores.
@@ -206,7 +207,7 @@ Implementation: [`src/utils/githubReleaseUpdate.ts`](../src/utils/githubReleaseU
1. Fetches the `nightly` release. 1. Fetches the `nightly` release.
2. Compares its `published_at` timestamp to `lastSeenNightlyPublishedAt` in extension storage. 2. Compares its `published_at` timestamp to `lastSeenNightlyPublishedAt` in extension storage.
3. On first install, records the current publish time without showing a badge. 3. On first install, records the current publish time without showing a badge.
4. Shows **“Update available — nightly #123”** (run number) when a newer nightly has been published. 4. Shows **“Update available — nightly (YYYY-MM-DD)”** when a newer nightly has been published.
Checks are throttled to once every **6 hours** per browser profile (`localStorage` key `bsplus_lastGhReleaseCheck`). Recent results are cached in memory for the session. Checks are throttled to once every **6 hours** per browser profile (`localStorage` key `bsplus_lastGhReleaseCheck`). Recent results are cached in memory for the session.
+4
View File
@@ -9,6 +9,10 @@ export default {
transform: { transform: {
'^.+\\.ts$': 'ts-jest', '^.+\\.ts$': 'ts-jest',
}, },
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'^webextension-polyfill$': '<rootDir>/src/test/mocks/webextension-polyfill.ts',
},
moduleFileExtensions: ['ts', 'js', 'json'], moduleFileExtensions: ['ts', 'js', 'json'],
collectCoverageFrom: [ collectCoverageFrom: [
'src/**/*.ts', 'src/**/*.ts',
+5
View File
@@ -18,6 +18,8 @@
"convert:safari": "xcrun safari-web-extension-converter dist/safari --project-location . --app-name $npm_package_name-safari", "convert:safari": "xcrun safari-web-extension-converter dist/safari --project-location . --app-name $npm_package_name-safari",
"dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg", "dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg",
"lint": "cross-env ESLINT_USE_FLAT_CONFIG=false eslint \"src/**/*.{js,ts}\"", "lint": "cross-env ESLINT_USE_FLAT_CONFIG=false eslint \"src/**/*.{js,ts}\"",
"test": "jest",
"test:smoke": "node scripts/smoke-test.mjs",
"release": "gh release create $npm_package_version --repo BetterSEQTA/BetterSEQTA-Plus ./dist/*.zip --generate-notes", "release": "gh release create $npm_package_version --repo BetterSEQTA/BetterSEQTA-Plus ./dist/*.zip --generate-notes",
"publish": "bun lib/publish.js --b", "publish": "bun lib/publish.js --b",
"zip": "bedframe zip" "zip": "bedframe zip"
@@ -43,6 +45,7 @@
"@crxjs/vite-plugin": "^2.4.0", "@crxjs/vite-plugin": "^2.4.0",
"@types/d3-scale": "^4.0.9", "@types/d3-scale": "^4.0.9",
"@types/d3-shape": "^3.1.8", "@types/d3-shape": "^3.1.8",
"@types/jest": "^30.0.0",
"@types/mime-types": "^3.0.1", "@types/mime-types": "^3.0.1",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
@@ -53,6 +56,7 @@
"eslint": "^9.33.0", "eslint": "^9.33.0",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.31.0",
"glob": "^11.0.1", "glob": "^11.0.1",
"jest": "^30.4.2",
"mime-types": "^3.0.1", "mime-types": "^3.0.1",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"process": "^0.11.10", "process": "^0.11.10",
@@ -61,6 +65,7 @@
"sass-loader": "^16.0.5", "sass-loader": "^16.0.5",
"semver": "^7.7.1", "semver": "^7.7.1",
"tailwindcss": "3", "tailwindcss": "3",
"ts-jest": "^29.4.11",
"url": "^0.11.4" "url": "^0.11.4"
}, },
"dependencies": { "dependencies": {
+49
View File
@@ -0,0 +1,49 @@
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
function fail(message) {
console.error(`[smoke-test] ${message}`);
process.exit(1);
}
function assertManifest(browserDir) {
const manifestPath = path.join(root, "dist", browserDir, "manifest.json");
if (!fs.existsSync(manifestPath)) {
fail(`Missing ${manifestPath} — run npm run build first`);
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
if (!manifest.manifest_version) fail(`${browserDir} manifest missing manifest_version`);
if (!manifest.name) fail(`${browserDir} manifest missing name`);
if (!manifest.version) fail(`${browserDir} manifest missing version`);
const sw =
manifest.background?.service_worker ??
manifest.background?.scripts?.[0] ??
null;
if (sw) {
const swPath = path.join(root, "dist", browserDir, sw);
if (!fs.existsSync(swPath)) {
fail(`${browserDir} service worker not found on disk: ${sw}`);
}
}
}
function assertAssets(browserDir) {
const assetsDir = path.join(root, "dist", browserDir, "assets");
if (!fs.existsSync(assetsDir)) {
fail(`Missing assets directory: dist/${browserDir}/assets`);
}
const jsFiles = fs.readdirSync(assetsDir).filter((f) => f.endsWith(".js"));
if (jsFiles.length === 0) {
fail(`No JS assets in dist/${browserDir}/assets`);
}
}
for (const browser of ["chrome", "firefox"]) {
assertManifest(browser);
assertAssets(browser);
}
console.log("[smoke-test] dist/chrome and dist/firefox look OK");
+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();
+44 -6
View File
@@ -1,12 +1,19 @@
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import {
ensureSyncableStorageDefaults,
getSyncableStorageDefaults,
} from "@/seqta/utils/ensureSyncableStorageDefaults";
import { import {
applyDownloadedEnvelope, applyDownloadedEnvelope,
buildUploadPayload,
BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY, BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY,
BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY, BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY,
buildUploadPatch,
CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
isKeyIncludedInCloudUploadPayload, isKeyIncludedInCloudUploadPayload,
normalizeStorageForSync,
resolveThemeIdForPostSyncDownload, resolveThemeIdForPostSyncDownload,
saveLastUploadedSnapshot,
getLastUploadedSnapshot,
setKnownRemoteUpdatedAt, setKnownRemoteUpdatedAt,
} from "@/seqta/utils/cloudSettingsSync"; } from "@/seqta/utils/cloudSettingsSync";
@@ -138,13 +145,38 @@ async function fetchCloudSummaryWithAuthRetry(
} }
type PutResult = type PutResult =
| { ok: true; updated_at?: string } | { ok: true; updated_at?: string; skipped?: boolean }
| { ok: false; unauthorized: boolean; error?: string }; | { 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> { async function putSettingsOnce(token: string): Promise<PutResult> {
try { try {
const all = await browser.storage.local.get(); await ensureSyncableStorageDefaults();
const payload = buildUploadPayload(all as Record<string, unknown>);
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, { const r = await fetch(CLOUD_SETTINGS_SYNC_URL, {
method: "PUT", method: "PUT",
headers: { headers: {
@@ -163,6 +195,7 @@ async function putSettingsOnce(token: string): Promise<PutResult> {
}; };
} }
const updated_at = data?.updated_at as string | undefined; const updated_at = data?.updated_at as string | undefined;
await saveLastUploadedSnapshot(normalized);
await setKnownRemoteUpdatedAt(updated_at); await setKnownRemoteUpdatedAt(updated_at);
return { ok: true, updated_at }; return { ok: true, updated_at };
} catch (e) { } catch (e) {
@@ -176,11 +209,13 @@ async function putSettingsOnce(token: string): Promise<PutResult> {
export async function performCloudSettingsUploadWithRetry( export async function performCloudSettingsUploadWithRetry(
token: string, token: string,
): Promise<{ success: boolean; error?: string; updated_at?: string }> { ): Promise<{ success: boolean; error?: string; updated_at?: string; skipped?: boolean }> {
let t = token; let t = token;
for (let attempt = 0; attempt < 2; attempt++) { for (let attempt = 0; attempt < 2; attempt++) {
const res = await putSettingsOnce(t); 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) { if (res.unauthorized && attempt === 0) {
const refreshed = await tryRefreshTokens(); const refreshed = await tryRefreshTokens();
if (!refreshed) return { success: false, error: "Not authenticated" }; if (!refreshed) return { success: false, error: "Not authenticated" };
@@ -234,6 +269,9 @@ async function getSettingsAndApplyOnce(token: string): Promise<GetResult> {
reloadSeqtaPagesFn?.(); reloadSeqtaPagesFn?.();
const updated_at = data?.updated_at as string | undefined; const updated_at = data?.updated_at as string | undefined;
await setKnownRemoteUpdatedAt(updated_at); await setKnownRemoteUpdatedAt(updated_at);
await saveLastUploadedSnapshot(
normalizeStorageForSync((await browser.storage.local.get()) as Record<string, unknown>),
);
return { ok: true, updated_at }; return { ok: true, updated_at };
} catch (e) { } catch (e) {
return { return {
+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;
} }
+7 -1
View File
@@ -22,6 +22,7 @@
import { import {
checkGithubReleaseUpdate, checkGithubReleaseUpdate,
dismissNightlyUpdate, dismissNightlyUpdate,
getInstalledGhReleaseChannelLabel,
isGhReleaseUpdateCheckEnabled, isGhReleaseUpdateCheckEnabled,
type GhReleaseUpdateInfo, type GhReleaseUpdateInfo,
} from "@/utils/githubReleaseUpdate"; } from "@/utils/githubReleaseUpdate";
@@ -33,6 +34,7 @@
let disclaimerTitle = $state("Confirm"); let disclaimerTitle = $state("Confirm");
let disclaimerMessage = $state(""); let disclaimerMessage = $state("");
const ghReleaseUpdateEnabled = isGhReleaseUpdateCheckEnabled(); const ghReleaseUpdateEnabled = isGhReleaseUpdateCheckEnabled();
const ghReleaseChannelLabel = getInstalledGhReleaseChannelLabel();
let ghReleaseUpdate = $state<GhReleaseUpdateInfo | null>(null); let ghReleaseUpdate = $state<GhReleaseUpdateInfo | null>(null);
const openGhRelease = () => { const openGhRelease = () => {
@@ -172,7 +174,11 @@
</button> </button>
{/if} {/if}
<p class="text-[9px] leading-tight text-right text-zinc-500 dark:text-zinc-400"> <p class="text-[9px] leading-tight text-right text-zinc-500 dark:text-zinc-400">
GitHub release build — do not upload to extension stores. {#if ghReleaseChannelLabel}
{ghReleaseChannelLabel} — do not upload to extension stores.
{:else}
GitHub release build — do not upload to extension stores.
{/if}
</p> </p>
</div> </div>
{/if} {/if}
+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)}>
@@ -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>
+53 -10
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;
@@ -340,8 +341,7 @@
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-grid { .bsplus-analytics-toolbar-grid {
@@ -413,7 +413,7 @@
.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 {
@@ -589,7 +589,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;
@@ -675,8 +675,15 @@
} }
/* 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) {
@@ -684,6 +691,12 @@
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 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 {
@@ -769,6 +782,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;
@@ -778,7 +797,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);
@@ -805,17 +824,37 @@
.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;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
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 {
@@ -823,10 +862,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 {
@@ -846,9 +887,10 @@
.bsplus-analytics-root .bsplus-chart-surface { .bsplus-analytics-root .bsplus-chart-surface {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
height: 280px; height: var(--bsplus-analytics-chart-height, 280px);
min-height: 280px; min-height: var(--bsplus-analytics-chart-height, 280px);
max-height: 280px; max-height: var(--bsplus-analytics-chart-height, 280px);
flex-shrink: 0;
} }
.bsplus-analytics-root .bsplus-chart-surface-bar { .bsplus-analytics-root .bsplus-chart-surface-bar {
@@ -857,6 +899,7 @@
height: 320px; height: 320px;
min-height: 320px; min-height: 320px;
max-height: 320px; max-height: 320px;
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
@@ -24,6 +24,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",
@@ -175,6 +194,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";
+2
View File
@@ -1,5 +1,6 @@
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import { clearCloudPfpCache } from "@/seqta/utils/cloudPfpCache"; import { clearCloudPfpCache } from "@/seqta/utils/cloudPfpCache";
import { clearLastUploadedSnapshot } from "@/seqta/utils/cloudSettingsSync";
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
const REDIRECT_URI = "https://accounts.betterseqta.org/auth/bsplus/callback"; const REDIRECT_URI = "https://accounts.betterseqta.org/auth/bsplus/callback";
@@ -205,6 +206,7 @@ class CloudAuthService {
public async logout(): Promise<void> { public async logout(): Promise<void> {
const userId = this._state.user?.id; const userId = this._state.user?.id;
if (userId) await clearCloudPfpCache(userId); if (userId) await clearCloudPfpCache(userId);
await clearLastUploadedSnapshot();
await browser.storage.local.remove([ await browser.storage.local.remove([
STORAGE_KEYS.accessToken, STORAGE_KEYS.accessToken,
STORAGE_KEYS.refreshToken, 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("");
});
});
+72 -14
View File
@@ -1,4 +1,5 @@
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import isEqual from "lodash/isEqual";
/** Matches the contract in docs/CLOUD_SETTINGS_SYNC_SERVER.md */ /** Matches the contract in docs/CLOUD_SETTINGS_SYNC_SERVER.md */
export const CLOUD_SETTINGS_SYNC_SCHEMA_VERSION = 1; 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 = export const BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY =
"bsplus_pending_theme_ensure_after_cloud"; "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). * Never uploaded to the cloud backup (OAuth and legacy keys).
* IndexedDB (e.g. Global Searchs `betterseqta-index` database) is not part of * 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 = [ const CLIENT_ONLY_CLOUD_KEYS_EXACT = [
BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY, BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY,
BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY,
"bsplus_lastCloudPoll", "bsplus_lastCloudPoll",
BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY, BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY,
] as const; ] as const;
@@ -118,30 +127,79 @@ export function normalizeThemeIdForSync(raw: unknown): string {
return raw.trim(); return raw.trim();
} }
export function buildUploadPayload(all: Record<string, unknown>): { /** Filter omit lists and migrate legacy keys → full syncable map for diff/export. */
schemaVersion: number; export function normalizeStorageForSync(all: Record<string, unknown>): Record<string, unknown> {
themeId: string;
data: Record<string, unknown>;
} {
const filtered: Record<string, unknown> = {}; const filtered: Record<string, unknown> = {};
for (const [k, v] of Object.entries(all)) { for (const [k, v] of Object.entries(all)) {
if (shouldOmitKeyFromCloudPayload(k)) continue; if (shouldOmitKeyFromCloudPayload(k)) continue;
filtered[k] = v; filtered[k] = v;
} }
const data = migrateLegacyToPluginSettings(filtered); return migrateLegacyToPluginSettings(filtered);
const themeId = normalizeThemeIdForSync(all.selectedTheme); }
/** 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 { return {
schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
themeId, themeId: normalizeThemeIdForSync(all.selectedTheme),
data, data,
}; };
} }
export async function getSnapshotForUpload(): Promise<{ /** Full normalized snapshot (dev export / debugging). */
schemaVersion: number; export function buildUploadPayload(all: Record<string, unknown>): CloudSettingsUploadEnvelope {
themeId: string; const data = normalizeStorageForSync(all);
data: Record<string, unknown>; 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(); const all = await browser.storage.local.get();
return buildUploadPayload(all as Record<string, unknown>); return buildUploadPayload(all as Record<string, unknown>);
} }
@@ -190,7 +248,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 locally for consistent diffing.
* 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();
} }
+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();
}
+15 -2
View File
@@ -35,6 +35,14 @@ function getBuildLabel(): string {
return typeof __BUILD_LABEL__ !== "undefined" ? __BUILD_LABEL__ : ""; return typeof __BUILD_LABEL__ !== "undefined" ? __BUILD_LABEL__ : "";
} }
function formatNightlyLabel(buildDate: string): string {
return buildDate ? `nightly (${buildDate})` : "nightly";
}
function nightlyDateFromPublishedAt(publishedAt: string): string {
return publishedAt.slice(0, 10);
}
function getCurrentVersion(): string { function getCurrentVersion(): string {
return browser.runtime.getManifest().version; return browser.runtime.getManifest().version;
} }
@@ -158,8 +166,7 @@ async function checkNightlyUpdate(): Promise<GhReleaseUpdateInfo> {
} }
const lastSeen = settingsState.lastSeenNightlyPublishedAt; const lastSeen = settingsState.lastSeenNightlyPublishedAt;
const buildLabel = getBuildLabel(); const label = formatNightlyLabel(nightlyDateFromPublishedAt(release.published_at));
const label = buildLabel ? `nightly #${buildLabel}` : "nightly";
if (!lastSeen) { if (!lastSeen) {
settingsState.lastSeenNightlyPublishedAt = release.published_at; settingsState.lastSeenNightlyPublishedAt = release.published_at;
@@ -177,6 +184,12 @@ export function isGhReleaseUpdateCheckEnabled(): boolean {
return isUpdateCheckEnabled(); return isUpdateCheckEnabled();
} }
/** Label for the installed GitHub release build (e.g. `nightly (2025-06-10)`). */
export function getInstalledGhReleaseChannelLabel(): string | null {
if (!isUpdateCheckEnabled() || getUpdateChannel() !== "nightly") return null;
return formatNightlyLabel(getBuildLabel());
}
export async function checkGithubReleaseUpdate(): Promise<GhReleaseUpdateInfo> { export async function checkGithubReleaseUpdate(): Promise<GhReleaseUpdateInfo> {
const fallback = { available: false, label: "", url: releasesBaseUrl() }; const fallback = { available: false, label: "", url: releasesBaseUrl() };