fix: harden extension security and plugin reliability

Address audit findings across background handlers, openers,
plugins, and UI: URL allowlists, XSS reductions, popup lifecycle
fixes, plugin dispose/cleanup, cloud sync hardening, global search
mathjs sandbox, and settings storage fixes.
This commit is contained in:
2026-06-17 10:50:26 +09:30
parent 0e696e0175
commit 8a5424c5a4
70 changed files with 1229 additions and 430 deletions
@@ -17,7 +17,7 @@ import {
processAssessments,
type WeightingEntry,
} from "./utils.ts";
import { injectRubricCopyButtons } from "./rubricCopy.ts";
import { injectRubricCopyButtons, teardownRubricCopyButtons } from "./rubricCopy.ts";
interface weightingsStorage {
weightings: Record<string, WeightingEntry>;
@@ -41,6 +41,8 @@ class AssessmentsAveragePluginClass extends BasePlugin<typeof settings> {
const instance = new AssessmentsAveragePluginClass();
let overrideListenerController: AbortController | null = null;
let wrapperColourObserver: MutationObserver | null = null;
let wrapperColourObserverTimeout: ReturnType<typeof setTimeout> | null = null;
const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
id: "assessments-average",
@@ -54,7 +56,9 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
await initStorage(api);
clearStuck(api);
api.seqta.onMount(".assessmentsWrapper", async () => {
const { unregister: unregisterWrapperMount } = api.seqta.onMount(
".assessmentsWrapper",
async () => {
await waitForElm(
"#main > .assessmentsWrapper .assessments [class*='AssessmentItem__AssessmentItem___']",
true,
@@ -88,17 +92,43 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
void parseAssessments(api);
const wrapper = document.querySelector(".assessmentsWrapper");
if (wrapper) {
const observer = new MutationObserver(() => {
wrapperColourObserver?.disconnect();
if (wrapperColourObserverTimeout) {
clearTimeout(wrapperColourObserverTimeout);
}
wrapperColourObserver = new MutationObserver(() => {
applySubjectColourToOverallResult();
});
observer.observe(wrapper, { childList: true, subtree: true });
setTimeout(() => observer.disconnect(), 10000);
wrapperColourObserver.observe(wrapper, { childList: true, subtree: true });
wrapperColourObserverTimeout = setTimeout(() => {
wrapperColourObserver?.disconnect();
wrapperColourObserver = null;
wrapperColourObserverTimeout = null;
}, 10000);
}
});
api.seqta.onMount("[class*='SelectedAssessment__']", () => {
},
);
const { unregister: unregisterSelectedMount } = api.seqta.onMount(
"[class*='SelectedAssessment__']",
() => {
injectWeightingsTab(api);
injectRubricCopyButtons();
});
},
);
return () => {
overrideListenerController?.abort();
overrideListenerController = null;
wrapperColourObserver?.disconnect();
wrapperColourObserver = null;
if (wrapperColourObserverTimeout) {
clearTimeout(wrapperColourObserverTimeout);
wrapperColourObserverTimeout = null;
}
teardownRubricCopyButtons();
unregisterWrapperMount();
unregisterSelectedMount();
};
},
};
+150 -37
View File
@@ -25,6 +25,91 @@ export interface WeightingEntry {
export type WeightingsMap = Record<string, WeightingEntry>;
/** Primary storage key for weightings / overrides. */
export function assessmentIdKey(mark: { id: string | number }): string {
return String(mark.id);
}
/** Composite lookup key when the same title appears in multiple metaclasses. */
export function assessmentTitleLookupKey(mark: {
metaclassID?: string | number;
title?: string;
}): string | null {
const title = mark.title?.trim();
if (!title) return null;
const metaclassID = mark.metaclassID;
if (metaclassID != null && metaclassID !== "") {
return `${metaclassID}:${title}`;
}
return title;
}
function registerAssessmentLookup(api: any, mark: any) {
const assessmentID = assessmentIdKey(mark);
const next: Record<string, string> = {
...api.storage.assessments,
[assessmentID]: assessmentID,
};
const compositeKey = assessmentTitleLookupKey(mark);
if (compositeKey) next[compositeKey] = assessmentID;
api.storage.assessments = next;
}
type MarkLike = {
id: string | number;
title?: string;
metaclassID?: string | number;
};
function collectMarksFromFiberState(state: Record<string, unknown>): MarkLike[] {
return [
...(Array.isArray(state.marks) ? state.marks : []),
...(Array.isArray(state.upcoming) ? state.upcoming : []),
...(Array.isArray(state.pending) ? state.pending : []),
] as MarkLike[];
}
async function resolveAssessmentId(
api: any,
title: string,
marks?: MarkLike[],
): Promise<string | undefined> {
const assessments = (api.storage.assessments ?? {}) as Record<string, string>;
let resolvedMarks = marks;
if (!resolvedMarks) {
try {
const state = await ReactFiber.find(
"[class*='AssessmentList__items___']",
).getState();
resolvedMarks = collectMarksFromFiberState(state);
} catch {
resolvedMarks = [];
}
}
const matching = resolvedMarks.filter((mark) => mark.title?.trim() === title);
if (matching.length === 1) {
return assessmentIdKey(matching[0]);
}
for (const mark of matching) {
const compositeKey = assessmentTitleLookupKey(mark);
if (compositeKey && assessments[compositeKey]) {
return assessments[compositeKey];
}
}
if (assessments[title]) return assessments[title];
const suffix = `:${title}`;
for (const [key, id] of Object.entries(assessments)) {
if (key.endsWith(suffix)) return id;
}
return undefined;
}
export function computeFingerprint(mark: any): string {
const score =
mark?.results?.percentage ?? mark?.results?.score ?? null;
@@ -264,6 +349,7 @@ function createWeightLabel(
weighting: string | undefined,
api: any,
refreshing = false,
assessmentID?: string,
) {
let statsContainer = assessmentItem.querySelector(
`[class*='AssessmentItem__stats___'], .betterseqta-stats-container`,
@@ -289,10 +375,8 @@ function createWeightLabel(
? "space-between"
: "flex-end";
const title = assessmentItem
.querySelector(`[class*='AssessmentItem__title___']`)
?.textContent?.trim();
const assessmentID = title ? api.storage.assessments?.[title] : undefined;
const resolvedAssessmentId =
assessmentID ?? assessmentItem.dataset.betterseqtaAssessmentId;
const existingLabel = statsContainer.querySelector(
".betterseqta-weight-label",
@@ -302,7 +386,7 @@ function createWeightLabel(
updateWeightLabelContent(
existingLabel,
weighting,
assessmentID,
resolvedAssessmentId,
api,
refreshing,
);
@@ -340,7 +424,7 @@ function createWeightLabel(
updateWeightLabelContent(
weightLabel,
weighting,
assessmentID,
resolvedAssessmentId,
api,
refreshing,
);
@@ -352,14 +436,20 @@ export const isFirefox =
!navigator.userAgent.toLowerCase().includes("seamonkey") &&
!navigator.userAgent.toLowerCase().includes("waterfox");
function trustedPageOrigin(): string {
return window.location.origin;
}
async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
const isBlobUrl = url.startsWith("blob:");
const pageOrigin = trustedPageOrigin();
if (isBlobUrl || isFirefox) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
const requestId = `pdf-fetch-${Date.now()}-${Math.random()}`;
const escapedUrl = url.replace(/'/g, "\\'");
const escapedOrigin = pageOrigin.replace(/'/g, "\\'");
script.textContent = `
(function() {
@@ -375,19 +465,20 @@ async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
type: '${requestId}',
success: true,
data: Array.from(new Uint8Array(arrayBuffer))
}, '*');
}, '${escapedOrigin}');
})
.catch(error => {
window.postMessage({
type: '${requestId}',
success: false,
error: error.message || String(error)
}, '*');
}, '${escapedOrigin}');
});
})();
`;
const messageHandler = (event: MessageEvent) => {
if (event.origin !== pageOrigin || event.source !== window) return;
if (event.data?.type === requestId) {
window.removeEventListener("message", messageHandler);
if (script.parentNode) {
@@ -454,6 +545,9 @@ export async function extractPDFText(url: string): Promise<string> {
const pdfLibInj = escJsSingleQuoted(pdfLibUrl);
const pdfWorkerInj = escJsSingleQuoted(pdfWorkerUrl);
const pageOrigin = trustedPageOrigin();
const escapedOrigin = pageOrigin.replace(/'/g, "\\'");
return new Promise((resolve, reject) => {
const script = document.createElement("script");
const requestId = `pdf-extract-${Date.now()}-${Math.random()}`;
@@ -466,6 +560,7 @@ export async function extractPDFText(url: string): Promise<string> {
script.textContent = `
(function() {
const requestId = '${requestId}';
const pageOrigin = '${escapedOrigin}';
const url = '${escapedUrl}';
const pdfLibSrc = '${pdfLibInj}';
const pdfWorkerSrc = '${pdfWorkerInj}';
@@ -485,7 +580,7 @@ export async function extractPDFText(url: string): Promise<string> {
type: requestId,
success: false,
error: 'Failed to load pdfjs library'
}, '*');
}, pageOrigin);
};
document.head.appendChild(pdfjsScript);
@@ -506,7 +601,7 @@ export async function extractPDFText(url: string): Promise<string> {
type: requestId,
success: false,
error: 'HTTP ' + xhr.status + ': ' + xhr.statusText
}, '*');
}, pageOrigin);
return;
}
@@ -542,21 +637,21 @@ export async function extractPDFText(url: string): Promise<string> {
type: requestId,
success: true,
text: text
}, '*');
}, pageOrigin);
})
.catch(error => {
window.postMessage({
type: requestId,
success: false,
error: 'PDF parsing error: ' + (error.message || String(error))
}, '*');
}, pageOrigin);
});
} catch (error) {
window.postMessage({
type: requestId,
success: false,
error: 'ArrayBuffer error: ' + (error.message || String(error))
}, '*');
}, pageOrigin);
}
};
@@ -565,7 +660,7 @@ export async function extractPDFText(url: string): Promise<string> {
type: requestId,
success: false,
error: 'Network error fetching PDF'
}, '*');
}, pageOrigin);
};
xhr.ontimeout = function() {
@@ -573,7 +668,7 @@ export async function extractPDFText(url: string): Promise<string> {
type: requestId,
success: false,
error: 'Timeout fetching PDF'
}, '*');
}, pageOrigin);
};
xhr.timeout = 30000;
@@ -583,13 +678,14 @@ export async function extractPDFText(url: string): Promise<string> {
type: requestId,
success: false,
error: 'Setup error: ' + (error.message || String(error))
}, '*');
}, pageOrigin);
}
}
})();
`;
const messageHandler = (event: MessageEvent) => {
if (event.origin !== pageOrigin || event.source !== window) return;
if (event.data?.type === requestId) {
window.removeEventListener("message", messageHandler);
if (script.parentNode) {
@@ -646,9 +742,8 @@ export async function extractPDFText(url: string): Promise<string> {
}
async function handleWeightings(mark: any, api: any) {
const assessmentID = mark.id;
const assessmentID = assessmentIdKey(mark);
const metaclassID = mark.metaclassID;
const title = mark.title;
const fingerprint = computeFingerprint(mark);
const existing = api.storage.weightings[assessmentID] as
@@ -687,10 +782,7 @@ async function handleWeightings(mark: any, api: any) {
[assessmentID]: placeholder,
};
api.storage.assessments = {
...api.storage.assessments,
[title.trim()]: assessmentID,
};
registerAssessmentLookup(api, mark);
// Surface the refreshing indicator on the affected row immediately,
// without waiting for the PDF fetch to finish.
@@ -813,6 +905,16 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
let hasRefreshingWeighting = false;
let count = 0;
let fiberMarks: MarkLike[] = [];
try {
const state = await ReactFiber.find(
"[class*='AssessmentList__items___']",
).getState();
fiberMarks = collectMarksFromFiberState(state);
} catch {
fiberMarks = [];
}
for (const assessmentItem of assessmentItems) {
const titleEl = assessmentItem.querySelector(
`[class*='AssessmentItem__title___']`,
@@ -822,7 +924,11 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
const title = titleEl.textContent?.trim();
if (!title) continue;
const assessmentID = api.storage.assessments?.[title];
const assessmentID = await resolveAssessmentId(api, title, fiberMarks);
if (assessmentID) {
(assessmentItem as HTMLElement).dataset.betterseqtaAssessmentId =
assessmentID;
}
const entry = assessmentID
? (api.storage.weightings?.[assessmentID] as WeightingEntry | undefined)
: undefined;
@@ -833,7 +939,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
const weighting = override ?? autoWeighting;
const refreshing = !override && Boolean(entry?.refreshing);
createWeightLabel(assessmentItem, weighting, api, refreshing);
createWeightLabel(assessmentItem, weighting, api, refreshing, assessmentID);
const gradeElement = assessmentItem.querySelector(
`[class*='Thermoscore__text___']`,
@@ -935,12 +1041,17 @@ function resolveTabSetClasses(): Record<string, string> {
return resolved;
}
function buildWeightingsTabContent(api: any, sheet: HTMLElement) {
const titleEl = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___'] [class*='AssessmentItem__title___']",
async function buildWeightingsTabContent(api: any, sheet: HTMLElement) {
const selectedItem = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___']",
) as HTMLElement | null;
const titleEl = selectedItem?.querySelector(
"[class*='AssessmentItem__title___']",
);
const title = titleEl?.textContent?.trim();
const assessmentID = title ? api.storage.assessments?.[title] : undefined;
const assessmentID =
selectedItem?.dataset.betterseqtaAssessmentId ??
(title ? await resolveAssessmentId(api, title) : undefined);
const entry = assessmentID
? (api.storage.weightings?.[assessmentID] as WeightingEntry | undefined)
@@ -1093,7 +1204,7 @@ export function injectWeightingsTab(api: any) {
container.appendChild(newSheet);
newTab.addEventListener("click", () => {
buildWeightingsTabContent(api, newSheet);
void buildWeightingsTabContent(api, newSheet);
});
const allTabs = Array.from(tabList.querySelectorAll("li"));
@@ -1107,20 +1218,22 @@ export function injectWeightingsTab(api: any) {
t.className.includes("TabSet__selected___"),
);
if (i === currentIndex) return;
const goingRight = i > currentIndex;
const goingRight = currentIndex < 0 ? true : i > currentIndex;
allTabs.forEach((t) => {
t.className = "";
t.setAttribute("aria-selected", "false");
});
allSheets[currentIndex].className = [
cls["TabSet__tabsheet___"],
cls["TabSet__hidden___"],
goingRight
? cls["TabSet__disappearToLeft___"]
: cls["TabSet__disappearToRight___"],
].join(" ");
if (currentIndex >= 0) {
allSheets[currentIndex].className = [
cls["TabSet__tabsheet___"],
cls["TabSet__hidden___"],
goingRight
? cls["TabSet__disappearToLeft___"]
: cls["TabSet__disappearToRight___"],
].join(" ");
}
allSheets[i].className = [
cls["TabSet__tabsheet___"],
@@ -29,6 +29,9 @@ async function fetchJSON(url: string, body: any) {
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status} for ${url}`);
}
return res.json();
}
@@ -164,7 +167,7 @@ async function getLearnAssessmentsData(studentId: number) {
}
export async function getAssessmentsData() {
if (settingsState.mockNotices) {
if (settingsState.hideSensitiveContent) {
return getMockAssessmentsData();
}
@@ -38,6 +38,9 @@ async function fetchJSON(url: string, body: unknown) {
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status} for ${url}`);
}
return res.json();
}
@@ -1,7 +1,7 @@
import type { Plugin } from "../../core/types";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { getAssessmentsData } from "./api";
import { renderErrorState, renderGrid, renderSkeletonLoader } from "./ui";
import { renderErrorState, renderGrid, renderSkeletonLoader, teardownOverviewUi } from "./ui";
import styles from "./styles.css?inline";
import { delay } from "@/seqta/utils/delay";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
@@ -66,6 +66,8 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
gridItem.appendChild(label);
menu.insertBefore(gridItem, menu.firstChild);
let loadRequestId = 0;
const menuObserver = new MutationObserver(() => {
ensureOverviewMenuPosition(menu, gridItem);
});
@@ -81,7 +83,18 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
};
gridItem.addEventListener("click", clickHandler);
const popstateHandler = () => {
if (isOverviewRoute()) {
void loadGridView();
} else {
loadRequestId += 1;
teardownOverviewUi();
}
};
window.addEventListener("popstate", popstateHandler);
async function loadGridView() {
const requestId = ++loadRequestId;
await delay(1);
if (isSeqtaEngageExperience()) {
@@ -98,7 +111,7 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
}
const main = document.getElementById("main");
if (!main) return;
if (!main || requestId !== loadRequestId) return;
document
.querySelectorAll('[data-key="assessments"] .item')
@@ -110,17 +123,22 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
.querySelector('[data-key="assessments"]')
?.classList.add("active");
main.innerHTML = '<div id="grid-view-container" class="bsplus-overview-host"></div>';
main.innerHTML =
'<div id="grid-view-container" class="bsplus-overview-host"></div>';
const container = document.getElementById(
"grid-view-container",
) as HTMLElement;
if (requestId !== loadRequestId) return;
renderSkeletonLoader(container);
try {
const data = await getAssessmentsData();
if (requestId !== loadRequestId) return;
renderGrid(container, data);
} catch (err) {
if (requestId !== loadRequestId) return;
console.error("Failed to load assessments:", err);
renderErrorState(
container,
@@ -130,8 +148,11 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
}
return () => {
loadRequestId += 1;
window.removeEventListener("popstate", popstateHandler);
menuObserver.disconnect();
gridItem.removeEventListener("click", clickHandler);
teardownOverviewUi();
gridItem.remove();
};
},
@@ -62,7 +62,7 @@ export function activeSubjectsFromEngageChild(child: {
const seen = new Set<string>();
for (const term of child.terms ?? []) {
if (term.active !== 1) continue;
if (!isActiveTermFlag(term.active)) continue;
for (const raw of term.subjects ?? []) {
const subject = normalizeOverviewSubject(raw);
if (!subject) continue;
@@ -151,7 +151,14 @@ export function determineStatus(item: any): string {
}
const completedKey = "betterseqta-completed-assessments";
const completed = JSON.parse(localStorage.getItem(completedKey) || "[]");
let completed: unknown[] = [];
try {
const raw = localStorage.getItem(completedKey);
const parsed = raw ? JSON.parse(raw) : [];
completed = Array.isArray(parsed) ? parsed : [];
} catch {
completed = [];
}
if (completed.includes(item.id)) {
return "MARKS_RELEASED";
}
+11 -3
View File
@@ -74,7 +74,10 @@ function ensureGestureStart(handler: () => void): () => void {
async function startPlayback(volume: number): Promise<void> {
const blob = await loadAudioBlob();
if (!blob) return;
if (!blob) {
stopAndCleanupAudio();
return;
}
stopAndCleanupAudio();
@@ -123,7 +126,7 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
}
});
// Note: Stop button/event removed by user; no stop handling needed
// Note: Stop button dispatches betterseqta-background-music-stop on remove
// Start if we have audio and autoplay is enabled
const tryStart = async () => {
@@ -160,16 +163,21 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
};
document.addEventListener("visibilitychange", visHandler);
// Allow uploads to trigger refresh
// Allow uploads to trigger refresh; stop event clears playback on remove
const uploadedHandler = () => {
const vol = (api.settings as any).volume ?? 0.5;
startPlayback(vol);
};
const stopHandler = () => {
stopAndCleanupAudio();
};
window.addEventListener("betterseqta-background-music-updated", uploadedHandler);
window.addEventListener("betterseqta-background-music-stop", stopHandler);
return () => {
document.removeEventListener("visibilitychange", visHandler);
window.removeEventListener("betterseqta-background-music-updated", uploadedHandler);
window.removeEventListener("betterseqta-background-music-stop", stopHandler);
if (cleanupRegistered && (window as any).__betterseqta_bg_music_cancel__) {
(window as any).__betterseqta_bg_music_cancel__();
(window as any).__betterseqta_bg_music_cancel__ = undefined;
@@ -255,9 +255,9 @@ const watchNavigator = (navigator: Element, onChange: () => void) => {
return observer;
};
const handleSlidePane = (pane: Element) => {
const handleSlidePane = (pane: Element): (() => void) => {
const navigator = pane.querySelector(".navigator");
if (!navigator) return;
if (!navigator) return () => {};
requestAnimationFrame(() => scrollSelectedIntoView(navigator));
setTimeout(() => scrollSelectedIntoView(navigator), 50);
@@ -272,17 +272,22 @@ const handleSlidePane = (pane: Element) => {
childList: true,
});
const cleanup = new MutationObserver((muts) => {
const paneCleanup = new MutationObserver((muts) => {
muts.forEach((m) => {
m.removedNodes.forEach((n) => {
if (n === pane) {
observer.disconnect();
cleanup.disconnect();
paneCleanup.disconnect();
}
});
});
});
cleanup.observe(document.body, { childList: true });
paneCleanup.observe(document.body, { childList: true });
return () => {
observer.disconnect();
paneCleanup.disconnect();
};
};
const enhancedNavigationPlugin: Plugin<typeof settings> = {
@@ -301,7 +306,11 @@ const enhancedNavigationPlugin: Plugin<typeof settings> = {
window.addEventListener("resize", positionArrows);
window.addEventListener("scroll", positionArrows, true);
api.seqta.onMount(".course", async (element) => {
const navObservers: MutationObserver[] = [];
const courseObservers: MutationObserver[] = [];
const slidePaneCleanups: Array<() => void> = [];
const courseMount = api.seqta.onMount(".course", async (element) => {
const course = element as HTMLElement;
let navObserver: MutationObserver | null = null;
@@ -318,6 +327,7 @@ const enhancedNavigationPlugin: Plugin<typeof settings> = {
}
ensureArrows(course);
});
navObservers.push(navObserver);
return true;
};
@@ -325,6 +335,7 @@ const enhancedNavigationPlugin: Plugin<typeof settings> = {
const courseObserver = new MutationObserver(() => {
if (setup()) courseObserver.disconnect();
});
courseObservers.push(courseObserver);
courseObserver.observe(course, { childList: true, subtree: true });
}
});
@@ -334,13 +345,21 @@ const enhancedNavigationPlugin: Plugin<typeof settings> = {
m.addedNodes.forEach((n) => {
if (n.nodeType !== 1) return;
const el = n as Element;
if (el.classList?.contains("uiSlidePane")) handleSlidePane(el);
if (el.classList?.contains("uiSlidePane")) {
slidePaneCleanups.push(handleSlidePane(el));
}
});
});
});
bodyObserver.observe(document.body, { childList: true });
return () => {
window.removeEventListener("resize", positionArrows);
window.removeEventListener("scroll", positionArrows, true);
courseMount.unregister();
navObservers.forEach((observer) => observer.disconnect());
courseObservers.forEach((observer) => observer.disconnect());
slidePaneCleanups.forEach((cleanup) => cleanup());
bodyObserver.disconnect();
document.getElementById(ARROW_CONTAINER_ID)?.remove();
document.getElementById(STYLE_ID)?.remove();
@@ -175,29 +175,35 @@
const term = searchTerm.trim().toLowerCase();
const requestId = ++searchRequestId;
if (commandsFuse && dynamicContentFuse) {
const results = await doSearch(
term,
commandsFuse,
commandIdToItemMap,
dynamicContentFuse,
dynamicIdToItemMap,
true, // sortByRecent
);
try {
if (commandsFuse && dynamicContentFuse) {
const results = await doSearch(
term,
commandsFuse,
commandIdToItemMap,
dynamicContentFuse,
dynamicIdToItemMap,
true, // sortByRecent
);
// Drop the result if the user has typed since this search started, or
// if the current term no longer matches what we searched for. This
// keeps the visible list anchored to the latest query.
if (requestId !== searchRequestId) return;
if (searchTerm.trim().toLowerCase() !== term) return;
// Drop the result if the user has typed since this search started, or
// if the current term no longer matches what we searched for. This
// keeps the visible list anchored to the latest query.
if (requestId !== searchRequestId) return;
if (searchTerm.trim().toLowerCase() !== term) return;
combinedResults = results;
} else {
if (requestId !== searchRequestId) return;
combinedResults = [];
combinedResults = results;
} else {
if (requestId !== searchRequestId) return;
combinedResults = [];
}
} finally {
// Only clear loading for the latest in-flight search — stale async
// passes must not leave the spinner stuck after fast typing.
if (requestId === searchRequestId) {
isLoading = false;
}
}
isLoading = false;
};
// Optimized debounce: shorter delay for better responsiveness
@@ -214,7 +214,7 @@ const staticCommands: StaticCommandItem[] = [
code: 'KeyM',
keyCode: 77,
altKey: true
}, "*");
}, location.origin);
},
keywords: ["compose", "message", "dm", "direct message", "new message"],
priority: 3,
@@ -37,6 +37,41 @@ export function mountSearchBar(
const searchButton = document.createElement("div");
searchButton.className = "search-trigger";
const searchIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
searchIcon.setAttribute("xmlns", "http://www.w3.org/2000/svg");
searchIcon.setAttribute("width", "16");
searchIcon.setAttribute("height", "16");
searchIcon.setAttribute("viewBox", "0 0 24 24");
searchIcon.setAttribute("fill", "none");
searchIcon.setAttribute("stroke", "currentColor");
searchIcon.setAttribute("stroke-width", "2");
searchIcon.setAttribute("stroke-linecap", "round");
searchIcon.setAttribute("stroke-linejoin", "round");
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("cx", "11");
circle.setAttribute("cy", "11");
circle.setAttribute("r", "8");
searchIcon.appendChild(circle);
const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
line.setAttribute("x1", "21");
line.setAttribute("y1", "21");
line.setAttribute("x2", "16.65");
line.setAttribute("y2", "16.65");
searchIcon.appendChild(line);
const searchLabel = document.createElement("p");
searchLabel.textContent = "Quick search...";
const hotkeySpan = document.createElement("span");
hotkeySpan.className = "search-trigger-hotkey";
hotkeySpan.style.marginLeft = "auto";
hotkeySpan.style.display = "flex";
hotkeySpan.style.alignItems = "center";
hotkeySpan.style.color = "#777";
hotkeySpan.style.fontSize = "12px";
const progressBarWrapper = document.createElement("div");
progressBarWrapper.className = "search-progress-bar-wrapper";
@@ -234,14 +269,10 @@ export function mountSearchBar(
appRef.clearDoneFlashTimer = clearDoneFlashTimer;
const updateSearchButtonDisplay = () => {
searchButton.innerHTML = /* html */ `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<p>Quick search...</p>
<span style="margin-left: auto; display: flex; align-items: center; color: #777; font-size: 12px;">${hotkeyDisplay}</span>
`;
hotkeySpan.textContent = hotkeyDisplay;
if (!searchButton.contains(searchIcon)) {
searchButton.replaceChildren(searchIcon, searchLabel, hotkeySpan);
}
};
updateSearchButtonDisplay();
@@ -4,7 +4,7 @@ import { renderComponentMap } from "./renderComponents";
import type { IndexItem, Job, JobContext } from "./types";
import { VectorWorkerManager } from "./worker/vectorWorkerManager";
import { loadDynamicItems } from "../utils/dynamicItems";
import { getVectorizedItemIds } from "./utils";
import { getVectorizedItemIds, pruneOrphanVectorEmbeddings } from "./utils";
import { INDEX_SCHEMA_VERSION, SCHEMA_VERSION_KEY } from "./schemaVersion";
const META_STORE = "meta";
@@ -220,6 +220,7 @@ export async function runIndexing(): Promise<void> {
startHeartbeat();
console.debug("%c[Indexer] Starting indexing...", "color: green");
try {
const jobIds = Object.keys(jobs);
let completedJobs = 0;
const totalSteps = jobIds.length + 1;
@@ -320,6 +321,17 @@ export async function runIndexing(): Promise<void> {
let allItemsInPrimaryStores = await loadAllStoredItems();
const liveItemIds = new Set(allItemsInPrimaryStores.map((item) => item.id));
const prunedCount = await pruneOrphanVectorEmbeddings(liveItemIds);
if (prunedCount > 0) {
try {
const { refreshVectorCache } = await import("../search/vector/vectorSearch");
await refreshVectorCache();
} catch (e) {
console.warn("[Indexer] Failed to refresh vector cache after prune:", e);
}
}
if (allItemsInPrimaryStores.length > 0) {
console.debug(
`%c[Indexer] Checking ${allItemsInPrimaryStores.length} items for vectorization...`,
@@ -434,8 +446,6 @@ export async function runIndexing(): Promise<void> {
);
}
stopHeartbeat();
allItemsInPrimaryStores = await loadAllStoredItems();
// Create new objects to avoid XrayWrapper issues in Firefox
const itemsWithComponents = allItemsInPrimaryStores.map(item => {
@@ -466,6 +476,9 @@ export async function runIndexing(): Promise<void> {
});
loadDynamicItems(itemsWithComponents);
window.dispatchEvent(new Event("dynamic-items-updated"));
} finally {
stopHeartbeat();
}
}
function mergeItems(existing: IndexItem[], incoming: IndexItem[]): IndexItem[] {
@@ -53,6 +53,75 @@ export async function getVectorizedItemIds(): Promise<Set<string>> {
});
}
const EMBEDDIA_DB = "embeddiaDB";
const EMBEDDIA_STORE = "embeddiaObjectStore";
/**
* Remove vector embeddings for the given item ids from embeddiaDB.
*/
export async function removeVectorEmbeddings(ids: string[]): Promise<void> {
if (ids.length === 0) return;
return new Promise((resolve) => {
const request = indexedDB.open(EMBEDDIA_DB);
request.onerror = () => resolve();
request.onsuccess = () => {
const db = request.result;
if (!db.objectStoreNames.contains(EMBEDDIA_STORE)) {
db.close();
resolve();
return;
}
try {
const transaction = db.transaction([EMBEDDIA_STORE], "readwrite");
const store = transaction.objectStore(EMBEDDIA_STORE);
for (const id of ids) {
store.delete(id);
}
transaction.oncomplete = () => {
db.close();
resolve();
};
transaction.onerror = () => {
db.close();
resolve();
};
} catch (error) {
console.warn("[Indexer] Failed to remove vector embeddings:", error);
db.close();
resolve();
}
};
});
}
/**
* Delete vector embeddings that no longer exist in the structured index.
* Returns the number of orphaned embeddings removed.
*/
export async function pruneOrphanVectorEmbeddings(
liveItemIds: Set<string>,
): Promise<number> {
const vectorizedIds = await getVectorizedItemIds();
const orphanIds = [...vectorizedIds].filter((id) => !liveItemIds.has(id));
if (orphanIds.length > 0) {
console.debug(
`[Indexer] Pruning ${orphanIds.length} orphaned vector embedding(s)`,
);
await removeVectorEmbeddings(orphanIds);
}
return orphanIds.length;
}
export function htmlToPlainText(rawHtml: string): string {
const parser = new DOMParser();
const doc = parser.parseFromString(rawHtml, "text/html");
@@ -242,11 +242,12 @@ export async function hybridSearch(
export async function hybridSearchWithExpansion(
bm25Results: CombinedResult[],
query: string,
_allItems: IndexItem[],
allItems: IndexItem[],
options: HybridSearchOptions = {},
): Promise<CombinedResult[]> {
const opts = { ...DEFAULT_OPTIONS, ...options };
const trimmedQuery = query.trim().toLowerCase();
const liveIndexIds = new Set(allItems.map((item) => item.id));
// First, rerank BM25 results
const rerankedBm25 = await hybridSearch(bm25Results, query, options);
@@ -298,6 +299,9 @@ export async function hybridSearchWithExpansion(
vectorResults.forEach(v => {
if (bm25Ids.has(v.object.id)) return;
// Drop stale vector hits for items no longer in the live structured index.
if (!liveIndexIds.has(v.object.id)) return;
// This is a semantic match that BM25 missed
const item = v.object;
@@ -1,4 +1,4 @@
import * as math from 'mathjs';
import { create, all, typeOf as mathTypeOf, format as mathFormat } from 'mathjs';
import { unitFullNames } from './unitMap';
export interface CalculatorResult {
@@ -10,66 +10,42 @@ export interface CalculatorResult {
error?: string;
}
const expandedMath = math.create(math.all);
/** Hard cap on calculator input length to limit parse/eval cost. */
export const CALCULATOR_MAX_INPUT_LENGTH = 128;
expandedMath.import({
five: 5,
ten: 10,
three: 3,
four: 4,
eight: 8,
sixteen: 16,
twenty: 20,
twentyfive: 25,
fifty: 50,
hundred: 100,
plus: (a: number, b: number) => a + b,
minus: (a: number, b: number) => a - b,
times: (a: number, b: number) => a * b,
divided: (a: number, b: number) => a / b,
power: (a: number, b: number) => Math.pow(a, b),
half: (a: number) => a / 2,
double: (a: number) => a * 2,
quarter: (a: number) => a / 4,
/**
* Functions safe to replace with stubs. Do not block type constructors
* (`complex`, `typed`, `fraction`, `bignumber`, `sparse`) or parse pipeline
* (`parse`, `compile`, `parser`) — mathjs needs those internally and
* `evaluate()` depends on them.
*/
const BLOCKED_MATH_FUNCTIONS = [
'import',
'createUnit',
'random',
'pickRandom',
'chain',
'help',
] as const;
// String functions
length: (str: string) => str.length,
concat: (...args: string[]) => args.join(''),
uppercase: (str: string) => str.toUpperCase(),
lowercase: (str: string) => str.toLowerCase(),
substr: (str: string, start: number, length: number) => str.substr(start, length),
function createSandboxedMath() {
const sandbox = create(all);
const blockFn = () => {
throw new Error('Function not allowed');
};
const blocked: Record<string, () => never> = {};
for (const name of BLOCKED_MATH_FUNCTIONS) {
blocked[name] = blockFn;
}
sandbox.import(blocked, { override: true });
return sandbox;
}
// Random functions
randomInt: (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min,
// Comparison and Boolean operations
and: (a: boolean, b: boolean) => a && b,
or: (a: boolean, b: boolean) => a || b,
not: (a: boolean) => !a,
// Combinatorics
permutations: (n: number, r: number) => expandedMath.combinations(n, r) * expandedMath.factorial(r),
nPr: (n: number, r: number) => expandedMath.combinations(n, r) * expandedMath.factorial(r),
nCr: (n: number, r: number) => expandedMath.combinations(n, r),
// Number theory
gcd: (a: number, b: number) => expandedMath.gcd(a, b),
lcm: (a: number, b: number) => expandedMath.lcm(a, b),
// Precision functions
precision: (num: number, digits: number) => parseFloat(num.toPrecision(digits)),
fix: (num: number, digits: number) => parseFloat(num.toFixed(digits)),
// Percentage operations
percent: (value: number) => value / 100,
// Financial operations
compound: (principal: number, rate: number, time: number) => principal * Math.pow(1 + rate, time),
}, { override: true });
const calculatorMath = createSandboxedMath();
function detectUnit(expression: string): string {
try {
const unit = expandedMath.unit(expression);
const unit = calculatorMath.unit(expression);
if (unit) {
const unitStr = unit.formatUnits();
return unitFullNames[unitStr] || unitStr;
@@ -120,9 +96,9 @@ function tryCompleteExpression(expression: string): string | null {
// Handle cases like "4 + 3 *" -> evaluate "4 + 3"
if (partial && !partial.match(/[\+\-\*\/\^]\s*$/)) {
try {
const result = expandedMath.evaluate(partial);
const result = calculatorMath.evaluate(partial);
if (typeof result === 'number' && !isNaN(result)) {
return expandedMath.format(result, { precision: 14, lowerExp: -15, upperExp: 15 });
return calculatorMath.format(result, { precision: 14, lowerExp: -15, upperExp: 15 });
}
} catch (e) {
// Continue to other attempts
@@ -147,6 +123,17 @@ export function calculateExpression(input: string): CalculatorResult {
outputUnit: '',
};
}
if (trimmed.length > CALCULATOR_MAX_INPUT_LENGTH) {
return {
result: null,
isValid: false,
isPartial: false,
inputUnit: '',
outputUnit: '',
error: `Expression too long (max ${CALCULATOR_MAX_INPUT_LENGTH} characters)`,
};
}
// Check if this looks like a math expression at all
if (!isLikelyMathExpression(trimmed)) {
@@ -161,23 +148,23 @@ export function calculateExpression(input: string): CalculatorResult {
try {
// First try to evaluate the expression as-is
const evaluated = expandedMath.evaluate(trimmed.replace('**', '^'));
const evaluated = calculatorMath.evaluate(trimmed.replace('**', '^'));
if (evaluated !== undefined) {
let result: string;
let inputUnit = '';
let outputUnit = '';
if (math.typeOf(evaluated) === 'Unit') {
if (mathTypeOf(evaluated) === 'Unit') {
// Handle unit conversion results
result = expandedMath.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
result = calculatorMath.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
inputUnit = detectUnit(trimmed);
outputUnit = detectUnit(result);
} else if (typeof evaluated === 'number') {
// Handle regular numbers
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
result = mathFormat(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
} else {
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
result = mathFormat(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
}
return {
@@ -220,4 +207,4 @@ export function calculateExpression(input: string): CalculatorResult {
inputUnit: '',
outputUnit: '',
};
}
}
@@ -6,6 +6,14 @@ export interface ParsedHotkey {
key: string;
}
/** Single-key allowlist: a-z, 0-9, and F1F12 only. */
const ALLOWED_HOTKEY_KEY = /^([a-z0-9]|f(1[0-2]|[1-9]))$/;
export function isAllowedHotkeyKey(key: string): boolean {
if (!key) return false;
return ALLOWED_HOTKEY_KEY.test(key.toLowerCase());
}
export function parseHotkey(hotkeyString: string): ParsedHotkey {
const parts = hotkeyString.toLowerCase().split('+').map(part => part.trim()).filter(part => part.length > 0);
@@ -68,14 +76,14 @@ export function formatHotkeyForDisplay(hotkeyString: string): string {
parts.push(isMac ? '⇧' : 'Shift');
}
if (parsed.key) {
if (parsed.key && isAllowedHotkeyKey(parsed.key)) {
parts.push(parsed.key.toUpperCase());
}
return parts.join(isMac ? ' ' : '+');
} catch (error) {
console.warn('Invalid hotkey string:', hotkeyString);
return hotkeyString; // Fallback to original string
return 'Ctrl+K';
}
}
@@ -84,7 +92,7 @@ export function matchesHotkey(event: KeyboardEvent, hotkeyString: string): boole
const parsed = parseHotkey(hotkeyString);
// If no key is specified, don't match anything
if (!parsed.key) {
if (!parsed.key || !isAllowedHotkeyKey(parsed.key)) {
return false;
}
@@ -111,8 +119,8 @@ export function matchesHotkey(event: KeyboardEvent, hotkeyString: string): boole
export function isValidHotkey(hotkeyString: string): boolean {
try {
const parsed = parseHotkey(hotkeyString);
return parsed.key.length > 0;
return parsed.key.length > 0 && isAllowedHotkeyKey(parsed.key);
} catch (error) {
return false;
}
}
}
@@ -268,7 +268,7 @@
xScale={scaleBand().padding(distribution().modeUsed === "letter" ? 0.22 : 0.28)}
yScale={yScale()}
yScale={yScale}
x="grade"
@@ -35,6 +35,15 @@
),
);
$effect(() => {
sortedData.length;
itemsPerPage;
const maxPage = Math.max(0, pageCount - 1);
if (currentPage > maxPage) {
currentPage = maxPage;
}
});
function toggleSort(column: keyof Assessment) {
if (sortColumn === column) {
sortDirection = sortDirection === "asc" ? "desc" : "asc";
@@ -53,8 +53,9 @@
const [minG, maxG] = gradeRange;
return analyticsData.filter((a) => {
if (filterSubjects.length && !filterSubjects.includes(a.subject)) return false;
const grade = a.finalGrade ?? -1;
if (grade < minG || grade > maxG) return false;
if (a.finalGrade !== undefined) {
if (a.finalGrade < minG || a.finalGrade > maxG) return false;
}
if (
filterSearch &&
!a.title.toLowerCase().includes(filterSearch.toLowerCase()) &&
+15 -3
View File
@@ -24,6 +24,9 @@ async function fetchJSON(url: string, body: Record<string, unknown>) {
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status} for ${url}`);
}
return res.json();
}
@@ -254,10 +257,19 @@ async function loadAllPast(
const results: Record<string, unknown>[][] = [];
for (let i = 0; i < subjects.length; i += PAST_FETCH_CONCURRENCY) {
const batch = subjects.slice(i, i + PAST_FETCH_CONCURRENCY);
const batchResults = await Promise.all(
const batchResults = await Promise.allSettled(
batch.map((s) => loadPastForSubject(studentId, s)),
);
results.push(...batchResults);
for (const result of batchResults) {
if (result.status === "fulfilled") {
results.push(result.value);
} else {
console.error(
"[BetterSEQTA+] Past assessments fetch failed:",
result.reason,
);
}
}
}
return results.flat();
}
@@ -295,7 +307,7 @@ function mergeRawAssessments(
}
export async function getStudentId(): Promise<number> {
const info = await getUserInfo();
const info = await getUserInfo({ validateSession: true });
const id = Number(info?.id);
if (!id || isNaN(id)) throw new Error("Could not resolve student ID");
return id;
+44 -5
View File
@@ -67,6 +67,44 @@ function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
}
function isAllowedFolderColor(color: unknown): color is string {
return typeof color === "string" && FOLDER_COLORS.includes(color);
}
function isAllowedFolderIcon(icon: unknown): icon is string {
return typeof icon === "string" && FOLDER_HEROICONS.includes(icon);
}
function normalizeFolder(folder: Folder): Folder {
return {
id: typeof folder.id === "string" && folder.id ? folder.id : generateId(),
name: typeof folder.name === "string" ? folder.name.trim().slice(0, 30) : "Folder",
color: isAllowedFolderColor(folder.color) ? folder.color : FOLDER_COLORS[0],
emoji: isAllowedFolderIcon(folder.emoji) ? folder.emoji : FOLDER_HEROICONS[0],
};
}
function setSvgIconContent(parent: HTMLElement, svgMarkup: string): void {
parent.replaceChildren();
const template = document.createElement("template");
template.innerHTML = svgMarkup.trim();
const node = template.content.firstElementChild;
if (node) parent.appendChild(node);
}
function appendFolderBadgeContent(badge: HTMLElement, folder: Folder): void {
badge.replaceChildren();
if (folder.emoji) {
const iconWrap = document.createElement("span");
iconWrap.style.display = "inline-flex";
iconWrap.style.verticalAlign = "middle";
iconWrap.style.marginRight = "2px";
setSvgIconContent(iconWrap, folder.emoji);
badge.appendChild(iconWrap);
}
badge.appendChild(document.createTextNode(folder.name));
}
const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFoldersStorage> = {
id: "messageFolders",
name: "Message Folders",
@@ -95,7 +133,8 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
let foldedSection: HTMLElement | null = null;
const unregisters: Array<{ unregister: () => void }> = [];
const getFolders = (): Folder[] => api.storage.folders ?? [];
const getFolders = (): Folder[] =>
(api.storage.folders ?? []).map((folder) => normalizeFolder(folder));
const getAssignments = (): Record<string, string[]> => api.storage.messageAssignments ?? {};
const saveFolders = (folders: Folder[]) => {
@@ -298,7 +337,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
const iconSpan = document.createElement("span");
iconSpan.className = "bsplus-folder-icon";
iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0];
setSvgIconContent(iconSpan, folder.emoji || FOLDER_HEROICONS[0]);
item.appendChild(iconSpan);
const name = document.createElement("span");
@@ -622,7 +661,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
const iconSpan = document.createElement("span");
iconSpan.className = "bsplus-folder-icon";
iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0];
setSvgIconContent(iconSpan, folder.emoji || FOLDER_HEROICONS[0]);
const name = document.createElement("span");
name.textContent = folder.name;
@@ -725,7 +764,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
dot.style.background = folder.color;
const iconSpan = document.createElement("span");
iconSpan.className = "bsplus-folder-icon";
iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0];
setSvgIconContent(iconSpan, folder.emoji || FOLDER_HEROICONS[0]);
const name = document.createElement("span");
name.textContent = folder.name;
item.appendChild(dot);
@@ -810,7 +849,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
const badge = document.createElement("span");
badge.className = "bsplus-msg-badge";
badge.style.background = folder.color;
badge.innerHTML = `${folder.emoji ? `<span style="display:inline-flex;vertical-align:middle;margin-right:2px">${folder.emoji}</span>` : ""}${folder.name}`;
appendFolderBadgeContent(badge, folder);
badge.title = `Filter by "${folder.name}"`;
badge.addEventListener("click", (e) => {
e.stopPropagation();
@@ -62,6 +62,10 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
},
);
if (!response.ok) {
throw new Error(`Heartbeat HTTP ${response.status}`);
}
const data = await response.json();
// Store notification count for history
+6 -4
View File
@@ -10,8 +10,8 @@ import { BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY } from "@/seqta/utils/cloud
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import debounce from "@/seqta/utils/debounce";
import { themeUpdates } from "@/interface/hooks/ThemeUpdates";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
import { getApiBase } from "@/seqta/utils/DevApiBase";
import { isAllowedFetchUrl } from "@/seqta/utils/allowedFetchUrl";
import { updateAllColors } from "@/seqta/ui/colors/Manager";
import {
clearCustomThemeAdaptiveCssVariables,
@@ -667,8 +667,12 @@ export class ThemeManager {
if (!downloadData?.success || !downloadData?.data?.theme_json_url) {
throw new Error("Failed to get theme download URL");
}
const themeJsonUrl = downloadData.data.theme_json_url;
if (!isAllowedFetchUrl(themeJsonUrl)) {
throw new Error("Theme download URL not allowed");
}
themeData = (await this.fetchFromUrl(
downloadData.data.theme_json_url,
themeJsonUrl,
)) as ThemeContent;
} catch (apiError) {
console.warn("[ThemeManager] API failed, trying GitHub fallback:", apiError);
@@ -796,10 +800,8 @@ export class ThemeManager {
this.storeUpdateCheckRunning = true;
localStorage.setItem(ThemeManager.STORE_CHECK_KEY, String(Date.now()));
try {
const token = await cloudAuth.getStoredToken();
const res = (await browser.runtime.sendMessage({
type: "fetchThemes",
token: token ?? undefined,
})) as {
success?: boolean;
data?: { themes?: Array<{ id: string; updated_at?: number }> };
+4
View File
@@ -84,6 +84,8 @@ async function handleTimetable(): Promise<void> {
}
function handleTimetableZoom(): void {
if (document.querySelector(".timetable-zoom-controls")) return;
console.log("Initializing timetable zoom controls");
// Create zoom controls
@@ -130,6 +132,8 @@ function handleTimetableZoom(): void {
}
function handleTimetableAssessmentHide(): void {
if (document.querySelector(".timetable-hide-controls")) return;
const hideControls = document.createElement("div");
hideControls.className = "timetable-hide-controls";
+40 -10
View File
@@ -85,7 +85,10 @@ function createSEQTAAPI(): SEQTAAPI {
*/
function createSettingsAPI<T extends PluginSettings>(
plugin: Plugin<T>,
): SettingsAPI<T> & { loaded: Promise<void> } {
): {
settings: SettingsAPI<T> & { loaded: Promise<void> };
dispose: () => void;
} {
const storageKey = `plugin.${plugin.id}.settings`;
const listeners = new Map<keyof T, Set<(value: any) => void>>();
@@ -167,6 +170,10 @@ function createSettingsAPI<T extends PluginSettings>(
browser.storage.onChanged.addListener(handleStorageChange);
const dispose = () => {
browser.storage.onChanged.removeListener(handleStorageChange);
};
const proxy = new Proxy(settingsWithMeta, {
get(target, prop) {
return target[prop];
@@ -183,6 +190,17 @@ function createSettingsAPI<T extends PluginSettings>(
dataToStore[key] = target[key];
}
// Preserve enabled flag managed separately for disableToggle plugins
if (plugin.disableToggle) {
const allSettings = settingsState.getAll() as Record<string, unknown>;
const existing = allSettings[storageKey] as
| { enabled?: boolean }
| undefined;
if (existing?.enabled !== undefined) {
dataToStore.enabled = existing.enabled;
}
}
browser.storage.local.set({ [storageKey]: dataToStore });
listeners.get(prop as keyof T)?.forEach((cb) => cb(value));
@@ -190,18 +208,18 @@ function createSettingsAPI<T extends PluginSettings>(
},
}) as SettingsAPI<T> & { loaded: Promise<void> };
return proxy;
return { settings: proxy, dispose };
}
function createStorageAPI<T = any>(
pluginId: string,
): StorageAPI<T> & { [K in keyof T]: T[K] } {
): {
storage: StorageAPI<T> & { [K in keyof T]: T[K] };
dispose: () => void;
} {
const prefix = `plugin.${pluginId}.storage.`;
const cache: Record<string, any> = {};
const listeners = new Map<string, Set<(value: any) => void>>();
const storageListeners = new Set<
(changes: { [key: string]: any }, area: string) => void
>();
// Load all existing storage values for this plugin
const loadStoragePromise = (async () => {
@@ -243,10 +261,13 @@ function createStorageAPI<T = any>(
}
};
browser.storage.onChanged.addListener(handleStorageChange);
storageListeners.add(handleStorageChange);
const dispose = () => {
browser.storage.onChanged.removeListener(handleStorageChange);
};
// Create the proxy for direct property access
return new Proxy(cache, {
const storage = new Proxy(cache, {
get(target, prop: string) {
if (prop === "onChange") {
return (key: keyof T, callback: (value: T[keyof T]) => void) => {
@@ -288,6 +309,8 @@ function createStorageAPI<T = any>(
return true;
},
}) as StorageAPI<T> & { [K in keyof T]: T[K] };
return { storage, dispose };
}
function createEventsAPI(pluginId: string): EventsAPI {
@@ -357,10 +380,17 @@ function createEventsAPI(pluginId: string): EventsAPI {
export function createPluginAPI<T extends PluginSettings, S = any>(
plugin: Plugin<T, S>,
): PluginAPI<T, S> {
const { settings, dispose: disposeSettings } = createSettingsAPI(plugin);
const { storage, dispose: disposeStorage } = createStorageAPI<S>(plugin.id);
return {
seqta: createSEQTAAPI(),
settings: createSettingsAPI(plugin),
storage: createStorageAPI<S>(plugin.id),
settings,
storage,
events: createEventsAPI(plugin.id),
dispose: () => {
disposeSettings();
disposeStorage();
},
};
}
+23 -6
View File
@@ -35,6 +35,7 @@ export class PluginManager {
private runningPlugins: Map<string, boolean> = new Map();
private eventBacklog: Map<string, any[]> = new Map();
private cleanupFunctions: Map<string, () => void> = new Map();
private apiDisposers: Map<string, () => void> = new Map();
private listeners: Map<string, Set<(...args: any[]) => void>> = new Map();
private styleElements: Map<string, HTMLStyleElement> = new Map();
@@ -148,6 +149,7 @@ export class PluginManager {
try {
const api = createPluginAPI(plugin);
this.apiDisposers.set(pluginId, api.dispose);
// Check if plugin is enabled before starting
if (plugin.disableToggle) {
@@ -158,6 +160,7 @@ export class PluginManager {
const enabled =
pluginSettings?.enabled ?? plugin.defaultEnabled ?? true;
if (!enabled) {
this.disposePluginAPI(pluginId);
console.info(
`Plugin "${pluginId}" is disabled, skipping initialization`,
);
@@ -186,6 +189,8 @@ export class PluginManager {
// Process any backlogged events
await this.processBackloggedEvents(pluginId);
} catch (error) {
this.removePluginStyles(pluginId);
this.disposePluginAPI(pluginId);
console.error(
`[BetterSEQTA+] Failed to start plugin ${pluginId}:`,
error,
@@ -194,6 +199,22 @@ export class PluginManager {
}
}
private removePluginStyles(pluginId: string): void {
const styleElement = this.styleElements.get(pluginId);
if (styleElement) {
styleElement.remove();
this.styleElements.delete(pluginId);
}
}
private disposePluginAPI(pluginId: string): void {
const dispose = this.apiDisposers.get(pluginId);
if (dispose) {
dispose();
this.apiDisposers.delete(pluginId);
}
}
/**
* Attempts to start all registered plugins.
* Errors during the start of individual plugins are caught and logged,
@@ -225,12 +246,8 @@ export class PluginManager {
* @returns {Promise<void>} A promise that resolves when the plugin has been stopped.
*/
public async stopPlugin(pluginId: string): Promise<void> {
// Remove plugin styles
const styleElement = this.styleElements.get(pluginId);
if (styleElement) {
styleElement.remove();
this.styleElements.delete(pluginId);
}
this.removePluginStyles(pluginId);
this.disposePluginAPI(pluginId);
const cleanup = this.cleanupFunctions.get(pluginId);
if (cleanup) {
+1
View File
@@ -141,6 +141,7 @@ export interface PluginAPI<T extends PluginSettings, S = any> {
settings: SettingsAPI<T>;
storage: TypedStorageAPI<S>;
events: EventsAPI;
dispose: () => void;
}
export interface Plugin<T extends PluginSettings = PluginSettings, S = any> {