From 51edff809afa12828a0db002433edcac957a3f6b Mon Sep 17 00:00:00 2001 From: Kaden Napper Date: Sun, 26 Jul 2026 16:02:28 +1000 Subject: [PATCH] Release Hudra 0.10 beta 1 --- CHANGELOG.md | 15 ++- README.md | 3 + ROADMAP.md | 10 ++ background.js | 260 +++++++++++++++++++++++++++++++++++-------- docs/ARCHITECTURE.md | 22 +++- manifest.json | 5 +- page-detection.js | 122 ++++++++++++++++++++ presenter.js | 59 ++-------- release.ps1 | 50 +++++---- 9 files changed, 424 insertions(+), 122 deletions(-) create mode 100644 page-detection.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a619a0..1605128 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ Turn HTML lessons into classroom presentations. +## Hudra 0.10 Beta 1 — 2026-07-26 + +- Restrict presentation startup to supported QLearn and Instructure lesson pages. +- Show an accessible in-page message when Hudra cannot start. +- Restore active presenter sessions after the extension service worker restarts. +- Remove the unnecessary `tabs` permission. +- Preserve rich paragraph markup during slide pagination. +- Package only browser-extension runtime files in release builds. + Build once. Present everywhere. @@ -47,9 +56,9 @@ It simply gives it Presentation Mode. ## Roadmap -Current release +Current beta release -Hudra 0.9.1 +Hudra 0.10 Beta 1 See ROADMAP.md @@ -71,4 +80,4 @@ Contributing ## License -MIT \ No newline at end of file +MIT diff --git a/README.md b/README.md index d8d99b6..1b19b2f 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,9 @@ Hudra Hudra transforms structured Canvas/QLearn pages into responsive presentations. All lesson processing remains local in the browser. +CURRENT BETA +Hudra 0.10 Beta 1 + INSTALL 1. Extract this ZIP file. 2. Open edge://extensions, chrome://extensions or brave://extensions. diff --git a/ROADMAP.md b/ROADMAP.md index 800e28b..b904de1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,6 +3,16 @@ Hudra is developed incrementally, with stability and simplicity prioritised over large feature releases. +## Current beta release + +### Hudra 0.10 Beta 1 + +- Supported-page detection for QLearn and Canvas +- Resilient presenter controls across service-worker restarts +- Rich paragraph markup preserved during pagination +- Reduced browser permissions +- Runtime-only release packaging + ## Current stable release ### Hudra 0.9.1 diff --git a/background.js b/background.js index f89bae8..5530de9 100644 --- a/background.js +++ b/background.js @@ -2,6 +2,80 @@ 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)}`; @@ -32,6 +106,57 @@ function chooseSecondaryDisplay(displays) { 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; @@ -57,7 +182,7 @@ async function waitForTabComplete(tabId, timeoutMs = 20000) { async function injectPresenter(tabId, config) { await chrome.scripting.executeScript({ target: { tabId }, - files: ["presenter.js"] + files: ["page-detection.js", "presenter.js"] }); await chrome.tabs.sendMessage(tabId, { @@ -67,6 +192,7 @@ async function injectPresenter(tabId, config) { } async function startSingleScreen(tab) { + await ensureSessionStateReady(); if (!tab.id || tab.windowId == null) return; const sessionId = makeSessionId(); @@ -93,12 +219,14 @@ async function startSingleScreen(tab) { 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(); @@ -153,6 +281,7 @@ async function startDualScreen(tab, primary, secondary) { 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 }); @@ -180,6 +309,7 @@ async function startDualScreen(tab, primary, secondary) { } async function endSession(sessionId, reason = "ended") { + await ensureSessionStateReady(); const session = sessions.get(sessionId); if (!session) return; @@ -197,10 +327,12 @@ async function endSession(sessionId, reason = "ended") { 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(() => {}); } @@ -211,6 +343,7 @@ async function endSession(sessionId, reason = "ended") { } async function sendCommand(sessionId, command, value) { + await ensureSessionStateReady(); const session = sessions.get(sessionId); if (!session?.audienceTabId) return; @@ -227,6 +360,7 @@ async function sendCommand(sessionId, command, value) { } async function toggleHudraForTab(tab) { + await ensureSessionStateReady(); if (!tab?.id) return; const windowSessionId = tab.windowId != null ? windowToSession.get(tab.windowId) : null; @@ -241,6 +375,30 @@ async function toggleHudraForTab(tab) { 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); @@ -270,31 +428,33 @@ chrome.commands.onCommand.addListener(async (command) => { chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message?.type === "QLEARN_PRESENTER_STATE") { - const session = sessions.get(message.sessionId); - if (!session) return; + 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 || ""; + 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(() => {}); + 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; } @@ -309,31 +469,41 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } if (message?.type === "QLEARN_PRESENTER_GET_SESSION") { - const session = sessions.get(message.sessionId); - 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); + 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) => { - const sessionId = windowToSession.get(windowId); - if (!sessionId) return; - const session = sessions.get(sessionId); - if (!session) return; + 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(() => {}); - } + // Closing either dual-screen window ends the whole presentation. + if (session.mode === "dual") { + endSession(sessionId, "window-closed").catch(() => {}); + } + }); }); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4ce669f..c95fe02 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -13,13 +13,28 @@ The current presentation model uses HTML headings: - H1 identifies the lesson title - H2 identifies the beginning of a new slide -- content following an H2 belongs to that slide until the next H2 +- H3 identifies the beginning of a new slide +- H4 identifies a subsection within the current slide +- content following an H2 or H3 belongs to that slide until the next H2 or H3 + +Pages without H2 or H3 headings are presented as a single slide. + +## Supported pages + +Hudra starts only on secure QLearn pages at `qlearn.eq.edu.au` (including +subdomains) and Canvas pages on an `instructure.com` subdomain. A supported +page must also contain a recognised lesson-content container and an H1 lesson +title. Unsupported pages receive a temporary in-page notice and are not put +into presentation mode. ## Main components ### background.js Handles extension-level behaviour and presentation launch logic. +Active session routing and window-restoration metadata is kept in +`chrome.storage.session` so presenter controls survive service-worker restarts. +Lesson content and slide previews are not persisted. ### controls.html @@ -37,6 +52,11 @@ Manages presenter navigation, controls and audience communication. Creates and manages the audience presentation experience. +### page-detection.js + +Contains the shared lesson-container and title detection used before launch +and when presentation mode starts. + ### manifest.json Defines the extension metadata, permissions and version. diff --git a/manifest.json b/manifest.json index a92696f..d222d1c 100644 --- a/manifest.json +++ b/manifest.json @@ -1,12 +1,13 @@ { "manifest_version": 3, "name": "Hudra", - "version": "0.9.2", + "version": "0.10.0", + "version_name": "0.10 Beta 1", "description": "Hudra turns Canvas/QLearn pages into responsive presentations with automatic pagination, dual-screen presenter view, audience controls, clicker support and local-only processing.", "permissions": [ "activeTab", "scripting", - "tabs", + "storage", "system.display" ], "host_permissions": [ diff --git a/page-detection.js b/page-detection.js new file mode 100644 index 0000000..f198bfe --- /dev/null +++ b/page-detection.js @@ -0,0 +1,122 @@ +(() => { + if (window.__hudraPageDetector) return; + + const contentRootSelectors = [ + "#content .user_content", + ".show-content .user_content", + ".wiki-page-content .user_content", + ".user_content", + "[role='main'] .page-content", + "[role='main'] .content" + ]; + + const titleSelectors = [ + "h1.page-title", + "h1.wiki-page-title", + "#content h1" + ]; + + function hasMeaningfulContent(element) { + return Boolean( + element?.textContent?.trim() || + element?.querySelector("img, video, iframe, svg, canvas, picture, table") + ); + } + + function resolve() { + let contentRoot = null; + let contentRootSelector = null; + + for (const selector of contentRootSelectors) { + const candidate = document.querySelector(selector); + if (candidate) { + contentRoot = candidate; + contentRootSelector = selector; + break; + } + } + + if (!contentRoot) { + return { supported: false, reason: "NO_LESSON_CONTAINER" }; + } + + const pageTitleElement = + contentRoot.querySelector("h1") || + document.querySelector(titleSelectors.join(", ")); + + if (!pageTitleElement) { + return { supported: false, reason: "NO_LESSON_TITLE" }; + } + + if (!hasMeaningfulContent(contentRoot)) { + return { supported: false, reason: "EMPTY_LESSON" }; + } + + return { + supported: true, + reason: null, + contentRoot, + contentRootSelector, + pageTitleElement + }; + } + + function inspect() { + const result = resolve(); + return { + supported: result.supported, + reason: result.reason, + contentRootSelector: result.contentRootSelector || null + }; + } + + function showNotice(message) { + document.getElementById("hudra-page-notice")?.remove(); + + const notice = document.createElement("div"); + notice.id = "hudra-page-notice"; + notice.setAttribute("role", "status"); + notice.setAttribute("aria-live", "polite"); + notice.style.cssText = [ + "position:fixed", + "z-index:2147483647", + "right:20px", + "bottom:20px", + "max-width:420px", + "box-sizing:border-box", + "padding:16px 48px 16px 18px", + "border:1px solid #bac4d2", + "border-radius:10px", + "background:#fff", + "color:#1f2937", + "box-shadow:0 8px 28px rgba(15,23,42,.2)", + "font:16px/1.4 system-ui,sans-serif" + ].join(";"); + + const text = document.createElement("span"); + text.textContent = message; + notice.appendChild(text); + + const close = document.createElement("button"); + close.type = "button"; + close.setAttribute("aria-label", "Dismiss Hudra message"); + close.textContent = "×"; + close.style.cssText = [ + "position:absolute", + "top:8px", + "right:10px", + "border:0", + "background:transparent", + "color:#374151", + "font:24px/1 system-ui,sans-serif", + "cursor:pointer" + ].join(";"); + close.addEventListener("click", () => notice.remove()); + notice.appendChild(close); + + document.body.appendChild(notice); + window.setTimeout(() => notice.remove(), 8000); + } + + window.__hudraPageDetector = { inspect, resolve, showNotice }; +})(); diff --git a/presenter.js b/presenter.js index 5cec8f1..53c53ab 100644 --- a/presenter.js +++ b/presenter.js @@ -10,36 +10,19 @@ const presenterMode = config.mode || "single"; const sessionId = config.sessionId || "standalone"; - const contentRoot = - document.querySelector( - "#content .user_content, " + - ".show-content .user_content, " + - ".wiki-page-content .user_content, " + - ".user_content" - ) || - document.querySelector( - "[role='main'] .page-content, " + - "[role='main'] .content" - ) || - document.querySelector("#content") || - document.body; + const detectedPage = window.__hudraPageDetector?.resolve(); - const pageTitleElement = - contentRoot.querySelector("h1") || - document.querySelector( - "#content h1, .page-title, .wiki-page-title, h1.page-title" + if (!detectedPage?.supported) { + window.__hudraPageDetector?.showNotice( + "Hudra couldn’t start because this page does not contain a supported lesson." ); + return; + } + + const { contentRoot, pageTitleElement } = detectedPage; let pageTitle = pageTitleElement?.textContent?.trim() || ""; - if (!pageTitle) { - pageTitle = document.title.replace(/\s*:\s*[^:]+$/, "").trim(); - } - - if (!pageTitle) { - pageTitle = "Lesson"; - } - function isHidden(element) { if (!element || element.hidden) return true; @@ -101,11 +84,6 @@ } } - if (!slideDefinitions.length) { - alert("No presentable page content was found."); - return; - } - const presentationLayer = document.createElement("div"); presentationLayer.id = "qlearn-presenter-layer"; document.body.appendChild(presentationLayer); @@ -699,20 +677,6 @@ return heading; } - function splitParagraph(paragraph) { - const text = paragraph.textContent.trim(); - const sentences = text.match(/[^.!?]+[.!?]+|[^.!?]+$/g) || [text]; - if (sentences.length < 2) return [paragraph.cloneNode(true)]; - const midpoint = Math.ceil(sentences.length / 2); - return [sentences.slice(0, midpoint), sentences.slice(midpoint)] - .filter((part) => part.length) - .map((part) => { - const copy = paragraph.cloneNode(false); - copy.textContent = part.join(" ").trim(); - return copy; - }); - } - function splitList(list) { const items = [...list.children].filter((item) => item.tagName === "LI"); if (items.length < 2) return [list.cloneNode(true)]; @@ -726,9 +690,6 @@ function divideOversizedBlock(block) { if (block.matches("ul, ol")) return splitList(block); - if (block.matches("p") && block.textContent.trim().length > 220) { - return splitParagraph(block); - } return [block.cloneNode(true)]; } @@ -1371,6 +1332,9 @@ }, getSlideCount() { return slideWrappers.length; + }, + reportState() { + sendPresenterState(); } }; @@ -1399,6 +1363,7 @@ if (command === "scrollBy") window.__qlearnPresenter.scrollBy(message.value); if (command === "resetView") window.__qlearnPresenter.resetView(); if (command === "goTo") window.__qlearnPresenter.goTo(message.value); + if (command === "reportState") window.__qlearnPresenter.reportState(); if (command === "exit") window.__qlearnPresenter.exit(false); } }); diff --git a/release.ps1 b/release.ps1 index 66eb075..a5c852a 100644 --- a/release.ps1 +++ b/release.ps1 @@ -47,21 +47,18 @@ Write-Host "" Write-Host "Building $ExtensionName $Version..." -ForegroundColor Cyan Write-Host "" -# Files and folders that should not be included in the extension ZIP -$ExcludedItems = @( - ".git", - ".github", - ".gitea", - ".vscode", - "docs", - "release", - "node_modules", - ".gitignore", - "README.md", - "ROADMAP.md", - "CONTRIBUTING.md", - "LICENSE", - "release.ps1" +# Only files required by the browser extension at runtime belong in the ZIP. +# Everything else is excluded by default, including release output, previous +# releases, scripts, repository metadata, documentation and development files. +$IncludedItems = @( + "manifest.json", + "background.js", + "page-detection.js", + "presenter.js", + "controls.html", + "controls.css", + "controls.js", + "icons" ) # Recreate the staging folder @@ -71,15 +68,20 @@ if (Test-Path $StagingFolder) { New-Item -ItemType Directory -Path $StagingFolder -Force | Out-Null -# Copy extension files into the clean staging folder -Get-ChildItem -Path $ProjectRoot -Force | ForEach-Object { - if ($ExcludedItems -notcontains $_.Name) { - Copy-Item ` - -Path $_.FullName ` - -Destination $StagingFolder ` - -Recurse ` - -Force +# Copy only the allowlisted runtime files into the clean staging folder +$IncludedItems | ForEach-Object { + $SourcePath = Join-Path $ProjectRoot $_ + + if (-not (Test-Path $SourcePath)) { + Write-Host "ERROR: Required runtime item was not found: $_" -ForegroundColor Red + exit 1 } + + Copy-Item ` + -Path $SourcePath ` + -Destination $StagingFolder ` + -Recurse ` + -Force } # Confirm that the copied release contains manifest.json @@ -121,4 +123,4 @@ Write-Host " $ZipPath" -ForegroundColor Yellow Write-Host "" Write-Host "ZIP size: $ZipSize KB" Write-Host "" -Write-Host "Ready for Gitea and the Chrome Web Store." -ForegroundColor Green \ No newline at end of file +Write-Host "Ready for Gitea and the Chrome Web Store." -ForegroundColor Green