Files
hudra/background.js
T
2026-07-26 16:02:28 +10:00

510 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const sessions = new Map();
const tabToSession = new Map();
const windowToSession = new Map();
const previousWindowStates = new Map();
const sessionStorageKey = "hudraActiveSessions";
let persistStatePromise = Promise.resolve();
function rebuildSessionIndexes() {
tabToSession.clear();
windowToSession.clear();
sessions.forEach((session) => {
if (session.audienceTabId != null) {
tabToSession.set(session.audienceTabId, session.id);
}
if (session.audienceWindowId != null) {
windowToSession.set(session.audienceWindowId, session.id);
}
if (session.controlWindowId != null) {
windowToSession.set(session.controlWindowId, session.id);
}
});
}
async function restoreSessionState() {
const stored = await chrome.storage.session.get(sessionStorageKey);
const state = stored[sessionStorageKey];
if (!state) return;
for (const storedSession of state.sessions || []) {
if (!storedSession?.id) continue;
sessions.set(storedSession.id, {
...storedSession,
pageTitle: "Hudra",
current: 0,
count: 0,
audienceHidden: false,
audienceZoom: 100,
audienceScrollY: 0,
currentPreview: "",
nextPreview: ""
});
}
for (const [windowId, windowState] of state.previousWindowStates || []) {
previousWindowStates.set(Number(windowId), windowState);
}
rebuildSessionIndexes();
}
const sessionStateReady = restoreSessionState().catch((error) => {
console.warn("Hudra could not restore its active sessions:", error);
});
async function ensureSessionStateReady() {
await sessionStateReady;
}
function persistSessionState() {
const state = {
sessions: [...sessions.values()].map((session) => ({
id: session.id,
mode: session.mode,
sourceTabId: session.sourceTabId,
audienceTabId: session.audienceTabId,
audienceWindowId: session.audienceWindowId,
controlWindowId: session.controlWindowId
})),
previousWindowStates: [...previousWindowStates.entries()]
};
persistStatePromise = persistStatePromise
.catch(() => {})
.then(() => chrome.storage.session.set({ [sessionStorageKey]: state }));
return persistStatePromise;
}
function makeSessionId() {
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
async function getUsableDisplays() {
const displays = await chrome.system.display.getInfo();
const unique = [];
const seen = new Set();
for (const display of displays) {
// Mirrored displays should behave like a single display.
if (display.mirroringSourceId) continue;
const bounds = display.bounds || {};
const key = `${bounds.left},${bounds.top},${bounds.width},${bounds.height}`;
if (seen.has(key)) continue;
seen.add(key);
unique.push(display);
}
return unique;
}
function chooseSecondaryDisplay(displays) {
const primary = displays.find((display) => display.isPrimary) || displays[0];
const secondary = displays.find((display) => display.id !== primary?.id);
return { primary, secondary };
}
function isSupportedLessonUrl(value) {
try {
const url = new URL(value);
const hostname = url.hostname.toLowerCase();
return url.protocol === "https:" && (
hostname === "qlearn.eq.edu.au" ||
hostname.endsWith(".qlearn.eq.edu.au") ||
hostname.endsWith(".instructure.com")
);
} catch (error) {
return false;
}
}
async function showPageNotice(tabId, message) {
await chrome.scripting.executeScript({
target: { tabId },
files: ["page-detection.js"]
});
await chrome.scripting.executeScript({
target: { tabId },
func: (noticeMessage) => window.__hudraPageDetector.showNotice(noticeMessage),
args: [message]
});
}
async function inspectLessonPage(tabId) {
await chrome.scripting.executeScript({
target: { tabId },
files: ["page-detection.js"]
});
const results = await chrome.scripting.executeScript({
target: { tabId },
func: () => window.__hudraPageDetector.inspect()
});
return results[0]?.result || { supported: false, reason: "INSPECTION_FAILED" };
}
function messageForUnsupportedPage(reason) {
if (reason === "NO_LESSON_TITLE") {
return "Hudra couldnt start because this lesson has no H1 title.";
}
if (reason === "EMPTY_LESSON") {
return "Hudra couldnt start because this lesson has no presentable content.";
}
return "Hudra couldnt start because this page does not contain a supported lesson.";
}
async function waitForTabComplete(tabId, timeoutMs = 20000) {
const existing = await chrome.tabs.get(tabId).catch(() => null);
if (existing?.status === "complete") return;
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
chrome.tabs.onUpdated.removeListener(listener);
reject(new Error("Timed out waiting for the presentation page to load."));
}, timeoutMs);
function listener(updatedTabId, changeInfo) {
if (updatedTabId === tabId && changeInfo.status === "complete") {
clearTimeout(timeout);
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}
}
chrome.tabs.onUpdated.addListener(listener);
});
}
async function injectPresenter(tabId, config) {
await chrome.scripting.executeScript({
target: { tabId },
files: ["page-detection.js", "presenter.js"]
});
await chrome.tabs.sendMessage(tabId, {
type: "QLEARN_PRESENTER_START",
config
});
}
async function startSingleScreen(tab) {
await ensureSessionStateReady();
if (!tab.id || tab.windowId == null) return;
const sessionId = makeSessionId();
const browserWindow = await chrome.windows.get(tab.windowId);
previousWindowStates.set(tab.windowId, browserWindow.state || "maximized");
const session = {
id: sessionId,
mode: "single",
sourceTabId: tab.id,
audienceTabId: tab.id,
audienceWindowId: tab.windowId,
controlWindowId: null,
pageTitle: "Hudra",
current: 0,
count: 0,
audienceHidden: false,
audienceZoom: 100,
audienceScrollY: 0,
currentPreview: "",
nextPreview: ""
};
sessions.set(sessionId, session);
tabToSession.set(tab.id, sessionId);
windowToSession.set(tab.windowId, sessionId);
await persistSessionState();
await injectPresenter(tab.id, { mode: "single", sessionId });
await chrome.windows.update(tab.windowId, { state: "fullscreen" });
}
async function startDualScreen(tab, primary, secondary) {
await ensureSessionStateReady();
if (!tab.id || !tab.url) return;
const sessionId = makeSessionId();
const sb = secondary.bounds;
const pb = primary.bounds;
const audienceWindow = await chrome.windows.create({
url: tab.url,
type: "popup",
focused: true,
left: sb.left,
top: sb.top,
width: Math.max(500, sb.width),
height: Math.max(400, sb.height)
});
const audienceTab = audienceWindow.tabs?.[0];
if (!audienceWindow.id || !audienceTab?.id) {
throw new Error("Could not create the audience presentation window.");
}
const controlUrl = chrome.runtime.getURL(`controls.html?session=${encodeURIComponent(sessionId)}`);
const controlWindow = await chrome.windows.create({
url: controlUrl,
type: "popup",
focused: false,
left: pb.left,
top: pb.top,
width: Math.max(700, pb.width),
height: Math.max(500, pb.height)
});
const session = {
id: sessionId,
mode: "dual",
sourceTabId: tab.id,
audienceTabId: audienceTab.id,
audienceWindowId: audienceWindow.id,
controlWindowId: controlWindow.id || null,
pageTitle: "Loading lesson…",
current: 0,
count: 0,
audienceHidden: false,
audienceZoom: 100,
audienceScrollY: 0,
currentPreview: "",
nextPreview: ""
};
sessions.set(sessionId, session);
tabToSession.set(audienceTab.id, sessionId);
windowToSession.set(audienceWindow.id, sessionId);
if (controlWindow.id != null) windowToSession.set(controlWindow.id, sessionId);
await persistSessionState();
await waitForTabComplete(audienceTab.id);
await injectPresenter(audienceTab.id, { mode: "audience", sessionId });
// Position first, then fullscreen so the chosen monitor owns fullscreen.
await chrome.windows.update(audienceWindow.id, {
left: sb.left,
top: sb.top,
width: sb.width,
height: sb.height,
focused: true
});
await chrome.windows.update(audienceWindow.id, { state: "fullscreen" });
if (controlWindow.id != null) {
await chrome.windows.update(controlWindow.id, {
left: pb.left,
top: pb.top,
width: pb.width,
height: pb.height,
focused: true
}).catch(() => {});
await chrome.windows.update(controlWindow.id, { state: "fullscreen", focused: true }).catch(() => {});
}
}
async function endSession(sessionId, reason = "ended") {
await ensureSessionStateReady();
const session = sessions.get(sessionId);
if (!session) return;
sessions.delete(sessionId);
if (session.audienceTabId != null) tabToSession.delete(session.audienceTabId);
if (session.audienceWindowId != null) windowToSession.delete(session.audienceWindowId);
if (session.controlWindowId != null) windowToSession.delete(session.controlWindowId);
if (session.mode === "single") {
await chrome.tabs.sendMessage(session.audienceTabId, {
type: "QLEARN_PRESENTER_COMMAND",
command: "exit",
reason
}).catch(() => {});
const previousState = previousWindowStates.get(session.audienceWindowId) || "maximized";
previousWindowStates.delete(session.audienceWindowId);
await persistSessionState();
await chrome.windows.update(session.audienceWindowId, { state: previousState }).catch(async () => {
await chrome.windows.update(session.audienceWindowId, { state: "maximized" }).catch(() => {});
});
} else {
await persistSessionState();
if (session.audienceWindowId != null) {
await chrome.windows.remove(session.audienceWindowId).catch(() => {});
}
if (session.controlWindowId != null) {
await chrome.windows.remove(session.controlWindowId).catch(() => {});
}
}
}
async function sendCommand(sessionId, command, value) {
await ensureSessionStateReady();
const session = sessions.get(sessionId);
if (!session?.audienceTabId) return;
if (command === "exit") {
await endSession(sessionId, "user");
return;
}
await chrome.tabs.sendMessage(session.audienceTabId, {
type: "QLEARN_PRESENTER_COMMAND",
command,
value
}).catch(() => {});
}
async function toggleHudraForTab(tab) {
await ensureSessionStateReady();
if (!tab?.id) return;
const windowSessionId = tab.windowId != null ? windowToSession.get(tab.windowId) : null;
if (windowSessionId) {
await endSession(windowSessionId, "toggle");
return;
}
const existingId = tabToSession.get(tab.id);
if (existingId) {
await endSession(existingId, "toggle");
return;
}
if (!isSupportedLessonUrl(tab.url)) {
await showPageNotice(
tab.id,
"Hudra works on QLearn and Canvas lesson pages hosted by Instructure."
).catch(() => {});
return;
}
let inspection;
try {
inspection = await inspectLessonPage(tab.id);
} catch (error) {
await showPageNotice(
tab.id,
"Hudra couldnt inspect this page. Wait for it to finish loading and try again."
).catch(() => {});
return;
}
if (!inspection.supported) {
await showPageNotice(tab.id, messageForUnsupportedPage(inspection.reason)).catch(() => {});
return;
}
try {
const displays = await getUsableDisplays();
const { primary, secondary } = chooseSecondaryDisplay(displays);
if (primary && secondary) {
await startDualScreen(tab, primary, secondary);
} else {
await startSingleScreen(tab);
}
} catch (error) {
console.error("Hudra failed to start:", error);
try {
await startSingleScreen(tab);
} catch (fallbackError) {
console.error("Hudra fallback also failed:", fallbackError);
}
}
}
chrome.action.onClicked.addListener(toggleHudraForTab);
chrome.commands.onCommand.addListener(async (command) => {
if (command !== "toggle-hudra") return;
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (tab) await toggleHudraForTab(tab);
});
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message?.type === "QLEARN_PRESENTER_STATE") {
ensureSessionStateReady().then(() => {
const session = sessions.get(message.sessionId);
if (!session) return;
session.current = Number(message.current) || 0;
session.count = Number(message.count) || 0;
session.pageTitle = message.pageTitle || session.pageTitle;
session.audienceHidden = Boolean(message.audienceHidden);
session.audienceZoom = Number(message.audienceZoom) || 100;
session.audienceScrollY = Number(message.audienceScrollY) || 0;
session.currentPreview = message.currentPreview || "";
session.nextPreview = message.nextPreview || "";
chrome.runtime.sendMessage({
type: "QLEARN_PRESENTER_STATE_UPDATE",
sessionId: session.id,
current: session.current,
count: session.count,
pageTitle: session.pageTitle,
mode: session.mode,
audienceHidden: session.audienceHidden,
audienceZoom: session.audienceZoom,
audienceScrollY: session.audienceScrollY,
currentPreview: session.currentPreview,
nextPreview: session.nextPreview
}).catch(() => {});
});
return;
}
if (message?.type === "QLEARN_PRESENTER_ENDED") {
endSession(message.sessionId, "audience").catch(() => {});
return;
}
if (message?.type === "QLEARN_PRESENTER_CONTROL_COMMAND") {
sendCommand(message.sessionId, message.command, message.value).catch(() => {});
return;
}
if (message?.type === "QLEARN_PRESENTER_GET_SESSION") {
ensureSessionStateReady().then(() => {
const session = sessions.get(message.sessionId);
if (session?.audienceTabId != null) {
chrome.tabs.sendMessage(session.audienceTabId, {
type: "QLEARN_PRESENTER_COMMAND",
command: "reportState"
}).catch(() => {});
}
sendResponse(session ? {
sessionId: session.id,
mode: session.mode,
current: session.current,
count: session.count,
pageTitle: session.pageTitle,
audienceHidden: session.audienceHidden,
audienceZoom: session.audienceZoom,
audienceScrollY: session.audienceScrollY,
currentPreview: session.currentPreview,
nextPreview: session.nextPreview
} : null);
});
return true;
}
});
chrome.windows.onRemoved.addListener((windowId) => {
ensureSessionStateReady().then(() => {
const sessionId = windowToSession.get(windowId);
if (!sessionId) return;
const session = sessions.get(sessionId);
if (!session) return;
// Closing either dual-screen window ends the whole presentation.
if (session.mode === "dual") {
endSession(sessionId, "window-closed").catch(() => {});
}
});
});