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 couldn’t start because this lesson has no H1 title."; } if (reason === "EMPTY_LESSON") { return "Hudra couldn’t start because this lesson has no presentable content."; } return "Hudra couldn’t 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 couldn’t 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(() => {}); } }); });