Release Hudra 0.10 beta 1

This commit is contained in:
git
2026-07-26 16:02:28 +10:00
parent 94a82eac07
commit 51edff809a
9 changed files with 424 additions and 122 deletions
+11 -2
View File
@@ -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
+3
View File
@@ -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.
+10
View File
@@ -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
+215 -45
View File
@@ -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 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;
@@ -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 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);
@@ -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(() => {});
}
});
});
+21 -1
View File
@@ -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.
+3 -2
View File
@@ -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": [
+122
View File
@@ -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 };
})();
+12 -47
View File
@@ -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 couldnt 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);
}
});
+25 -23
View File
@@ -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